[billreminder/fresh] added timechart and some basic navigation



commit 099c5ec86045dd98961b11fe3e728ef48ce7120d
Author: Toms Bauģis <toms baugis gmail com>
Date:   Wed Jan 20 14:52:40 2010 +0000

    added timechart and some basic navigation

 data/new.ui                  |  177 +++++++++++++++-------
 src/gui/new.py               |   27 ++++
 src/gui/widgets/__init__.py  |    1 +
 src/gui/widgets/timechart.py |  340 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 487 insertions(+), 58 deletions(-)
---
diff --git a/data/new.ui b/data/new.ui
index 6d80e0e..775bca0 100644
--- a/data/new.ui
+++ b/data/new.ui
@@ -3,7 +3,7 @@
   <requires lib="gtk+" version="2.16"/>
   <!-- interface-naming-policy project-wide -->
   <object class="GtkWindow" id="main_window">
-    <property name="border_width">12</property>
+    <property name="window_position">center</property>
     <property name="default_width">600</property>
     <property name="default_height">400</property>
     <signal name="delete_event" handler="on_delete_event"/>
@@ -11,16 +11,48 @@
       <object class="GtkVBox" id="vbox1">
         <property name="visible">True</property>
         <property name="orientation">vertical</property>
-        <property name="spacing">10</property>
         <child>
-          <object class="GtkLabel" id="range_title">
+          <object class="GtkToolbar" id="toolbar1">
             <property name="visible">True</property>
-            <property name="xalign">0</property>
-            <property name="label">January 1-31, 2010</property>
-            <attributes>
-              <attribute name="weight" value="bold"/>
-              <attribute name="size" value="15000"/>
-            </attributes>
+            <child>
+              <object class="GtkToolButton" id="prev">
+                <property name="visible">True</property>
+                <property name="label" translatable="yes">toolbutton1</property>
+                <property name="use_underline">True</property>
+                <property name="stock_id">gtk-go-back</property>
+                <signal name="clicked" handler="on_prev_clicked"/>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="homogeneous">True</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkToolButton" id="next">
+                <property name="visible">True</property>
+                <property name="label" translatable="yes">toolbutton2</property>
+                <property name="use_underline">True</property>
+                <property name="stock_id">gtk-go-forward</property>
+                <signal name="clicked" handler="on_next_clicked"/>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="homogeneous">True</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkToolButton" id="home">
+                <property name="visible">True</property>
+                <property name="label" translatable="yes">toolbutton3</property>
+                <property name="use_underline">True</property>
+                <property name="stock_id">gtk-home</property>
+                <signal name="clicked" handler="on_home_clicked"/>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="homogeneous">True</property>
+              </packing>
+            </child>
           </object>
           <packing>
             <property name="expand">False</property>
@@ -28,91 +60,120 @@
           </packing>
         </child>
         <child>
-          <object class="GtkAlignment" id="time_box">
-            <property name="height_request">100</property>
+          <object class="GtkVBox" id="vbox2">
             <property name="visible">True</property>
+            <property name="border_width">12</property>
+            <property name="orientation">vertical</property>
+            <property name="spacing">20</property>
             <child>
-              <placeholder/>
+              <object class="GtkLabel" id="range_title">
+                <property name="visible">True</property>
+                <property name="xalign">0</property>
+                <property name="label">January 1-31, 2010</property>
+                <attributes>
+                  <attribute name="weight" value="bold"/>
+                  <attribute name="size" value="15000"/>
+                </attributes>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="position">0</property>
+              </packing>
             </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkHBox" id="hbox1">
-            <property name="visible">True</property>
-            <property name="spacing">10</property>
             <child>
-              <object class="GtkScrolledWindow" id="scrollbox99">
+              <object class="GtkAlignment" id="time_box">
+                <property name="height_request">60</property>
                 <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="hscrollbar_policy">automatic</property>
-                <property name="vscrollbar_policy">automatic</property>
                 <child>
-                  <object class="GtkViewport" id="upcoming_box">
-                    <property name="visible">True</property>
-                    <property name="resize_mode">queue</property>
-                    <child>
-                      <object class="GtkAlignment" id="bill_box">
-                        <property name="visible">True</property>
-                        <child>
-                          <placeholder/>
-                        </child>
-                      </object>
-                    </child>
-                  </object>
+                  <placeholder/>
                 </child>
               </object>
               <packing>
-                <property name="position">0</property>
+                <property name="expand">False</property>
+                <property name="position">1</property>
               </packing>
             </child>
             <child>
-              <object class="GtkAlignment" id="totals">
-                <property name="width_request">250</property>
+              <object class="GtkHBox" id="hbox1">
                 <property name="visible">True</property>
+                <property name="spacing">10</property>
                 <child>
-                  <object class="GtkVBox" id="vbox2">
+                  <object class="GtkScrolledWindow" id="scrollbox99">
                     <property name="visible">True</property>
-                    <property name="orientation">vertical</property>
+                    <property name="can_focus">True</property>
+                    <property name="hscrollbar_policy">automatic</property>
+                    <property name="vscrollbar_policy">automatic</property>
                     <child>
-                      <object class="GtkAlignment" id="by_type">
+                      <object class="GtkViewport" id="upcoming_box">
                         <property name="visible">True</property>
+                        <property name="resize_mode">queue</property>
                         <child>
-                          <placeholder/>
+                          <object class="GtkAlignment" id="bill_box">
+                            <property name="visible">True</property>
+                            <child>
+                              <placeholder/>
+                            </child>
+                          </object>
                         </child>
                       </object>
-                      <packing>
-                        <property name="position">0</property>
-                      </packing>
                     </child>
+                  </object>
+                  <packing>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkAlignment" id="totals">
+                    <property name="width_request">250</property>
+                    <property name="visible">True</property>
+                    <property name="top_padding">20</property>
                     <child>
-                      <object class="GtkAlignment" id="by_category">
+                      <object class="GtkVBox" id="vbox3">
                         <property name="visible">True</property>
+                        <property name="orientation">vertical</property>
+                        <child>
+                          <object class="GtkAlignment" id="by_type">
+                            <property name="height_request">80</property>
+                            <property name="visible">True</property>
+                            <child>
+                              <placeholder/>
+                            </child>
+                          </object>
+                          <packing>
+                            <property name="expand">False</property>
+                            <property name="position">0</property>
+                          </packing>
+                        </child>
+                        <child>
+                          <object class="GtkAlignment" id="by_category">
+                            <property name="visible">True</property>
+                            <child>
+                              <placeholder/>
+                            </child>
+                          </object>
+                          <packing>
+                            <property name="position">1</property>
+                          </packing>
+                        </child>
                         <child>
                           <placeholder/>
                         </child>
                       </object>
-                      <packing>
-                        <property name="position">1</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <placeholder/>
                     </child>
                   </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="position">1</property>
+                  </packing>
                 </child>
               </object>
               <packing>
-                <property name="expand">False</property>
-                <property name="position">1</property>
+                <property name="position">2</property>
               </packing>
             </child>
           </object>
           <packing>
-            <property name="position">2</property>
+            <property name="position">1</property>
           </packing>
         </child>
       </object>
diff --git a/src/gui/new.py b/src/gui/new.py
index 8661909..b75d8f8 100755
--- a/src/gui/new.py
+++ b/src/gui/new.py
@@ -33,6 +33,9 @@ class MainWindow:
         self.filtered_types = []
         self.filtered_categories = []
 
+        self.time_chart = widgets.TimeChart()
+        self.get_widget("time_box").add(self.time_chart)
+
         self.type_chart = charting.HorizontalBarChart(interactive = True)
         self.type_chart.max_bar_width = 20
         self.type_chart.legend_width = 60
@@ -92,6 +95,10 @@ class MainWindow:
 
 
     def update_graphs(self, bills):
+        bill_amounts = [(bill.dueDate, float(bill.amount)) for bill in bills]
+        self.time_chart.draw(bill_amounts, self.start_date, self.end_date)
+
+
         today = dt.date.today()
         # totals by type - paid, upcoming and overdue
         bill_types = ("Paid", "Upcoming", "Overdue")
@@ -119,6 +126,26 @@ class MainWindow:
         self.category_chart.plot(category_keys, category_amount)
 
 
+    def on_prev_clicked(self, button):
+        self.end_date = self.start_date - dt.timedelta(1)
+        first_weekday, days_in_month = calendar.monthrange(self.end_date.year, self.end_date.month)
+        self.start_date = self.end_date - dt.timedelta(days_in_month - 1)
+        self.load_bills()
+
+    def on_next_clicked(self, button):
+        self.start_date = self.end_date + dt.timedelta(1)
+        first_weekday, days_in_month = calendar.monthrange(self.start_date.year, self.start_date.month)
+        self.end_date = self.start_date + dt.timedelta(days_in_month - 1)
+        self.load_bills()
+
+    def on_home_clicked(self, button):
+        today = dt.date.today()
+        self.start_date = today - dt.timedelta(today.day - 1) #set to beginning of month
+        first_weekday, days_in_month = calendar.monthrange(today.year, today.month)
+        self.end_date = self.start_date + dt.timedelta(days_in_month - 1)
+
+        self.load_bills()
+
     def get_widget(self, name):
         """ skip one variable (huh) """
         return self.ui.get_object(name)
diff --git a/src/gui/widgets/__init__.py b/src/gui/widgets/__init__.py
index e157219..5cdf81f 100644
--- a/src/gui/widgets/__init__.py
+++ b/src/gui/widgets/__init__.py
@@ -10,6 +10,7 @@ from datepicker import DatePicker
 from genericlistview import GenericListView
 from statusbar import Statusbar
 from timeline import Timeline, Bullet
+from timechart import TimeChart
 from toolbar import Toolbar
 from trayicon import NotifyIcon
 from viewbill import ViewBill
diff --git a/src/gui/widgets/timechart.py b/src/gui/widgets/timechart.py
new file mode 100644
index 0000000..2239818
--- /dev/null
+++ b/src/gui/widgets/timechart.py
@@ -0,0 +1,340 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part of Project Hamster.
+
+# Project Hamster is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# Project Hamster is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with Project Hamster.  If not, see <http://www.gnu.org/licenses/>.
+
+import os  # for locale
+import gtk, pango
+
+import graphics
+
+import time, datetime as dt
+import calendar
+
+from bisect import bisect
+
+DAY = dt.timedelta(1)
+WEEK = dt.timedelta(7)
+
+class TimeChart(graphics.Area):
+    """this widget is kind of half finished"""
+
+    def __init__(self):
+        graphics.Area.__init__(self)
+        self.start_time, self.end_time = None, None
+        self.durations = []
+
+        self.day_start = dt.time() # ability to start day at another hour
+        self.first_weekday = self.locale_first_weekday()
+
+        self.minor_tick = None
+
+        self.tick_totals = []
+
+
+    def draw(self, durations, start_date, end_date):
+        self.durations = durations
+
+        if start_date > end_date:
+            start_date, end_date = end_date, start_date
+
+        # for hourly representation we will operate in minutes since and until the day start
+        if end_date - start_date < dt.timedelta(days=2):
+            start_time = dt.datetime.combine(start_date, self.day_start.replace(minute=0))
+            end_time = dt.datetime.combine(end_date, self.day_start.replace(minute=0)) + dt.timedelta(days = 1)
+
+            durations_start_time, durations_end_time = start_time, end_time
+            if durations:
+                durations_start_time = durations[0][0]
+                durations_end_time = durations[-1][0] + durations[-1][1]
+
+            self.start_time = min([start_time, durations_start_time])
+            self.end_time = max([end_time, durations_end_time])
+
+        else:
+            start_time = dt.datetime.combine(start_date, dt.time())
+            end_time = dt.datetime.combine(end_date, dt.time(23, 59))
+
+            durations_start_time, durations_end_time = start_time, end_time
+            if durations:
+                durations_start_time = dt.datetime.combine(durations[0][0], dt.time())
+                durations_end_time = dt.datetime.combine(durations[-1][0], dt.time())
+
+            self.start_time = min([start_time, durations_start_time])
+            self.end_time = max([end_time, durations_end_time])
+
+
+
+        days = (self.end_time - self.start_time).days
+
+
+        # determine fraction and do addittional start time move
+        if days > 125: # about 4 month -> show per month
+            self.minor_tick = dt.timedelta(days = 30) #this is approximate and will be replaced by exact days in month
+            # make sure we start on first day of month
+            self.start_time = self.start_time - dt.timedelta(self.start_time.day - 1)
+
+        elif days > 40: # bit more than month -> show per week
+            self.minor_tick = WEEK
+            # make sure we start week on first day
+            #set to monday
+            start_time = self.start_time - dt.timedelta(self.start_time.weekday() + 1)
+            # look if we need to start on sunday or monday
+            start_time = start_time + dt.timedelta(self.first_weekday)
+            if self.start_time - start_time == WEEK:
+                start_time += WEEK
+            self.start_time = start_time
+        elif days > 2: # more than two days -> show per day
+            self.minor_tick = DAY
+        else: # show per hour
+            self.minor_tick = dt.timedelta(seconds = 60 * 60)
+
+        self.count_hours()
+
+        self.redraw_canvas()
+
+
+    def on_expose(self):
+        if not self.start_time or not self.end_time:
+            return
+
+        # figure out colors
+        bg_color = self.get_style().bg[gtk.STATE_NORMAL].to_string()
+        if self.colors.is_light(bg_color):
+            bar_color = self.colors.darker(bg_color,  30)
+            tick_color = self.colors.darker(bg_color,  50)
+        else:
+            bar_color = self.colors.darker(bg_color,  -30)
+            tick_color = self.colors.darker(bg_color,  -50)
+
+        # now for the text - we want reduced contrast for relaxed visuals
+        fg_color = self.get_style().fg[gtk.STATE_NORMAL].to_string()
+        if self.colors.is_light(fg_color):
+            label_color = self.colors.darker(fg_color,  70)
+        else:
+            label_color = self.colors.darker(fg_color,  -70)
+
+
+
+        self.context.set_line_width(1)
+
+        # major ticks
+        if self.end_time - self.start_time < dt.timedelta(days=3):  # about the same day
+            major_step = dt.timedelta(seconds = 60 * 60)
+        else:
+            major_step = dt.timedelta(days=1)
+
+
+        def first_weekday(date):
+            return (date.weekday() + 1 - self.first_weekday) % 7 == 0
+
+        # count ticks so we can correctly calculate the average bar width
+        ticks = []
+        for i, (current_time, total) in enumerate(self.tick_totals):
+            # move the x bit further when ticks kick in
+            if (major_step < DAY and current_time.time() == dt.time(0,0)) \
+               or (self.minor_tick == DAY and first_weekday(current_time)) \
+               or (self.minor_tick <= WEEK and current_time.day == 1) \
+               or (current_time.timetuple().tm_yday == 1):
+                ticks.append(current_time)
+
+
+        # calculate position of each bar
+        # essentially we care more about the exact 1px gap between bars than about the bar width
+        # so after each iteration, we adjust the bar width
+        exes = {}
+
+        x = 0
+        bar_width = (float(self.width) - len(ticks) * 2)  / len(self.tick_totals)
+        remaining_ticks = len(ticks)
+        for i, (current_time, total) in enumerate(self.tick_totals):
+            # move the x bit further when ticks kick in
+            if current_time in ticks:
+                x += 2
+                remaining_ticks -= 1
+
+            exes[current_time] = (x, int(bar_width)) #saving those as getting pixel precision is not an exact science
+
+            x = int(x + bar_width)
+            bar_width = (self.width - x - remaining_ticks * 2) / float(max(len(self.tick_totals) - i - 1, 1))
+
+
+
+        def line(x, color):
+            self.context.move_to(round(x) + 0.5, 0)
+            self.set_color(color)
+            self.context.line_to(round(x) + 0.5, self.height)
+            self.context.stroke()
+
+        def somewhere_in_middle(time, color):
+            # draws line somewhere in middle of the minor tick
+            left_index = exes.keys()[bisect(exes.keys(), time) - 1]
+            #should yield something between 0 and 1
+            adjustment = self.duration_minutes(time - left_index) / float(self.duration_minutes(self.minor_tick))
+            x, width = exes[left_index]
+            line(x + round(width * adjustment) - 1, color)
+
+
+        # mark tick lines
+        current_time = self.start_time + major_step
+        while current_time < self.end_time:
+            if current_time in ticks:
+                line(exes[current_time][0] - 2, tick_color)
+            else:
+                if self.minor_tick <= WEEK and current_time.day == 1:  # month change
+                    somewhere_in_middle(current_time, tick_color)
+                # year change
+                elif current_time.timetuple().tm_yday == 1: # year change
+                    somewhere_in_middle(current_time, tick_color)
+
+            current_time += major_step
+
+
+
+        # the bars
+        for current_time, total in self.tick_totals:
+            bar_size = max(round(self.height * total * 0.8), 1)
+            x, bar_width = exes[current_time]
+
+            self.set_color(bar_color)
+
+            # rounded corners
+            self.draw_rect(x, self.height - bar_size, bar_width - 1, bar_size, 3)
+
+            # straighten out bottom rounded corners
+            self.context.rectangle(x, self.height - min(bar_size, 2), bar_width - 1, min(bar_size, 2))
+
+            self.context.fill()
+
+
+        # tick label format
+        if self.minor_tick >= dt.timedelta(days = 28): # month
+            step_format = "%b"
+
+        elif self.minor_tick == WEEK: # week
+            step_format = "%b %d"
+        elif self.minor_tick == DAY: # day
+            if (self.end_time - self.start_time) > dt.timedelta(10):
+                step_format = "%b %d"
+            else:
+                step_format = "%a"
+        else:
+            step_format = "%H<small><sup>%M</sup></small>"
+
+
+        # tick labels
+        for current_time, total in self.tick_totals:
+            # if we are on the day level, show label only on week start
+            if (self.end_time - self.start_time) > dt.timedelta(10) \
+               and self.minor_tick == DAY and first_weekday(current_time) == False:
+                continue
+
+            x, bar_width = exes[current_time]
+
+            self.set_color(label_color)
+            self.layout.set_width(int((self.width - x) * pango.SCALE))
+            self.layout.set_markup(current_time.strftime(step_format))
+            self.context.move_to(x + 2, 0)
+            self.context.show_layout(self.layout)
+
+
+    def count_hours(self):
+        #go through facts and make array of time used by our fraction
+        fractions = []
+
+        current_time = self.start_time
+
+        minor_tick = self.minor_tick
+        while current_time <= self.end_time:
+            # if minor tick is month, the starting date will have been
+            # already adjusted to the first
+            # now we have to make sure to move month by month
+            if self.minor_tick >= dt.timedelta(days=28):
+                minor_tick = dt.timedelta(calendar.monthrange(current_time.year, current_time.month)[1]) # days in month
+
+            fractions.append(current_time)
+            current_time += minor_tick
+
+        hours = [0] * len(fractions)
+
+        tick_minutes = float(self.duration_minutes(self.minor_tick))
+
+        for start_time, duration in self.durations:
+            if isinstance(duration, dt.timedelta):
+                if self.minor_tick < dt.timedelta(1):
+                    end_time = start_time + duration
+
+                    # find in which fraction the fact starts and
+                    # add duration up to the border of tick to that fraction
+                    # then move cursor to the start of next fraction
+                    first_index = bisect(fractions, start_time) - 1
+                    step_time = fractions[first_index]
+                    first_end = min(end_time, step_time + self.minor_tick)
+                    first_tick = self.duration_minutes(first_end - start_time) / tick_minutes
+
+                    hours[first_index] += first_tick
+                    step_time = step_time + self.minor_tick
+
+                    # now go through ticks until we reach end of the time
+                    while step_time < end_time:
+                        index = bisect(fractions, step_time) - 1
+                        interval = min([1, self.duration_minutes(end_time - step_time) / tick_minutes])
+                        hours[index] += interval
+
+                        step_time += self.minor_tick
+                else:
+
+                    duration_date = start_time.date() - dt.timedelta(1 if start_time.time() < self.day_start else 0)
+                    hour_index = bisect(fractions, dt.datetime.combine(duration_date, dt.time())) - 1
+                    hours[hour_index] += self.duration_minutes(duration)
+            else:
+                if isinstance(start_time, dt.datetime):
+                    duration_date = start_time.date() - dt.timedelta(1 if start_time.time() < self.day_start else 0)
+                else:
+                    duration_date = start_time
+
+                hour_index = bisect(fractions, dt.datetime.combine(duration_date, dt.time())) - 1
+                hours[hour_index] += duration
+
+
+        # now normalize
+        max_hour = max(hours)
+        hours = [hour / float(max_hour or 1) for hour in hours]
+
+        self.tick_totals = zip(fractions, hours)
+
+
+    def duration_minutes(self, duration):
+        """returns minutes from duration, otherwise we keep bashing in same math"""
+        return duration.seconds / 60 + duration.days * 24 * 60
+
+    def locale_first_weekday(self):
+        """figure if week starts on monday or sunday"""
+        first_weekday = 6 #by default settle on monday
+
+        try:
+            process = os.popen("locale first_weekday week-1stday")
+            week_offset, week_start = process.read().split('\n')[:2]
+            process.close()
+            week_start = dt.date(*time.strptime(week_start, "%Y%m%d")[:3])
+            week_offset = dt.timedelta(int(week_offset) - 1)
+            beginning = week_start + week_offset
+            first_weekday = int(beginning.strftime("%w"))
+        except:
+            print("WARNING - Failed to get first weekday from locale")
+
+        return first_weekday



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]