diff --git a/configure.ac b/configure.ac index b3b3a84170..09cd073ea7 100644 --- a/configure.ac +++ b/configure.ac @@ -73,6 +73,7 @@ src/jarabe/Makefile src/jarabe/model/Makefile src/jarabe/model/update/Makefile src/jarabe/util/Makefile +src/jarabe/util/sugarpycha/Makefile src/jarabe/util/telepathy/Makefile src/jarabe/view/Makefile src/jarabe/webservice/Makefile diff --git a/po/POTFILES.in b/po/POTFILES.in index 54a306c2ec..327ec3c6cc 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -62,6 +62,7 @@ src/jarabe/frame/devicestray.py src/jarabe/frame/zoomtoolbar.py src/jarabe/intro/window.py src/jarabe/intro/agepicker.py +src/jarabe/journal/dashboardview.py src/jarabe/journal/detailview.py src/jarabe/journal/expandedentry.py src/jarabe/journal/journalactivity.py diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am index 31ce3b005a..59e88d4751 100644 --- a/src/jarabe/journal/Makefile.am +++ b/src/jarabe/journal/Makefile.am @@ -2,6 +2,7 @@ sugardir = $(pythondir)/jarabe/journal sugar_PYTHON = \ __init__.py \ bundlelauncher.py \ + dashboardview.py \ detailview.py \ expandedentry.py \ iconview.py \ diff --git a/src/jarabe/journal/dashboardview.py b/src/jarabe/journal/dashboardview.py new file mode 100644 index 0000000000..f8c473d96d --- /dev/null +++ b/src/jarabe/journal/dashboardview.py @@ -0,0 +1,744 @@ +# Copyright (C) 2019 Hrishi Patel +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') +from gi.repository import Gtk, Gdk, Pango, GObject, GdkPixbuf +import logging + +from gettext import gettext as _ + +from sugar3.activity.activity import launch_bundle +from sugar3.activity.activity import get_activity_root +from sugar3.graphics.toolbarbox import ToolbarBox +from sugar3.graphics.icon import CellRendererIcon +from sugar3.graphics import style +from sugar3.activity.widgets import ActivityToolbarButton +from sugar3.activity.widgets import StopButton +from sugar3.graphics.toolbutton import ToolButton +from sugar3.datastore import datastore +from sugar3 import profile + +from jarabe.model import bundleregistry +from jarabe.journal import misc + +from jarabe.util.charts import Chart +from jarabe.util.readers import JournalReader +from jarabe.util.utils import get_user_fill_color + +import os +import datetime +import locale + + +COLOR1 = get_user_fill_color('str') + + +class DashboardView(Gtk.ScrolledWindow): + + def __init__(self, **kwargs): + + Gtk.ScrolledWindow.__init__(self) + + self.current_chart = None + self.x_label = "" + self.y_label = "" + self.chart_data = [] + self.mime_types = ['image/bmp', 'image/gif', 'image/jpeg', + 'image/png', 'image/tiff', + 'application/pdf', + 'application/vnd.olpc-sugar', + 'application/rtf', 'text/rtf', + 'application/epub+zip', 'text/html', + 'application/x-pdf'] + + # Detect if device is a XO + if os.path.exists('/etc/olpc-release') or \ + os.path.exists('/sys/power/olpc-pm'): + COLUMN_SPACING = 1 + STATS_WIDTH = 30 + TP_WIDTH = 45 + HMAP_WIDTH = 90 + else: + COLUMN_SPACING = 2 + STATS_WIDTH = 50 + TP_WIDTH = 75 + HMAP_WIDTH = 150 + + # ScrolledWindow as the main container + self.set_can_focus(False) + self.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + self.set_shadow_type(Gtk.ShadowType.NONE) + self.show_all() + + grid = Gtk.Grid(column_spacing=6, row_spacing=3.5) + grid.set_border_width(20) + grid.set_halign(Gtk.Align.CENTER) + self.add_with_viewport(grid) + + # VBoxes for total activities, journal entries and total files + vbox_total_activities = Gtk.VBox() + vbox_journal_entries = Gtk.VBox() + vbox_total_contribs = Gtk.VBox() + vbox_tree = Gtk.VBox() + vbox_heatmap = Gtk.VBox() + self.vbox_pie = Gtk.VBox() + + eb_total_activities = Gtk.EventBox() + eb_journal_entries = Gtk.EventBox() + eb_total_contribs = Gtk.EventBox() + eb_heatmap = Gtk.EventBox() + eb_tree = Gtk.EventBox() + eb_pie = Gtk.EventBox() + + eb_total_activities.add(vbox_total_activities) + eb_journal_entries.add(vbox_journal_entries) + eb_total_contribs.add(vbox_total_contribs) + eb_heatmap.add(vbox_heatmap) + eb_pie.add(self.vbox_pie) + eb_tree.add(vbox_tree) + + # change eventbox color + eb_total_activities.modify_bg(Gtk.StateType.NORMAL, + Gdk.color_parse("#ffffff")) + eb_journal_entries.modify_bg(Gtk.StateType.NORMAL, + Gdk.color_parse("#ffffff")) + eb_total_contribs.modify_bg(Gtk.StateType.NORMAL, + Gdk.color_parse("#ffffff")) + eb_heatmap.modify_bg(Gtk.StateType.NORMAL, + Gdk.color_parse("#ffffff")) + eb_pie.modify_bg(Gtk.StateType.NORMAL, + Gdk.color_parse("#ffffff")) + eb_tree.modify_bg(Gtk.StateType.NORMAL, + Gdk.color_parse("#ffffff")) + + label_dashboard = Gtk.Label() + text_dashboard = "{0}".format(_("Dashboard")) + label_dashboard.set_markup(text_dashboard) + + # label for total activities + label_TA = Gtk.Label() + text_TA = "{0}".format(_("Activities Installed")) + label_TA.set_markup(text_TA) + vbox_total_activities.add(label_TA) + + self.label_total_activities = Gtk.Label() + vbox_total_activities.add(self.label_total_activities) + + # label for total journal entries + label_JE = Gtk.Label() + text_JE = "{0}".format(_("Journal Entries")) + label_JE.set_markup(text_JE) + vbox_journal_entries.add(label_JE) + + self.label_journal_entries = Gtk.Label() + vbox_journal_entries.add(self.label_journal_entries) + + # label for files + label_CE = Gtk.Label() + text_CE = "{0}".format(_("Total Files")) + label_CE.set_markup(text_CE) + vbox_total_contribs.add(label_CE) + + # label for pie + label_PIE = Gtk.Label() + text_PIE = "{0}".format(_("Most used activities")) + label_PIE.set_markup(text_PIE) + self.vbox_pie.pack_start(label_PIE, False, True, 5) + + self.label_contribs = Gtk.Label() + vbox_total_contribs.add(self.label_contribs) + + # pie chart + self.labels_and_values = ChartData(self) + self.eventbox = Gtk.EventBox() + self.charts_area = ChartArea(self) + self.charts_area.connect('size-allocate', self._chart_size_allocate_cb) + self.eventbox.modify_bg(Gtk.StateType.NORMAL, Gdk.color_parse("white")) + self.eventbox.add(self.charts_area) + self.vbox_pie.pack_start(self.eventbox, True, True, 0) + self.eventbox.connect('button-press-event', self._pie_opened) + self.charts_area.set_tooltip_text(_("Click for more information")) + + # pie chart window + self.window = Gtk.Window(Gtk.WindowType.TOPLEVEL) + self.window.set_border_width(2) + self.window.set_position(Gtk.WindowPosition.CENTER_ALWAYS) + self.window.set_decorated(True) + self.window.set_resizable(False) + self.window.set_modal(True) + self.window.set_keep_above(True) + self.window.set_size_request(800, 600) + self.window.set_title("Pie Chart") + self.window.connect('delete-event', self._hide_window) + + eb_image_holder = Gtk.EventBox() + eb_image_holder.modify_bg(Gtk.StateType.NORMAL, + Gdk.color_parse("ffffff")) + self.window.modify_bg(Gtk.StateType.NORMAL, + Gdk.color_parse("#282828")) + + vbox_image = Gtk.VBox() + eb_image_holder.add(vbox_image) + + # scrolled window for details window + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_can_focus(False) + scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + scrolled_window.set_shadow_type(Gtk.ShadowType.NONE) + scrolled_window.show() + + # load pie image + # not using get_activity_root for now + self.image = Gtk.Image() + self.image.set_from_file("/tmp/screenshot.png") + vbox_image.add(self.image) + + self.vbox_holder = Gtk.VBox() + self.vbox_holder.pack_start(eb_image_holder, True, True, 0) + self.vbox_holder.pack_start(self.labels_and_values, False, False, 0) + self.window.add(scrolled_window) + scrolled_window.add_with_viewport(self.vbox_holder) + + reader = JournalReader() + self._graph_from_reader(reader) + self.current_chart = Chart("pie") + self.update_chart() + + # font + font_main = Pango.FontDescription("Granada 12") + label_JE.modify_font(font_main) + label_CE.modify_font(font_main) + label_TA.modify_font(font_main) + + font_actual = Pango.FontDescription("12") + self.label_journal_entries.modify_font(font_actual) + self.label_total_activities.modify_font(font_actual) + self.label_contribs.modify_font(font_actual) + label_dashboard.modify_font(font_actual) + + self.treeview_list = [] + self.files_list = [] + self.old_list = [] + self.heatmap_list = [] + self.journal_entries = 0 + + # treeview for Journal entries + self.liststore = Gtk.ListStore(str, str, str, object, str, + datastore.DSMetadata, str, str) + self.treeview = Gtk.TreeView(self.liststore) + self.treeview.set_headers_visible(False) + self.treeview.set_grid_lines(Gtk.TreeViewGridLines.HORIZONTAL) + self._load_data() + + for i, col_title in enumerate(["Recently Opened Activities"]): + + renderer_title = Gtk.CellRendererText() + renderer_time = Gtk.CellRendererText() + icon_renderer = CellRendererActivityIcon() + + renderer_title.set_property('ellipsize', Pango.EllipsizeMode.END) + renderer_title.set_property('ellipsize-set', True) + + column1 = Gtk.TreeViewColumn("Icon") + column1.pack_start(icon_renderer, True) + column1.add_attribute(icon_renderer, 'file-name', + 1) + column1.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) + column1.add_attribute(icon_renderer, 'xo-color', + 3) + column2 = Gtk.TreeViewColumn(col_title, renderer_title, text=0) + column2.set_min_width(200) + column3 = Gtk.TreeViewColumn(col_title, renderer_time, text=6) + + self.treeview.set_tooltip_column(0) + self.treeview.append_column(column1) + self.treeview.append_column(column2) + self.treeview.append_column(column3) + + # combobox for sort selection + cbox_store = Gtk.ListStore(str) + cbox_entries = [_("Newest"), _("Files"), _("Oldest")] + + for item in cbox_entries: + cbox_store.append([item]) + + combobox = Gtk.ComboBox.new_with_model(cbox_store) + combobox.set_halign(Gtk.Align.END) + combobox.connect('changed', self._on_name_combo_changed_cb) + renderer_text = Gtk.CellRendererText() + combobox.pack_start(renderer_text, True) + combobox.add_attribute(renderer_text, "text", 0) + combobox.set_active(0) + + self._add_to_treeview(self.treeview_list) + + selected_row = self.treeview.get_selection() + selected_row.connect('changed', self._item_select_cb) + + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_can_focus(False) + scrolled_window.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + scrolled_window.set_shadow_type(Gtk.ShadowType.NONE) + scrolled_window.show() + + hbox_tree2 = Gtk.HBox() + text_treeview = "{0}".format(_("Journal Entries")) + self.label_treeview = Gtk.Label(text_treeview) + hbox_tree2.pack_start(self.label_treeview, False, True, 10) + hbox_tree2.pack_start(combobox, True, True, 10) + + vbox_tree.pack_start(hbox_tree2, False, False, 5) + scrolled_window.add_with_viewport(self.treeview) + + # label for recent activities + label_rec = Gtk.Label(expand=False) + text_treeview = "{0}".format(_("Recently Opened Activities")) + label_rec.set_markup(text_treeview) + + vbox_tree.add(scrolled_window) + + # heatmap + label_heatmap = Gtk.Label(_("User Activity")) + grid_heatmap = Gtk.Grid(column_spacing=COLUMN_SPACING, + row_spacing=2) + grid_heatmap.set_halign(Gtk.Align.CENTER) + vbox_heatmap.pack_start(label_heatmap, False, True, 5) + vbox_heatmap.pack_start(grid_heatmap, False, True, 5) + + self.dates, self.dates_a, months = self._generate_dates() + self._build_heatmap(grid_heatmap, self.dates, self.dates_a, months) + + self.heatmap_liststore = Gtk.ListStore(str, str, str, object, str, + datastore.DSMetadata, str, str) + heatmap_treeview = Gtk.TreeView(self.heatmap_liststore) + heatmap_treeview.set_headers_visible(False) + heatmap_treeview.set_grid_lines(Gtk.TreeViewGridLines.HORIZONTAL) + + for i, col_title in enumerate(["Activity"]): + renderer_title = Gtk.CellRendererText() + icon_renderer = CellRendererActivityIcon() + renderer_time = Gtk.CellRendererText() + + column1 = Gtk.TreeViewColumn("Icon") + column1.pack_start(icon_renderer, True) + column1.add_attribute(icon_renderer, 'file-name', + 1) + column1.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) + column1.add_attribute(icon_renderer, 'xo-color', + 3) + column2 = Gtk.TreeViewColumn(col_title, renderer_title, text=0) + column3 = Gtk.TreeViewColumn(col_title, renderer_time, text=6) + + heatmap_treeview.append_column(column1) + heatmap_treeview.append_column(column2) + heatmap_treeview.append_column(column3) + + vbox_heatmap.pack_start(heatmap_treeview, False, True, 5) + + selected_row_heatmap = heatmap_treeview.get_selection() + selected_row_heatmap.connect('changed', self._item_select_cb) + + # add views to grid + grid.attach(label_dashboard, 1, 2, 1, 1) + grid.attach_next_to(eb_total_activities, label_dashboard, + Gtk.PositionType.BOTTOM, STATS_WIDTH, 35) + grid.attach_next_to(eb_journal_entries, eb_total_activities, + Gtk.PositionType.RIGHT, STATS_WIDTH, 35) + grid.attach_next_to(eb_total_contribs, eb_journal_entries, + Gtk.PositionType.RIGHT, STATS_WIDTH, 35) + grid.attach_next_to(eb_tree, eb_total_activities, + Gtk.PositionType.BOTTOM, TP_WIDTH, 90) + grid.attach_next_to(eb_pie, eb_tree, + Gtk.PositionType.RIGHT, TP_WIDTH, 90) + grid.attach_next_to(eb_heatmap, eb_tree, + Gtk.PositionType.BOTTOM, HMAP_WIDTH, 75) + grid.show_all() + + def _load_data(self, widget=None): + del self.treeview_list[:] + del self.files_list[:] + del self.old_list[:] + del self.heatmap_list[:] + + dsobjects, journal_entries = datastore.find({}) + for dsobject in dsobjects: + new = [] + new.append(dsobject.metadata['title']) + new.append(misc.get_icon_name(dsobject.metadata)) + new.append(dsobject.metadata['activity_id']) + new.append(profile.get_color()) + new.append(dsobject.get_object_id()) + new.append(dsobject.metadata) + new.append(misc.get_date(dsobject.metadata)) + new.append(dsobject.metadata['mtime']) + self.treeview_list.append(new) + self.old_list.append(new) + + # determine if a file + if dsobject.metadata['mime_type'] in self.mime_types: + new2 = [] + new2.append(dsobject.metadata['title']) + new2.append(misc.get_icon_name(dsobject.metadata)) + new2.append(dsobject.metadata['activity_id']) + new2.append(profile.get_color()) + new2.append(dsobject.get_object_id()) + new2.append(dsobject.metadata) + new2.append(misc.get_date(dsobject.metadata)) + new2.append(dsobject.metadata['mtime']) + self.files_list.append(new2) + + self.old_list = sorted(self.old_list, key=lambda x: x[7]) + self.journal_entries = journal_entries + self._add_to_treeview(self.treeview_list) + + # get number of activities installed + registry = bundleregistry.get_registry() + + self.label_total_activities.set_text(str(len(registry))) + self.label_journal_entries.set_text(str(self.journal_entries)) + self.label_contribs.set_text(str(len(self.files_list))) + + def _pie_opened(self, widget, event): + self.update_chart(300) + self.vbox_holder.pack_start(self.labels_and_values, False, False, 0) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale("/tmp/screenshot.png", + 800, 550, True) + self.image.set_from_pixbuf(pixbuf) + self.window.show() + self.window.show_all() + + def _hide_window(self, *args): + self.vbox_holder.remove(self.labels_and_values) + self.window.hide() + self.update_chart(0) + return Gtk.true + + def _build_heatmap(self, grid, dates, dates_a, months): + j = 1 + k = 1 + counter_days = 0 + counter_weeks = 0 + week_list = [0, 5, 9, 13, 18, 22, 26, 31, 35, 39, 44, 49] + months_dict = {} + + # populate dictionary + for i, item in enumerate(week_list): + months_dict[item] = months[i] + + for i in range(0, 365): + if (i % 7 == 0): + j = j + 1 + k = 0 + k = k + 1 + count = 0 + for x in range(0, len(self.old_list)): + date = self.old_list[x][7][:-16] + if date == dates[i]: + count = count + 1 + box = HeatMapBlock(dates_a[i], count, i) + box.connect('on-clicked', self._on_clicked_cb) + lab_days = Gtk.Label() + lab_months = Gtk.Label() + + # for weekdays + if(k % 2 == 0 and counter_days < 3): + day = '' + if(counter_days == 0): + day = dates_a[8][:-13] + lab_days.set_text(_(day)) + if(counter_days == 1): + day = dates_a[10][:-13] + lab_days.set_text(_(day)) + if(counter_days == 2): + day = dates_a[12][:-13] + lab_days.set_text(_(day)) + + grid.attach(lab_days, 0, k, 1, 1) + counter_days = counter_days + 1 + + # for months + if(k % 4 == 0 and counter_weeks < 54): + for key, value in months_dict.items(): + if counter_weeks == key: + lab_months.set_text(str(value)) + + if counter_weeks in week_list: + grid.attach(lab_months, j, 0, 2, 1) + + counter_weeks = counter_weeks + 1 + + grid.attach(box, j, k, 1, 1) + + def _on_clicked_cb(self, i, index): + self.heatmap_liststore.clear() + del self.heatmap_list[:] + + for y in range(0, len(self.old_list)): + date = self.old_list[y][7][:-16] + if date == self.dates[index]: + self.heatmap_list.append(self.old_list[y]) + + for item in self.heatmap_list: + self.heatmap_liststore.append(item) + + def _generate_dates(self): + year = datetime.date.today().year + + dt = datetime.datetime(year, 1, 1) + end = datetime.datetime(year, 12, 31, 23, 59, 59) + step = datetime.timedelta(days=1) + + result = [] + result_a = [] + months = [] + + while dt < end: + result_a.append(dt.strftime('%a, %b %d %Y')) + result.append(dt.strftime('%Y-%m-%d')) + dt += step + + for i in range(1, 13): + month_abre = datetime.date(year, i, 1).strftime('%b') + months.append(month_abre) + + return result, result_a, months + + def _add_to_treeview(self, tlist): + self.liststore.clear() + for item in tlist: + self.liststore.append(item) + + def _on_name_combo_changed_cb(self, combo): + tree_iter = combo.get_active_iter() + if tree_iter is not None: + model = combo.get_model() + selected_item = model[tree_iter][0] + if selected_item == "Files": + self._add_to_treeview(self.files_list) + elif selected_item == "Newest": + self._add_to_treeview(self.treeview_list) + elif selected_item == "Oldest": + self._add_to_treeview(self.old_list) + + def _item_select_cb(self, selection): + model, row = selection.get_selected() + + if row is not None: + metadata = model[row][5] + bundle_id = metadata.get('activity', '') + launch_bundle(bundle_id, model[row][4]) + + def _chart_size_allocate_cb(self, widget, allocation): + self._render_chart() + + def _render_chart(self, extra_size=0): + if self.current_chart is None or self.charts_area is None: + return + + # Resize the chart for all the screen sizes + alloc = self.vbox_pie.get_allocation() + new_width = alloc.width + extra_size + new_height = alloc.height + extra_size + + self.current_chart.width = new_width + self.current_chart.height = new_height + + try: + if self.current_chart.type == "pie": + self.current_chart.render(self) + else: + self.current_chart.render() + self.charts_area.queue_draw() + surface = self.charts_area.get_surface() + surface.write_to_png('/tmp/screenshot.png') + except (ZeroDivisionError, ValueError): + pass + + return False + + def _graph_from_reader(self, reader): + self.labels_and_values.model.clear() + self.chart_data = [] + + chart_data = reader.get_chart_data() + + # Load the data + for row in chart_data: + self._add_value(None, + label=row[0], value=float(row[1])) + + self.update_chart() + + def _add_value(self, widget, label="", value="0.0"): + data = (label, float(value)) + if data not in self.chart_data: + pos = self.labels_and_values.add_value(label, value) + self.chart_data.insert(pos, data) + self._update_chart_data() + + def update_chart(self, extra_size=0): + if self.current_chart: + self.current_chart.data_set(self.chart_data) + self.current_chart.set_x_label(self.x_label) + self.current_chart.set_y_label(self.y_label) + self._render_chart(extra_size) + + def _update_chart_data(self): + if self.current_chart is None: + return + self.current_chart.data_set(self.chart_data) + self._update_chart_labels() + + def _update_chart_labels(self, title=""): + if self.current_chart is None: + return + self.current_chart.set_title(title) + self.current_chart.set_x_label(self.x_label) + self.current_chart.set_y_label(self.y_label) + self._render_chart() + + +class ChartArea(Gtk.DrawingArea): + + def __init__(self, parent): + """A class for Draw the chart""" + super(ChartArea, self).__init__() + self._parent = parent + self.add_events(Gdk.EventMask.EXPOSURE_MASK | + Gdk.EventMask.VISIBILITY_NOTIFY_MASK) + self.connect('draw', self._draw_cb) + + def _draw_cb(self, widget, context): + alloc = self.get_allocation() + + # White Background: + context.rectangle(0, 0, alloc.width, alloc.height) + context.set_source_rgb(255, 255, 255) + context.fill() + + # Paint the chart: + chart_width = self._parent.current_chart.width + chart_height = self._parent.current_chart.height + + cxpos = alloc.width / 2 - chart_width / 2 + cypos = alloc.height / 2 - chart_height / 2 + + context.set_source_surface(self._parent.current_chart.surface, + cxpos, + cypos) + context.paint() + + def get_surface(self): + return self._parent.current_chart.surface + + +class ChartData(Gtk.TreeView): + + def __init__(self, activity): + GObject.GObject.__init__(self) + + self.model = Gtk.ListStore(str, str) + self.set_model(self.model) + + self._selection = self.get_selection() + self._selection.set_mode(Gtk.SelectionMode.SINGLE) + + # Label column + column = Gtk.TreeViewColumn(_("Activities")) + label = Gtk.CellRendererText() + + column.pack_start(label, True) + column.add_attribute(label, 'text', 0) + self.append_column(column) + + # Value column + column = Gtk.TreeViewColumn(_("Number of Instances")) + value = Gtk.CellRendererText() + + column.pack_start(value, True) + column.add_attribute(value, 'text', 1) + + self.append_column(column) + self.set_enable_search(False) + + self.show_all() + + def add_value(self, label, value): + treestore, selected = self._selection.get_selected() + if not selected: + path = 0 + + elif selected: + path = int(str(self.model.get_path(selected))) + 1 + try: + _iter = self.model.insert(path, [label, str(value)]) + except ValueError: + _iter = self.model.append([label, str(value)]) + + self.set_cursor(self.model.get_path(_iter), + self.get_column(1), + True) + + return path + + +class CellRendererActivityIcon(CellRendererIcon): + __gtype_name__ = 'DashboardCellRendererActivityIcon' + + def __init__(self): + CellRendererIcon.__init__(self) + + self.props.width = style.GRID_CELL_SIZE + self.props.height = style.GRID_CELL_SIZE + self.props.size = style.STANDARD_ICON_SIZE + self.props.mode = Gtk.CellRendererMode.ACTIVATABLE + + +class HeatMapBlock(Gtk.EventBox): + + __gsignals__ = { + 'on-clicked': (GObject.SignalFlags.RUN_FIRST, None, + (int,)), + } + + def __init__(self, date, contribs, index): + Gtk.EventBox.__init__(self) + + self._i = index + + label = Gtk.Label(" ") + tooltip = date + "\nContributions: " + str(contribs) + label.set_tooltip_text(tooltip) + + if contribs == 0: + self.modify_bg(Gtk.StateType.NORMAL, Gdk.color_parse("#cdcfd3")) + elif contribs <= 2 and contribs > 0: + self.modify_bg(Gtk.StateType.NORMAL, Gdk.color_parse("#5fce68")) + elif contribs <= 5 and contribs > 2: + self.modify_bg(Gtk.StateType.NORMAL, Gdk.color_parse("#47a94f")) + elif contribs >= 6: + self.modify_bg(Gtk.StateType.NORMAL, Gdk.color_parse("#38853e")) + + self.add(label) + self.set_events(Gdk.EventType.BUTTON_PRESS) + self.connect('button-press-event', self._on_mouse_cb) + + def _on_mouse_cb(self, widget, event): + self.emit('on-clicked', self._i) diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py index d00c7ff465..9fdb164702 100644 --- a/src/jarabe/journal/journalactivity.py +++ b/src/jarabe/journal/journalactivity.py @@ -19,7 +19,6 @@ import uuid import time - from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GdkX11 @@ -37,8 +36,10 @@ from jarabe.journal.journaltoolbox import MainToolbox from jarabe.journal.journaltoolbox import AddNewBar from jarabe.journal.journaltoolbox import DetailToolbox +from jarabe.journal.journaltoolbox import DashboardToolBox from jarabe.journal.journaltoolbox import EditToolbox from jarabe.journal.projectview import ProjectView +from jarabe.journal.dashboardview import DashboardView from jarabe.journal.listview import ListView from jarabe.journal.detailview import DetailView @@ -186,6 +187,7 @@ class JournalViews(object): MAIN = 1 DETAIL = 2 PROJECT = 3 + DASHBOARD = 4 class JournalActivity(JournalWindow): @@ -212,6 +214,7 @@ def __init__(self): self._setup_main_view() self._setup_secondary_view() self._setup_project_view() + self._setup_dashboard_view() self.add_events(Gdk.EventMask.ALL_EVENTS_MASK) self._realized_sid = self.connect('realize', self.__realize_cb) @@ -281,6 +284,8 @@ def _setup_main_view(self): self._main_toolbox = MainToolbox() self._edit_toolbox = EditToolbox(self) self._main_view = Gtk.VBox() + self._main_toolbox.connect('dashboard-clicked', + self._show_dashboard_view) self._add_new_box = AddNewBar(_('Add new project')) self._add_new_box.activate.connect(self.__add_project_activate_cb) @@ -320,6 +325,24 @@ def _setup_project_view(self): project_vbox.pack_start(self._list_view_project, True, True, 0) self._list_view_project.show() + def _setup_dashboard_view(self): + self._dashboard_toolbox = DashboardToolBox() + self._dashboard_toolbox.connect('refresh-clicked', + self._refresh_clicked_cb) + self._dashboard_toolbox.connect('journal-clicked', + self._journal_clicked_cb) + self._dashboard_holder = Gtk.VBox() + self._dashboard_view = DashboardView() + self._dashboard_holder.show_all() + self._dashboard_holder.pack_start(self._dashboard_view, True, True, 0) + + def _refresh_clicked_cb(self, i): + self._dashboard_view._load_data() + + def _journal_clicked_cb(self, i): + self._main_toolbox.clear_query() + self.show_main_view() + def get_add_new_box(self): return self._add_new_box @@ -490,6 +513,16 @@ def _show_secondary_view(self, object_id): self.set_canvas(self._secondary_view) self._secondary_view.show() + def _show_dashboard_view(self, i): + self._active_view = JournalViews.DASHBOARD + if self.canvas != self._dashboard_holder: + self.set_canvas(self._dashboard_holder) + self._dashboard_holder.show() + self._toolbox = self._dashboard_toolbox + self.set_toolbar_box(self._dashboard_toolbox) + else: + self.set_canvas(self._main_view) + def show_object(self, object_id): metadata = model.get(object_id) if metadata is None: @@ -571,7 +604,8 @@ def set_active_volume(self, mount): def show_journal(self): """Become visible and show main view""" self.reveal() - self.show_main_view() + if self._active_view != JournalViews.DASHBOARD: + self.show_main_view() def get_total_number_of_entries(self): list_view_model = self.get_list_view().get_model() diff --git a/src/jarabe/journal/journaltoolbox.py b/src/jarabe/journal/journaltoolbox.py index 72281f5d47..a7871ce696 100644 --- a/src/jarabe/journal/journaltoolbox.py +++ b/src/jarabe/journal/journaltoolbox.py @@ -76,6 +76,11 @@ class MainToolbox(ToolbarBox): + __gsignals__ = { + 'dashboard-clicked': (GObject.SignalFlags.RUN_FIRST, None, + ([])), + } + query_changed_signal = GObject.Signal('query-changed', arg_types=([object])) @@ -114,6 +119,13 @@ def __init__(self, default_what_filter=None, default_filter_type=None): self.toolbar.insert(self._proj_list_button, -1) self._proj_list_button.show() + self._dashboard_button = ToggleToolButton('activity-journal') + self._dashboard_button.set_tooltip(_('Dashboard')) + self._dashboard_button.connect('toggled', + self._dashboard_clicked_cb) + self.toolbar.insert(self._dashboard_button, -1) + self._dashboard_button.show() + if not self._proj_list_button.props.active: self._what_widget_contents = None self._what_widget = Gtk.ToolItem() @@ -482,6 +494,9 @@ def _proj_list_button_clicked_cb(self, proj_list_button): self._what_search_button.show() self._update_if_needed() + def _dashboard_clicked_cb(self, dashboard_button): + self.emit('dashboard-clicked') + def __favorite_button_toggled_cb(self, favorite_button): self._update_if_needed() @@ -517,6 +532,9 @@ def clear_query(self): self._what_search_button.show() self._proj_list_button.props.active = False + if self._dashboard_button.props.active: + self._dashboard_button.props.active = False + self._update_if_needed() @@ -770,6 +788,37 @@ def get_current_sort(self): return (self._property, self._order) +class DashboardToolBox(ToolbarBox): + + __gsignals__ = { + 'refresh-clicked': (GObject.SignalFlags.RUN_FIRST, None, + ([])), + 'journal-clicked': (GObject.SignalFlags.RUN_FIRST, None, + ([])), + } + + def __init__(self): + ToolbarBox.__init__(self) + + self.journal_button = ToolButton('activity-journal') + self.journal_button.connect('clicked', self._journal_clicked_cb) + self.journal_button.show() + self.toolbar.insert(self.journal_button, -1) + + refresh_button = ToolButton('view-refresh') + refresh_button.set_tooltip_text(_("Refresh Data")) + refresh_button.connect('clicked', self._load_data) + refresh_button.show() + self.toolbar.insert(refresh_button, -1) + self.show() + + def _load_data(self, widget=None): + self.emit('refresh-clicked') + + def _journal_clicked_cb(self, widget=None): + self.emit('journal-clicked') + + class EditToolbox(ToolbarBox): def __init__(self, journalactivity): diff --git a/src/jarabe/util/Makefile.am b/src/jarabe/util/Makefile.am index b6553ced65..e929834593 100644 --- a/src/jarabe/util/Makefile.am +++ b/src/jarabe/util/Makefile.am @@ -1,9 +1,13 @@ SUBDIRS = \ - telepathy + telepathy \ + sugarpycha sugardir = $(pythondir)/jarabe/util sugar_PYTHON = \ __init__.py \ + charts.py \ downloader.py \ httprange.py \ - normalize.py + normalize.py \ + readers.py \ + utils.py diff --git a/src/jarabe/util/charts.py b/src/jarabe/util/charts.py new file mode 100644 index 0000000000..53d78b81ac --- /dev/null +++ b/src/jarabe/util/charts.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# charts.py by: +# Agustin Zubiaga +# Gonzalo Odiard +# Manuel QuiƱones + +# Copyright (C) 2019 Hrishi Patel +# This program 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. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from jarabe.util.sugarpycha import bar +from jarabe.util.sugarpycha import line +from jarabe.util.sugarpycha import pie + +import gi +from gi.repository import GObject +import cairo + + +class Chart(GObject.GObject): + def __init__(self, type="vertical", width=600, height=460): + GObject.GObject.__init__(self) + + self.dataSet = None + self.options = None + self.surface = None + + self.type = type + self.width = width + self.height = height + + def data_set(self, data): + """Set chart data (dataSet)""" + + self.dataSet = ( + ('Puntos', [(i, l[1]) for i, l in enumerate(data)]), + ) + + self.options = { + 'legend': {'hide': True}, + 'titleFontSize': 16, + 'axis': { + 'tickFontSize': 12, + 'labelFontSize': 14, + 'lineColor': '#b3b3b3', + 'x': { + 'ticks': [dict(v=i, label=l[0]) for i, + l in enumerate(data)], + 'label': 'X', + }, + 'y': { + 'tickCount': 5, + 'label': 'Y', + } + }, + 'stroke': { + 'width': 3 + }, + 'background': { + 'chartColor': '#FFFFFF', + 'lineColor': '#CCCCCC' + }, + 'colorScheme': { + 'name': 'gradient', + 'args': { + 'initialColor': 'blue', + }, + }, + } + + def set_color_scheme(self, color='blue'): + """Set the chart color scheme""" + self.options["colorScheme"]["args"] = {'initialColor': color} + + def set_line_color(self, color='#000000'): + """Set the chart line color""" + self.options["stroke"]["color"] = color + + def set_x_label(self, text="X"): + """Set the X Label""" + self.options["axis"]["x"]["label"] = str(text) + + def set_y_label(self, text="Y"): + """Set the Y Label""" + self.options["axis"]["y"]["label"] = str(text) + + def set_type(self, type="vertical"): + """Set chart type (vertical, horizontal, line, pie)""" + self.type = type + + def set_title(self, title="Chart"): + """Set the chart title""" + self.options["title"] = title + + def render(self, sg=None): + """Draw the chart + Use the self.surface variable for show the chart""" + self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, + self.width, + self.height) + + if self.type == "vbar": + chart = bar.VerticalBarChart(self.surface, self.options) + + elif self.type == "hbar": + chart = bar.HorizontalBarChart(self.surface, + self.options) + + elif self.type == "line": + chart = line.LineChart(self.surface, self.options) + + elif self.type == "pie": + self.options["legend"] = {"hide": "False"} + chart = pie.PieChart(self.surface, self.options) + self.dataSet = [(data[0], + [[0, data[1]]]) for data in sg.chart_data] + + else: + chart = bar.HorizontalBarChart(self.surface, + self.options) + + chart.addDataset(self.dataSet) + chart.render() + + def as_png(self, file): + """Save the chart as png image""" + self.surface.write_to_png(file) diff --git a/src/jarabe/util/readers.py b/src/jarabe/util/readers.py new file mode 100644 index 0000000000..610a10a015 --- /dev/null +++ b/src/jarabe/util/readers.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# readers.py by: +# Agustin Zubiaga +# Walter Bender + +# Copyright (C) 2019 Hrishi Patel +# This program 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. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import os +import glob +import statvfs + +from gettext import gettext as _ + +from sugar3 import env +from sugar3 import profile +from sugar3.datastore import datastore + + +class FreeSpaceReader(): + """Reader for Free Space + Measure free space on disk. + """ + + def __init__(self): + """Import chart data from file.""" + + space = self._get_space() + self._reader = ((_('Free space'), space[0]), + (_('Used space'), space[1])) + self.xlabel = "" + self.ylabel = "" + + def get_chart_data(self): + """Return data suitable for pyCHA.""" + + chart_data = [] + + for row in self._reader: + label, value = row[0], row[1] + + if label == "XLabel": + self.xlabel = value + + elif label == "YLabel": + self.ylabel = value + + else: + chart_data.append((label, float(value))) + + return chart_data + + def _get_space(self): + stat = os.statvfs(env.get_profile_path()) + free_space = stat[statvfs.F_BSIZE] * stat[statvfs.F_BAVAIL] + total_space = stat[statvfs.F_BSIZE] * stat[statvfs.F_BLOCKS] + + free_space = self._get_MBs(free_space) + total_space = self._get_MBs(total_space) + used_space = total_space - free_space + + return free_space, used_space, total_space + + def _get_MBs(self, space): + space = space / (1024 * 1024) + + return space + + def _get_GBs(self, space): + space = space / 1024 + + return space + + def get_labels_name(self): + """Return the h_label and y_label names.""" + + return self.xlabel, self.ylabel + + +class TurtleReader(): + """Reader for Journal activity + + Import chart data from journal activity analysis + """ + + TACAT = {'clean':'forward', 'forward':'forward', 'back':'forward', + 'left':'forward', 'right':'forward', 'arc': 'arc', + 'xcor': 'coord', 'ycor': 'coord', 'heading': 'coord', + 'setxy2': 'setxy', 'seth': 'setxy', 'penup': 'pen', 'pendown': 'pen', + 'setpensize': 'pen', 'setcolor': 'pen', 'pensize': 'pen', + 'color': 'pen', 'setshade': 'pen', 'setgray': 'pen', 'shade': 'pen', + 'gray': 'pen', 'fillscreen': 'pen', 'startfill': 'fill', + 'stopfill': 'fill', 'plus2': 'number', 'minus2': 'number', + 'product2': 'number', 'division2': 'number', 'remainder2': 'number', + 'sqrt': 'number', 'identity2': 'number', 'and2': 'boolean', + 'or2': 'boolean', 'not': 'boolean', 'greater2': 'boolean', + 'less2': 'boolean', 'equal2': 'boolean', 'random': 'random', + 'repeat': 'repeat', 'forever': 'repeat', 'if': 'ifthen', + 'ifelse': 'ifthen', 'while': 'ifthen', 'until': 'ifthen', + 'hat': 'action', 'stack': 'action', 'storein': 'box', 'box': 'box', + 'luminance': 'sensor', 'mousex': 'sensor', 'mousey': 'sensor', + 'mousebutton2': 'sensor', 'keyboard': 'sensor', 'kbinput': 'sensor', + 'readpixel': 'sensor', 'see': 'sensor', 'time': 'sensor', + 'sound': 'sensor', 'volume': 'sensor', 'pitch': 'sensor', + 'resistance': 'sensor', 'voltage': 'sensor', 'video': 'media', + 'wait': 'media', 'camera': 'media', 'journal': 'media', + 'audio': 'media', 'show': 'media', 'setscale': 'media', + 'savepix': 'media', 'savesvg': 'media', 'mediawait': 'media', + 'mediapause': 'media', 'mediastop': 'media', 'mediaplay': 'media', + 'speak': 'media', 'sinewave': 'media', 'description': 'media', + 'push':'extras', 'pop':'extras', 'printheap':'extras', + 'clearheap':'extras', 'isheapempty2':'extras', 'chr':'extras', + 'int':'extras', 'myfunction': 'python', 'userdefined': 'python', + 'loadblock': 'python', 'loadpalette': 'python'} + TAPAL = {'forward': 'turtlep', 'arc': 'turtlep', 'coord': 'turtlep', + 'setxy': 'turtlep', 'pen': 'penp', 'fill': 'penp', 'number': 'numberp', + 'random': 'numberp', 'boolean': 'numberp', 'repeat': 'flowp', + 'ifthen': 'flowp', 'action': 'boxp', 'box': 'boxp', + 'sensor': 'sensorp', 'media': 'mediap', 'extras': 'extrasp', + 'python': 'extrasp'} + TASCORE = {'forward': 3, 'arc': 3, 'setxy': 2.5, 'coord': 4, 'turtlep': 5, + 'pen': 2.5, 'fill': 2.5, 'penp': 5, + 'number': 2.5, 'boolean': 2.5, 'random': 2.5, 'numberp': 0, + 'repeat': 2.5, 'ifthen': 7.5, 'flowp': 10, + 'box': 7.5, 'action': 7.5, 'boxp': 0, + 'media': 5, 'mediap': 0, + 'python': 5, 'extras': 5, 'extrasp': 0, + 'sensor': 5, 'sensorp': 0} + PALS = ['turtlep', 'penp', 'numberp', 'flowp', 'boxp', 'sensorp', 'mediap', + 'extrasp'] + PALNAMES = [_('turtle'), _('pen'), _('number'), _('flow'), _('box'), + _('sensor'), _('media'), _('extras')] + + def hasturtleblocks(self, path): + ''' Parse turtle block data and generate score based on rubric ''' + + fd = open(path) + blocks = [] + # block name is second token in each line + for line in fd: + tokens = line.split(',') + if len(tokens) > 1: + token = tokens[1].strip('" [') + blocks.append(token) + + score = [] + for i in range(len(self.PALS)): + score.append([self.PALNAMES[i], 0]) + cats = [] + pals = [] + + for b in blocks: + if b in self.TACAT: + if not self.TACAT[b] in cats: + cats.append(self.TACAT[b]) + for c in cats: + if c in self.TAPAL: + if not self.TAPAL[c] in pals: + pals.append(self.TAPAL[c]) + + for c in cats: + if c in self.TASCORE: + score[self.PALS.index(self.TAPAL[c])][1] += self.TASCORE[c] + + for p in pals: + if p in self.TASCORE: + score[self.PALS.index(p)][1] += self.TASCORE[p] + + return score + + def __init__(self, file): + + self._reader = self.hasturtleblocks(file) + self.xlabel = "" + self.ylabel = "" + + def get_chart_data(self): + """Return data suitable for pyCHA.""" + + chart_data = [] + + for row in self._reader: + label, value = row[0], row[1] + + if label == "XLabel": + self.xlabel = value + + elif label == "YLabel": + self.ylabel = value + + else: + chart_data.append((label, float(value))) + + return chart_data + + def get_labels_name(self): + """Return the h_label and y_label names.""" + + return self.xlabel, self.ylabel + + +MAX = 19 +class ParseJournal(): + ''' Simple parser of datastore ''' + + def __init__(self): + self._dsdict = {} + self._activity_name = [] + self._activity_count = [] + + dsobjects, journal_entries = datastore.find({}) + for dobj in dsobjects: + name = dobj.metadata['activity'] + activity_name = name.split('.')[-1] + if not activity_name.isdigit(): + self._activity_name.append(activity_name) + self._activity_count.append(1) + + def get_sorted(self): + activity_tuples = [] + for i in range(len(self._activity_name)): + activity_tuples.append((self._activity_name[i].replace('Activity', + ''), + self._activity_count[i])) + sorted_tuples = sorted(activity_tuples, key=lambda x: x[1]) + activity_list = [] + count = 0 + length = len(sorted_tuples) + for i in range(length): + if i < MAX: + activity_list.append([sorted_tuples[length - i - 1][0], + sorted_tuples[length - i - 1][1]]) + else: + count += sorted_tuples[length - i - 1][1] + if count > 0: + activity_list.append([_('Others'), count]) + return activity_list + + +class JournalReader(): + """Reader for Journal activity + + Import chart data from journal activity analysis + """ + + def __init__(self): + + self._reader = ParseJournal().get_sorted() + self.xlabel = "" + self.ylabel = "" + + def get_chart_data(self): + """Return data suitable for pyCHA.""" + + chart_data = [] + + for row in self._reader: + label, value = row[0], row[1] + + if label == "XLabel": + self.xlabel = value + + elif label == "YLabel": + self.ylabel = value + + else: + chart_data.append((label, float(value))) + + return chart_data + + def get_labels_name(self): + """Return the h_label and y_label names.""" + + return self.xlabel, self.ylabel diff --git a/src/jarabe/util/sugarpycha/Makefile.am b/src/jarabe/util/sugarpycha/Makefile.am new file mode 100644 index 0000000000..29edf690e3 --- /dev/null +++ b/src/jarabe/util/sugarpycha/Makefile.am @@ -0,0 +1,13 @@ +sugardir = $(pythondir)/jarabe/util/sugarpycha +sugar_PYTHON = \ + __init__.py \ + bar.py \ + chart.py \ + color.py \ + line.py \ + pie.py \ + polygonal.py \ + radial.py \ + scatter.py \ + stackedbar.py \ + utils.py diff --git a/src/jarabe/util/sugarpycha/__init__.py b/src/jarabe/util/sugarpycha/__init__.py new file mode 100644 index 0000000000..35bba091e4 --- /dev/null +++ b/src/jarabe/util/sugarpycha/__init__.py @@ -0,0 +1,18 @@ +# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +version = "0.6.1dev" diff --git a/src/jarabe/util/sugarpycha/bar.py b/src/jarabe/util/sugarpycha/bar.py new file mode 100644 index 0000000000..40c734f22b --- /dev/null +++ b/src/jarabe/util/sugarpycha/bar.py @@ -0,0 +1,318 @@ +# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +from jarabe.util.sugarpycha.chart import Chart, uniqueIndices +from jarabe.util.sugarpycha.color import hex2rgb +from jarabe.util.sugarpycha.utils import safe_unicode + + +class BarChart(Chart): + + def __init__(self, surface=None, options={}, debug=False): + super(BarChart, self).__init__(surface, options, debug) + self.bars = [] + self.minxdelta = 0.0 + self.barWidthForSet = 0.0 + self.barMargin = 0.0 + + def _updateXY(self): + super(BarChart, self)._updateXY() + # each dataset is centered around a line segment. that's why we + # need n + 1 divisions on the x axis + self.xscale = 1 / (self.xrange + 1.0) + + def _updateChart(self): + """Evaluates measures for vertical bars""" + stores = self._getDatasetsValues() + uniqx = uniqueIndices(stores) + + if len(uniqx) == 1: + self.minxdelta = 1.0 + else: + self.minxdelta = min([abs(uniqx[j] - uniqx[j - 1]) + for j in range(1, len(uniqx))]) + + k = self.minxdelta * self.xscale + barWidth = k * self.options.barWidthFillFraction + self.barWidthForSet = barWidth / len(stores) + self.barMargin = k * (1.0 - self.options.barWidthFillFraction) / 2 + + self.bars = [] + + def _renderChart(self, cx): + """Renders a horizontal/vertical bar chart""" + + def drawBar(bar): + stroke_width = self.options.stroke.width + ux, uy = cx.device_to_user_distance(stroke_width, stroke_width) + if ux < uy: + ux = uy + cx.set_line_width(ux) + + # gather bar proportions + x = self.layout.chart.x + self.layout.chart.w * bar.x + y = self.layout.chart.y + self.layout.chart.h * bar.y + w = self.layout.chart.w * bar.w + h = self.layout.chart.h * bar.h + + if (w < 1 or h < 1) and self.options.yvals.skipSmallValues: + return # don't draw when the bar is too small + + if self.options.stroke.shadow: + cx.set_source_rgba(0, 0, 0, 0.15) + rectangle = self._getShadowRectangle(x, y, w, h) + cx.rectangle(*rectangle) + cx.fill() + + if self.options.shouldFill or (not self.options.stroke.hide): + + if self.options.shouldFill: + cx.set_source_rgb(*self.colorScheme[bar.name]) + cx.rectangle(x, y, w, h) + cx.fill() + + if not self.options.stroke.hide: + cx.set_source_rgb(*hex2rgb(self.options.stroke.color)) + cx.rectangle(x, y, w, h) + cx.stroke() + + if bar.yerr: + self._renderError(cx, x, y, w, h, bar.yval, bar.yerr) + + # render yvals above/beside bars + if self.options.yvals.show: + cx.save() + cx.set_font_size(self.options.yvals.fontSize) + cx.set_source_rgb(*hex2rgb(self.options.yvals.fontColor)) + + if callable(self.options.yvals.renderer): + label = safe_unicode(self.options.yvals.renderer(bar), + self.options.encoding) + else: + label = safe_unicode(bar.yval, self.options.encoding) + extents = cx.text_extents(label) + labelW = extents[2] + labelH = extents[3] + + self._renderYVal(cx, label, labelW, labelH, x, y, w, h) + + cx.restore() + + cx.save() + for bar in self.bars: + drawBar(bar) + cx.restore() + + def _renderYVal(self, cx, label, width, height, x, y, w, h): + raise NotImplementedError + + +class VerticalBarChart(BarChart): + + def _updateChart(self): + """Evaluates measures for vertical bars""" + super(VerticalBarChart, self)._updateChart() + for i, (name, store) in enumerate(self.datasets): + for item in store: + if len(item) == 3: + xval, yval, yerr = item + else: + xval, yval = item + + x = (((xval - self.minxval) * self.xscale) + + self.barMargin + (i * self.barWidthForSet)) + w = self.barWidthForSet + h = abs(yval) * self.yscale + if yval > 0: + y = (1.0 - h) - self.origin + else: + y = 1 - self.origin + rect = Rect(x, y, w, h, xval, yval, name) + + if (0.0 <= rect.x <= 1.0) and (0.0 <= rect.y <= 1.0): + self.bars.append(rect) + + def _updateTicks(self): + """Evaluates bar ticks""" + super(BarChart, self)._updateTicks() + offset = (self.minxdelta * self.xscale) / 2 + self.xticks = [(tick[0] + offset, tick[1]) for tick in self.xticks] + + def _getShadowRectangle(self, x, y, w, h): + return (x - 2, y - 2, w + 4, h + 2) + + def _renderYVal(self, cx, label, labelW, labelH, barX, barY, barW, barH): + x = barX + (barW / 2.0) - (labelW / 2.0) + if self.options.yvals.snapToOrigin: + y = barY + barH - 0.5 * labelH + elif self.options.yvals.inside: + y = barY + (1.5 * labelH) + else: + y = barY - 0.5 * labelH + + # if the label doesn't fit below the bar, put it above the bar + if y > (barY + barH): + y = barY - 0.5 * labelH + + cx.move_to(x, y) + cx.show_text(label) + + def _renderError(self, cx, barX, barY, barW, barH, value, error): + center = barX + (barW / 2.0) + errorWidth = max(barW * 0.1, 5.0) + left = center - errorWidth + right = center + errorWidth + errorSize = barH * error / value + top = barY + errorSize + bottom = barY - errorSize + + cx.set_source_rgb(0, 0, 0) + cx.move_to(left, top) + cx.line_to(right, top) + cx.stroke() + cx.move_to(center, top) + cx.line_to(center, bottom) + cx.stroke() + cx.move_to(left, bottom) + cx.line_to(right, bottom) + cx.stroke() + + +class HorizontalBarChart(BarChart): + + def _updateChart(self): + """Evaluates measures for horizontal bars""" + super(HorizontalBarChart, self)._updateChart() + + for i, (name, store) in enumerate(self.datasets): + for item in store: + if len(item) == 3: + xval, yval, yerr = item + else: + xval, yval = item + yerr = 0.0 + + y = (((xval - self.minxval) * self.xscale) + + self.barMargin + (i * self.barWidthForSet)) + h = self.barWidthForSet + w = abs(yval) * self.yscale + if yval > 0: + x = self.origin + else: + x = self.origin - w + rect = Rect(x, y, w, h, xval, yval, name, yerr) + + if (0.0 <= rect.x <= 1.0) and (0.0 <= rect.y <= 1.0): + self.bars.append(rect) + + def _updateTicks(self): + """Evaluates bar ticks""" + super(BarChart, self)._updateTicks() + offset = (self.minxdelta * self.xscale) / 2 + tmp = self.xticks + self.xticks = [(1.0 - tick[0], tick[1]) for tick in self.yticks] + self.yticks = [(tick[0] + offset, tick[1]) for tick in tmp] + + def _renderLines(self, cx): + """Aux function for _renderBackground""" + if self.options.axis.y.showLines and self.yticks: + for tick in self.xticks: + self._renderLine(cx, tick, True) + if self.options.axis.x.showLines and self.xticks: + for tick in self.yticks: + self._renderLine(cx, tick, False) + + def _getShadowRectangle(self, x, y, w, h): + return (x, y - 2, w + 2, h + 4) + + def _renderXAxisLabel(self, cx, labelText): + labelText = self.options.axis.x.label + super(HorizontalBarChart, self)._renderYAxisLabel(cx, labelText) + + def _renderXAxis(self, cx): + """Draws the horizontal line representing the X axis""" + cx.new_path() + cx.move_to(self.layout.chart.x, + self.layout.chart.y + self.layout.chart.h) + cx.line_to(self.layout.chart.x + self.layout.chart.w, + self.layout.chart.y + self.layout.chart.h) + cx.close_path() + cx.stroke() + + def _renderYAxisLabel(self, cx, labelText): + labelText = self.options.axis.y.label + super(HorizontalBarChart, self)._renderXAxisLabel(cx, labelText) + + def _renderYAxis(self, cx): + # draws the vertical line representing the Y axis + cx.new_path() + cx.move_to(self.layout.chart.x + self.origin * self.layout.chart.w, + self.layout.chart.y) + cx.line_to(self.layout.chart.x + self.origin * self.layout.chart.w, + self.layout.chart.y + self.layout.chart.h) + cx.close_path() + cx.stroke() + + def _renderYVal(self, cx, label, labelW, labelH, barX, barY, barW, barH): + y = barY + (barH / 2.0) + (labelH / 2.0) + if self.options.yvals.snapToOrigin: + x = barX + 2 + elif self.options.yvals.inside: + x = barX + barW - (1.2 * labelW) + else: + x = barX + barW + 0.2 * labelW + + # if the label doesn't fit to the left of the bar, put it to the right + if x < barX: + x = barX + barW + 0.2 * labelW + + cx.move_to(x, y) + cx.show_text(label) + + def _renderError(self, cx, barX, barY, barW, barH, value, error): + center = barY + (barH / 2.0) + errorHeight = max(barH * 0.1, 5.0) + top = center + errorHeight + bottom = center - errorHeight + errorSize = barW * error / value + right = barX + barW + errorSize + left = barX + barW - errorSize + + cx.set_source_rgb(0, 0, 0) + cx.move_to(left, top) + cx.line_to(left, bottom) + cx.stroke() + cx.move_to(left, center) + cx.line_to(right, center) + cx.stroke() + cx.move_to(right, top) + cx.line_to(right, bottom) + cx.stroke() + + +class Rect(object): + + def __init__(self, x, y, w, h, xval, yval, name, yerr=0.0): + self.x, self.y, self.w, self.h = x, y, w, h + self.xval, self.yval, self.yerr = xval, yval, yerr + self.name = name + + def __str__(self): + return ("" + % (self.x, self.y, self.w, self.h, + self.xval, self.yval, self.yerr, + self.name)) diff --git a/src/jarabe/util/sugarpycha/chart.py b/src/jarabe/util/sugarpycha/chart.py new file mode 100644 index 0000000000..e69cc48b91 --- /dev/null +++ b/src/jarabe/util/sugarpycha/chart.py @@ -0,0 +1,887 @@ +# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +import copy +import inspect +import math + +import cairo + +from jarabe.util.sugarpycha.color import ColorScheme, hex2rgb, DEFAULT_COLOR +from jarabe.util.sugarpycha.utils import safe_unicode +from functools import reduce + + +class Chart(object): + + def __init__(self, surface, options={}, debug=False): + # this flag is useful to reuse this chart for drawing different data + # or use different options + self.resetFlag = False + + # initialize storage + self.datasets = [] + + # computed values used in several methods + self.layout = Layout() + self.minxval = None + self.maxxval = None + self.minyval = None + self.maxyval = None + self.xscale = 1.0 + self.yscale = 1.0 + self.xrange = None + self.yrange = None + self.origin = 0.0 + + self.xticks = [] + self.yticks = [] + + # set the default options + self.options = copy.deepcopy(DEFAULT_OPTIONS) + if options: + self.options.merge(options) + + # initialize the surface + self._initSurface(surface) + + self.colorScheme = None + + # debug mode to draw aditional hints + self.debug = debug + + def addDataset(self, dataset): + """Adds an object containing chart data to the storage hash""" + self.datasets += dataset + + def _getDatasetsKeys(self): + """Return the name of each data set""" + return [d[0] for d in self.datasets] + + def _getDatasetsValues(self): + """Return the data (value) of each data set""" + return [d[1] for d in self.datasets] + + def setOptions(self, options={}): + """Sets options of this chart""" + self.options.merge(options) + + def getSurfaceSize(self): + cx = cairo.Context(self.surface) + x, y, w, h = cx.clip_extents() + return w, h + + def reset(self): + """Resets options and datasets. + + In the next render the surface will be cleaned before any drawing. + """ + self.resetFlag = True + self.options = copy.deepcopy(DEFAULT_OPTIONS) + self.datasets = [] + + def render(self, surface=None, options={}): + """Renders the chart with the specified options. + + The optional parameters can be used to render a chart in a different + surface with new options. + """ + self._update(options) + if surface: + self._initSurface(surface) + + cx = cairo.Context(self.surface) + + # calculate area data + surface_width, surface_height = self.getSurfaceSize() + self.layout.update(cx, self.options, surface_width, surface_height, + self.xticks, self.yticks) + + self._renderBackground(cx) + if self.debug: + self.layout.render(cx) + self._renderChart(cx) + self._renderAxis(cx) + self._renderTitle(cx) + self._renderLegend(cx) + + def clean(self): + """Clears the surface with a white background.""" + cx = cairo.Context(self.surface) + cx.save() + cx.set_source_rgb(1, 1, 1) + cx.paint() + cx.restore() + + def _setColorscheme(self): + """Sets the colorScheme used for the chart using the + options.colorScheme option + """ + name = self.options.colorScheme.name + keys = self._getDatasetsKeys() + colorSchemeClass = ColorScheme.getColorScheme(name, None) + if colorSchemeClass is None: + raise ValueError('Color scheme "%s" is invalid!' % name) + + # Remove invalid args before calling the constructor + kwargs = dict(self.options.colorScheme.args) + validArgs = inspect.getargspec(colorSchemeClass.__init__)[0] + kwargs = dict([(k, v) for k, v in list(kwargs.items()) if k in validArgs]) + self.colorScheme = colorSchemeClass(keys, **kwargs) + + def _initSurface(self, surface): + self.surface = surface + + if self.resetFlag: + self.resetFlag = False + self.clean() + + def _update(self, options={}): + """Update all the information needed to render the chart""" + self.setOptions(options) + self._setColorscheme() + self._updateXY() + self._updateChart() + self._updateTicks() + + def _updateXY(self): + """Calculates all kinds of metrics for the x and y axis""" + x_range_is_defined = self.options.axis.x.range is not None + y_range_is_defined = self.options.axis.y.range is not None + + if not x_range_is_defined or not y_range_is_defined: + stores = self._getDatasetsValues() + + # gather data for the x axis + if x_range_is_defined: + self.minxval, self.maxxval = self.options.axis.x.range + else: + xdata = [pair[0] for pair in reduce(lambda a, b: a + b, stores)] + self.minxval = float(min(xdata)) + self.maxxval = float(max(xdata)) + if self.minxval * self.maxxval > 0 and self.minxval > 0: + self.minxval = 0.0 + + self.xrange = self.maxxval - self.minxval + if self.xrange == 0: + self.xscale = 1.0 + else: + self.xscale = 1.0 / self.xrange + + # gather data for the y axis + if y_range_is_defined: + self.minyval, self.maxyval = self.options.axis.y.range + else: + ydata = [pair[1] for pair in reduce(lambda a, b: a + b, stores)] + self.minyval = float(min(ydata)) + self.maxyval = float(max(ydata)) + if self.minyval * self.maxyval > 0 and self.minyval > 0: + self.minyval = 0.0 + + self.yrange = self.maxyval - self.minyval + if self.yrange == 0: + self.yscale = 1.0 + else: + self.yscale = 1.0 / self.yrange + + if self.minyval * self.maxyval < 0: # different signs + self.origin = abs(self.minyval) * self.yscale + else: + self.origin = 0.0 + + def _updateChart(self): + raise NotImplementedError + + def _updateTicks(self): + """Evaluates ticks for x and y axis. + + You should call _updateXY before because that method computes the + values of xscale, minxval, yscale, and other attributes needed for + this method. + """ + stores = self._getDatasetsValues() + + # evaluate xTicks + self.xticks = [] + if self.options.axis.x.ticks: + for tick in self.options.axis.x.ticks: + if not isinstance(tick, Option): + tick = Option(tick) + if tick.label is None: + label = str(tick.v) + else: + label = tick.label + pos = self.xscale * (tick.v - self.minxval) + if 0.0 <= pos <= 1.0: + self.xticks.append((pos, label)) + + elif self.options.axis.x.interval > 0: + interval = self.options.axis.x.interval + label = (divmod(self.minxval, interval)[0] + 1) * interval + pos = self.xscale * (label - self.minxval) + prec = self.options.axis.x.tickPrecision + while 0.0 <= pos <= 1.0: + pretty_label = round(label, prec) + if prec == 0: + pretty_label = int(pretty_label) + self.xticks.append((pos, pretty_label)) + label += interval + pos = self.xscale * (label - self.minxval) + + elif self.options.axis.x.tickCount > 0: + uniqx = list(range(len(uniqueIndices(stores)) + 1)) + roughSeparation = self.xrange / self.options.axis.x.tickCount + i = j = 0 + while i < len(uniqx) and j < self.options.axis.x.tickCount: + if (uniqx[i] - self.minxval) >= (j * roughSeparation): + pos = self.xscale * (uniqx[i] - self.minxval) + if 0.0 <= pos <= 1.0: + self.xticks.append((pos, uniqx[i])) + j += 1 + i += 1 + + # evaluate yTicks + self.yticks = [] + if self.options.axis.y.ticks: + for tick in self.options.axis.y.ticks: + if not isinstance(tick, Option): + tick = Option(tick) + if tick.label is None: + label = str(tick.v) + else: + label = tick.label + pos = 1.0 - (self.yscale * (tick.v - self.minyval)) + if 0.0 <= pos <= 1.0: + self.yticks.append((pos, label)) + + elif self.options.axis.y.interval > 0: + interval = self.options.axis.y.interval + label = (divmod(self.minyval, interval)[0] + 1) * interval + pos = 1.0 - (self.yscale * (label - self.minyval)) + prec = self.options.axis.y.tickPrecision + while 0.0 <= pos <= 1.0: + pretty_label = round(label, prec) + if prec == 0: + pretty_label = int(pretty_label) + self.yticks.append((pos, pretty_label)) + label += interval + pos = 1.0 - (self.yscale * (label - self.minyval)) + + elif self.options.axis.y.tickCount > 0: + prec = self.options.axis.y.tickPrecision + num = self.yrange / self.options.axis.y.tickCount + if (num < 1 and prec == 0): + roughSeparation = 1 + else: + roughSeparation = round(num, prec) + + for i in range(self.options.axis.y.tickCount + 1): + yval = self.minyval + (i * roughSeparation) + pos = 1.0 - ((yval - self.minyval) * self.yscale) + if 0.0 <= pos <= 1.0: + pretty_label = round(yval, prec) + if prec == 0: + pretty_label = int(pretty_label) + self.yticks.append((pos, pretty_label)) + + def _renderBackground(self, cx): + """Renders the background area of the chart""" + if self.options.background.hide: + return + + cx.save() + + if self.options.background.baseColor: + cx.set_source_rgb(*hex2rgb(self.options.background.baseColor)) + cx.paint() + + if self.options.background.chartColor: + cx.set_source_rgb(*hex2rgb(self.options.background.chartColor)) + surface_width, surface_height = self.getSurfaceSize() + cx.rectangle(self.options.padding.left, self.options.padding.top, + surface_width - (self.options.padding.left + + self.options.padding.right), + surface_height - (self.options.padding.top + + self.options.padding.bottom)) + cx.fill() + + if self.options.background.lineColor: + cx.set_source_rgb(*hex2rgb(self.options.background.lineColor)) + cx.set_line_width(self.options.axis.lineWidth) + self._renderLines(cx) + + cx.restore() + + def _renderLines(self, cx): + """Aux function for _renderBackground""" + if self.options.axis.y.showLines and self.yticks: + for tick in self.yticks: + self._renderLine(cx, tick, False) + if self.options.axis.x.showLines and self.xticks: + for tick in self.xticks: + self._renderLine(cx, tick, True) + + def _renderLine(self, cx, tick, horiz): + """Aux function for _renderLines""" + x1, x2, y1, y2 = (0, 0, 0, 0) + if horiz: + x1 = x2 = tick[0] * self.layout.chart.w + self.layout.chart.x + y1 = self.layout.chart.y + y2 = y1 + self.layout.chart.h + else: + x1 = self.layout.chart.x + x2 = x1 + self.layout.chart.w + y1 = y2 = tick[0] * self.layout.chart.h + self.layout.chart.y + + cx.new_path() + cx.move_to(x1, y1) + cx.line_to(x2, y2) + cx.close_path() + cx.stroke() + + def _renderChart(self, cx): + raise NotImplementedError + + def _renderTick(self, cx, tick, x, y, x2, y2, rotate, text_position): + """Aux method for _renderXTick and _renderYTick""" + if callable(tick): + return + + cx.new_path() + cx.move_to(x, y) + cx.line_to(x2, y2) + cx.close_path() + cx.stroke() + cx.set_source_rgb(*hex2rgb('#000000')) + cx.select_font_face(self.options.axis.tickFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cx.set_font_size(self.options.axis.tickFontSize) + + label = safe_unicode(tick[1], self.options.encoding) + xb, yb, width, height, xa, ya = cx.text_extents(label) + + x, y = text_position + + if rotate: + cx.save() + cx.translate(x, y) + cx.rotate(math.radians(rotate)) + x = -width / 2.0 + y = -height / 2.0 + cx.move_to(x - xb, y - yb) + cx.show_text(label) + cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) + if self.debug: + cx.rectangle(x, y, width, height) + cx.stroke() + cx.restore() + else: + x -= width / 2.0 + y -= height / 2.0 + cx.move_to(x - xb, y - yb) + cx.show_text(label) + cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) + if self.debug: + cx.rectangle(x, y, width, height) + cx.stroke() + + return label + + def _renderYTick(self, cx, tick): + """Aux method for _renderAxis""" + x = self.layout.y_ticks.x + self.layout.y_ticks.w + y = self.layout.y_ticks.y + tick[0] * self.layout.y_ticks.h + + text_position = ((self.layout.y_tick_labels.x + + self.layout.y_tick_labels.w / 2.0 - 5), y) + + return self._renderTick(cx, tick, + x, y, + x - self.options.axis.tickSize, y, + self.options.axis.y.rotate, + text_position) + + def _renderXTick(self, cx, tick): + """Aux method for _renderAxis""" + + x = self.layout.x_ticks.x + tick[0] * self.layout.x_ticks.w + y = self.layout.x_ticks.y + + text_position = (x, (self.layout.x_tick_labels.y + 5 + + self.layout.x_tick_labels.h / 2.0)) + + return self._renderTick(cx, tick, + x, y, + x, y + self.options.axis.tickSize, + self.options.axis.x.rotate, + text_position) + + def _renderAxisLabel(self, cx, label, x, y, vertical=False): + cx.save() + cx.select_font_face(self.options.axis.labelFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_BOLD) + cx.set_font_size(self.options.axis.labelFontSize) + cx.set_source_rgb(*hex2rgb(self.options.axis.labelColor)) + + xb, yb, width, height, xa, ya = cx.text_extents(label) + + if vertical: + y = y + width / 2.0 + cx.move_to(x - xb, y - yb) + cx.translate(x, y) + cx.rotate(-math.radians(90)) + cx.move_to(-xb, -yb) + cx.show_text(label) + if self.debug: + cx.rectangle(0, 0, width, height) + cx.stroke() + else: + x = x - width / 2.0 + cx.move_to(x - xb, y - yb) + cx.show_text(label) + if self.debug: + cx.rectangle(x, y, width, height) + cx.stroke() + cx.restore() + + def _renderYAxisLabel(self, cx, label_text): + label = safe_unicode(label_text, self.options.encoding) + x = self.layout.y_label.x + y = self.layout.y_label.y + self.layout.y_label.h / 2.0 + self._renderAxisLabel(cx, label, x, y, True) + + def _renderYAxis(self, cx): + """Draws the vertical line represeting the Y axis""" + cx.new_path() + cx.move_to(self.layout.chart.x, self.layout.chart.y) + cx.line_to(self.layout.chart.x, + self.layout.chart.y + self.layout.chart.h) + cx.close_path() + cx.stroke() + + def _renderXAxisLabel(self, cx, label_text): + label = safe_unicode(label_text, self.options.encoding) + x = self.layout.x_label.x + self.layout.x_label.w / 2.0 + y = self.layout.x_label.y + self._renderAxisLabel(cx, label, x, y, False) + + def _renderXAxis(self, cx): + """Draws the horizontal line representing the X axis""" + cx.new_path() + y = self.layout.chart.y + (1.0 - self.origin) * self.layout.chart.h + cx.move_to(self.layout.chart.x, y) + cx.line_to(self.layout.chart.x + self.layout.chart.w, y) + cx.close_path() + cx.stroke() + + def _renderAxis(self, cx): + """Renders axis""" + if self.options.axis.x.hide and self.options.axis.y.hide: + return + + cx.save() + cx.set_line_width(self.options.axis.lineWidth) + + if not self.options.axis.y.hide: + if self.yticks: + for tick in self.yticks: + self._renderYTick(cx, tick) + + if self.options.axis.y.label: + self._renderYAxisLabel(cx, self.options.axis.y.label) + + cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) + self._renderYAxis(cx) + + if not self.options.axis.x.hide: + if self.xticks: + for tick in self.xticks: + self._renderXTick(cx, tick) + + if self.options.axis.x.label: + self._renderXAxisLabel(cx, self.options.axis.x.label) + + cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) + self._renderXAxis(cx) + + cx.restore() + + def _renderTitle(self, cx): + if self.options.title: + cx.save() + cx.select_font_face(self.options.titleFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_BOLD) + cx.set_font_size(self.options.titleFontSize) + cx.set_source_rgb(*hex2rgb(self.options.titleColor)) + + title = safe_unicode(self.options.title, self.options.encoding) + extents = cx.text_extents(title) + title_width = extents[2] + + x = (self.layout.title.x + + self.layout.title.w / 2.0 + - title_width / 2.0) + y = self.layout.title.y - extents[1] - 10 + + cx.move_to(x, y) + cx.show_text(title) + + cx.restore() + + def _renderLegend(self, cx): + """This function adds a legend to the chart""" + if self.options.legend.hide: + return + + surface_width, surface_height = self.getSurfaceSize() + + # Compute legend dimensions + padding = 4 + bullet = 15 + width = 0 + height = padding + keys = self._getDatasetsKeys() + cx.select_font_face(self.options.legend.legendFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cx.set_font_size(self.options.legend.legendFontSize) + for key in keys: + key = safe_unicode(key, self.options.encoding) + extents = cx.text_extents(key) + width = max(extents[2], width) + height += max(extents[3], bullet) + padding + width = padding + bullet + padding + width + padding + + # Compute legend position + legend = self.options.legend + if legend.position.right is not None: + legend.position.left = (surface_width + - legend.position.right + - width) + if legend.position.bottom is not None: + legend.position.top = (surface_height + - legend.position.bottom + - height) + + # Draw the legend + cx.save() + cx.rectangle(self.options.legend.position.left, + self.options.legend.position.top, + width, height) + cx.set_source_rgba(1, 1, 1, self.options.legend.opacity) + cx.fill_preserve() + cx.set_line_width(self.options.legend.borderWidth) + cx.set_source_rgb(*hex2rgb(self.options.legend.borderColor)) + cx.stroke() + + def drawKey(key, x, y, text_height): + cx.rectangle(x, y, bullet, bullet) + cx.set_source_rgb(*self.colorScheme[key]) + cx.fill_preserve() + cx.set_source_rgb(0, 0, 0) + cx.stroke() + cx.move_to(x + bullet + padding, + y + bullet / 2.0 + text_height / 2.0) + cx.show_text(key) + + cx.set_line_width(1) + x = self.options.legend.position.left + padding + y = self.options.legend.position.top + padding + for key in keys: + extents = cx.text_extents(key) + drawKey(key, x, y, extents[3]) + y += max(extents[3], bullet) + padding + + cx.restore() + + +def uniqueIndices(arr): + """Return a list with the indexes of the biggest element of arr""" + return list(range(max([len(a) for a in arr]))) + + +class Area(object): + """Simple rectangle to hold an area coordinates and dimensions""" + + def __init__(self, x=0.0, y=0.0, w=0.0, h=0.0): + self.x, self.y, self.w, self.h = x, y, w, h + + def __str__(self): + msg = "" + return msg % (self.x, self.y, self.w, self.h) + + +def get_text_extents(cx, text, font, font_size, encoding): + if text: + cx.save() + cx.select_font_face(font, + cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + cx.set_font_size(font_size) + safe_text = safe_unicode(text, encoding) + extents = cx.text_extents(safe_text) + cx.restore() + return extents[2:4] + return (0.0, 0.0) + + +class Layout(object): + """Set of chart areas""" + + def __init__(self): + self.title = Area() + self.x_label = Area() + self.y_label = Area() + self.x_tick_labels = Area() + self.y_tick_labels = Area() + self.x_ticks = Area() + self.y_ticks = Area() + self.chart = Area() + + self._areas = ( + (self.title, (1, 126 / 255.0, 0)), # orange + (self.y_label, (41 / 255.0, 91 / 255.0, 41 / 255.0)), # grey + (self.x_label, (41 / 255.0, 91 / 255.0, 41 / 255.0)), # grey + (self.y_tick_labels, (0, 115 / 255.0, 0)), # green + (self.x_tick_labels, (0, 115 / 255.0, 0)), # green + (self.y_ticks, (229 / 255.0, 241 / 255.0, 18 / 255.0)), # yellow + (self.x_ticks, (229 / 255.0, 241 / 255.0, 18 / 255.0)), # yellow + (self.chart, (75 / 255.0, 75 / 255.0, 1.0)), # blue + ) + + def update(self, cx, options, width, height, xticks, yticks): + self.title.x = options.padding.left + self.title.y = options.padding.top + self.title.w = width - (options.padding.left + options.padding.right) + self.title.h = get_text_extents(cx, + options.title, + options.titleFont, + options.titleFontSize, + options.encoding)[1] + x_axis_label_height = get_text_extents(cx, + options.axis.x.label, + options.axis.labelFont, + options.axis.labelFontSize, + options.encoding)[1] + y_axis_label_width = get_text_extents(cx, + options.axis.y.label, + options.axis.labelFont, + options.axis.labelFontSize, + options.encoding)[1] + + x_axis_tick_labels_height = self._getAxisTickLabelsSize(cx, options, + options.axis.x, + xticks)[1] + y_axis_tick_labels_width = self._getAxisTickLabelsSize(cx, options, + options.axis.y, + yticks)[0] + + self.y_label.x = options.padding.left + self.y_label.y = options.padding.top + self.title.h + self.y_label.w = y_axis_label_width + self.y_label.h = height - (options.padding.bottom + + options.padding.top + + x_axis_label_height + + x_axis_tick_labels_height + + options.axis.tickSize + + self.title.h) + self.x_label.x = (options.padding.left + + y_axis_label_width + + y_axis_tick_labels_width + + options.axis.tickSize) + self.x_label.y = height - (options.padding.bottom + + x_axis_label_height) + self.x_label.w = width - (options.padding.left + + options.padding.right + + options.axis.tickSize + + y_axis_label_width + + y_axis_tick_labels_width) + self.x_label.h = x_axis_label_height + + self.y_tick_labels.x = self.y_label.x + self.y_label.w + self.y_tick_labels.y = self.y_label.y + self.y_tick_labels.w = y_axis_tick_labels_width + self.y_tick_labels.h = self.y_label.h + + self.x_tick_labels.x = self.x_label.x + self.x_tick_labels.y = self.x_label.y - x_axis_tick_labels_height + self.x_tick_labels.w = self.x_label.w + self.x_tick_labels.h = x_axis_tick_labels_height + + self.y_ticks.x = self.y_tick_labels.x + self.y_tick_labels.w + self.y_ticks.y = self.y_tick_labels.y + self.y_ticks.w = options.axis.tickSize + self.y_ticks.h = self.y_label.h + + self.x_ticks.x = self.x_tick_labels.x + self.x_ticks.y = self.x_tick_labels.y - options.axis.tickSize + self.x_ticks.w = self.x_label.w + self.x_ticks.h = options.axis.tickSize + + self.chart.x = self.y_ticks.x + self.y_ticks.w + self.chart.y = self.title.y + self.title.h + self.chart.w = self.x_ticks.w + self.chart.h = self.y_ticks.h + + def render(self, cx): + + def draw_area(area, r, g, b): + cx.rectangle(area.x, area.y, area.w, area.h) + cx.set_source_rgba(r, g, b, 0.5) + cx.fill() + + cx.save() + for area, color in self._areas: + draw_area(area, *color) + cx.restore() + + def _getAxisTickLabelsSize(self, cx, options, axis, ticks): + cx.save() + cx.select_font_face(options.axis.tickFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cx.set_font_size(options.axis.tickFontSize) + + max_width = max_height = 0.0 + if not axis.hide: + extents = [cx.text_extents(safe_unicode( + tick[1], options.encoding, + ))[2:4] # get width and height as a tuple + for tick in ticks] + if extents: + widths, heights = list(zip(*extents)) + max_width, max_height = max(widths), max(heights) + if axis.rotate: + radians = math.radians(axis.rotate) + sin = math.sin(radians) + cos = math.cos(radians) + max_width, max_height = ( + max_width * cos + max_height * sin, + max_width * sin + max_height * cos, + ) + cx.restore() + return max_width, max_height + + +class Option(dict): + """Useful dict that allow attribute-like access to its keys""" + + def __getattr__(self, name): + if name in list(self.keys()): + return self[name] + else: + raise AttributeError(name) + + def merge(self, other): + """Recursive merge with other Option or dict object""" + for key, value in list(other.items()): + if key in self: + if isinstance(self[key], Option): + self[key].merge(other[key]) + else: + self[key] = other[key] + + +DEFAULT_OPTIONS = Option( + axis=Option( + lineWidth=1.0, + lineColor='#0f0000', + tickSize=3.0, + labelColor='#666666', + labelFont='Tahoma', + labelFontSize=9, + tickFont='Tahoma', + tickFontSize=9, + x=Option( + hide=False, + ticks=None, + tickCount=10, + tickPrecision=1, + range=None, + rotate=None, + label=None, + interval=0, + showLines=False, + ), + y=Option( + hide=False, + ticks=None, + tickCount=10, + tickPrecision=1, + range=None, + rotate=None, + label=None, + interval=0, + showLines=True, + ), + ), + background=Option( + hide=False, + baseColor=None, + chartColor='#f5f5f5', + lineColor='#ffffff', + lineWidth=1.5, + ), + legend=Option( + opacity=0.8, + borderColor='#000000', + borderWidth=2, + hide=False, + legendFont='Tahoma', + legendFontSize=9, + position=Option(top=20, left=40, bottom=None, right=None), + ), + padding=Option( + left=10, + right=10, + top=10, + bottom=10, + ), + stroke=Option( + color='#000000', + hide=False, + shadow=True, + width=1 + ), + yvals=Option( + show=False, + inside=False, + fontSize=11, + fontColor='#000000', + skipSmallValues=True, + snapToOrigin=False, + renderer=None + ), + fillOpacity=1.0, + shouldFill=True, + barWidthFillFraction=0.75, + pieRadius=0.4, + colorScheme=Option( + name='gradient', + args=Option( + initialColor=DEFAULT_COLOR, + colors=None, + ), + ), + title=None, + titleColor='#000000', + titleFont='Tahoma', + titleFontSize=12, + encoding='utf-8', +) diff --git a/src/jarabe/util/sugarpycha/color.py b/src/jarabe/util/sugarpycha/color.py new file mode 100644 index 0000000000..5e38bd87b4 --- /dev/null +++ b/src/jarabe/util/sugarpycha/color.py @@ -0,0 +1,203 @@ +# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez +# 2009 by Yaco S.L. +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +import math + +from jarabe.util.sugarpycha.utils import clamp + + +DEFAULT_COLOR = '#3c581a' + + +def hex2rgb(hexstring, digits=2): + """Converts a hexstring color to a rgb tuple. + + Example: #ff0000 -> (1.0, 0.0, 0.0) + + digits is an integer number telling how many characters should be + interpreted for each component in the hexstring. + """ + if isinstance(hexstring, (tuple, list)): + return hexstring + + top = float(int(digits * 'f', 16)) + r = int(hexstring[1:digits + 1], 16) + g = int(hexstring[digits + 1:digits * 2 + 1], 16) + b = int(hexstring[digits * 2 + 1:digits * 3 + 1], 16) + return r / top, g / top, b / top + + +def rgb2hsv(r, g, b): + """Converts a RGB color into a HSV one + + See http://en.wikipedia.org/wiki/HSV_color_space + """ + maximum = max(r, g, b) + minimum = min(r, g, b) + if maximum == minimum: + h = 0.0 + elif maximum == r: + h = 60.0 * ((g - b) / (maximum - minimum)) + 360.0 + if h >= 360.0: + h -= 360.0 + elif maximum == g: + h = 60.0 * ((b - r) / (maximum - minimum)) + 120.0 + elif maximum == b: + h = 60.0 * ((r - g) / (maximum - minimum)) + 240.0 + + if maximum == 0.0: + s = 0.0 + else: + s = 1.0 - (minimum / maximum) + + v = maximum + + return h, s, v + + +def hsv2rgb(h, s, v): + """Converts a HSV color into a RGB one + + See http://en.wikipedia.org/wiki/HSV_color_space + """ + hi = int(math.floor(h / 60.0)) % 6 + f = (h / 60.0) - hi + p = v * (1 - s) + q = v * (1 - f * s) + t = v * (1 - (1 - f) * s) + + if hi == 0: + r, g, b = v, t, p + elif hi == 1: + r, g, b = q, v, p + elif hi == 2: + r, g, b = p, v, t + elif hi == 3: + r, g, b = p, q, v + elif hi == 4: + r, g, b = t, p, v + elif hi == 5: + r, g, b = v, p, q + + return r, g, b + + +def lighten(r, g, b, amount): + """Return a lighter version of the color (r, g, b)""" + return (clamp(0.0, 1.0, r + amount), + clamp(0.0, 1.0, g + amount), + clamp(0.0, 1.0, b + amount)) + + +basicColors = dict( + red='#6d1d1d', + green=DEFAULT_COLOR, + blue='#224565', + grey='#444444', + black='#000000', + darkcyan='#305755', + ) + + +class ColorSchemeMetaclass(type): + """This metaclass is used to autoregister all ColorScheme classes""" + + def __new__(mcs, name, bases, dict): + klass = type.__new__(mcs, name, bases, dict) + klass.registerColorScheme() + return klass + + +class ColorScheme(dict): + """A color scheme is a dictionary where the keys match the keys + constructor argument and the values are colors""" + __metaclass__ = ColorSchemeMetaclass + __registry__ = {} + + def __init__(self, keys): + super(ColorScheme, self).__init__() + + @classmethod + def registerColorScheme(cls): + key = cls.__name__.replace('ColorScheme', '').lower() + if key: + cls.__registry__[key] = cls + + @classmethod + def getColorScheme(cls, name, default=None): + return cls.__registry__.get(name, default) + + +class GradientColorScheme(ColorScheme): + """In this color scheme each color is a lighter version of initialColor. + + This difference is computed based on the number of keys. + + The initialColor is given in a hex string format. + """ + + def __init__(self, keys, initialColor=DEFAULT_COLOR): + super(GradientColorScheme, self).__init__(keys) + if initialColor in basicColors: + initialColor = basicColors[initialColor] + + r, g, b = hex2rgb(initialColor) + light = 1.0 / (len(keys) * 2) + + for i, key in enumerate(keys): + self[key] = lighten(r, g, b, light * i) + + +class FixedColorScheme(ColorScheme): + """In this color scheme fixed colors are used. + + These colors are provided as a list argument in the constructor. + """ + + def __init__(self, keys, colors=[]): + super(FixedColorScheme, self).__init__(keys) + + if len(keys) != len(colors): + raise ValueError("You must provide as many colors as datasets " + "for the fixed color scheme") + + for i, key in enumerate(keys): + self[key] = hex2rgb(colors[i]) + + +class RainbowColorScheme(ColorScheme): + """In this color scheme the rainbow is divided in N pieces + where N is the number of datasets. + + So each dataset gets a color of the rainbow. + """ + + def __init__(self, keys, initialColor=DEFAULT_COLOR): + super(RainbowColorScheme, self).__init__(keys) + if initialColor in basicColors: + initialColor = basicColors[initialColor] + + r, g, b = hex2rgb(initialColor) + h, s, v = rgb2hsv(r, g, b) + + angleDelta = 360.0 / (len(keys) + 1) + for key in keys: + self[key] = hsv2rgb(h, s, v) + h += angleDelta + if h >= 360.0: + h -= 360.0 diff --git a/src/jarabe/util/sugarpycha/line.py b/src/jarabe/util/sugarpycha/line.py new file mode 100644 index 0000000000..a4e25e5036 --- /dev/null +++ b/src/jarabe/util/sugarpycha/line.py @@ -0,0 +1,129 @@ +# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +from jarabe.util.sugarpycha.chart import Chart +from jarabe.util.sugarpycha.color import hex2rgb + + +class LineChart(Chart): + + def __init__(self, surface=None, options={}, debug=False): + super(LineChart, self).__init__(surface, options, debug) + self.points = [] + + def _updateChart(self): + """Evaluates measures for line charts""" + self.points = [] + + for i, (name, store) in enumerate(self.datasets): + for item in store: + xval, yval = item + x = (xval - self.minxval) * self.xscale + y = 1.0 - (yval - self.minyval) * self.yscale + point = Point(x, y, xval, yval, name) + + if 0.0 <= point.x <= 1.0 and 0.0 <= point.y <= 1.0: + self.points.append(point) + + def _renderChart(self, cx): + """Renders a line chart""" + + def preparePath(storeName): + cx.new_path() + firstPoint = True + lastX = None + if self.options.shouldFill: + # Go to the (0,0) coordinate to start drawing the area + #cx.move_to(self.layout.chart.x, + # self.layout.chart.y + self.layout.chart.h) + offset = (1.0 - self.origin) * self.layout.chart.h + cx.move_to(self.layout.chart.x, self.layout.chart.y + offset) + + for point in self.points: + if point.name == storeName: + if not self.options.shouldFill and firstPoint: + # starts the first point of the line + cx.move_to(point.x * self.layout.chart.w + + self.layout.chart.x, + point.y * self.layout.chart.h + + self.layout.chart.y) + firstPoint = False + continue + cx.line_to(point.x * self.layout.chart.w + + self.layout.chart.x, + point.y * self.layout.chart.h + + self.layout.chart.y) + # we remember the last X coordinate to close the area + # properly. See bug #4 + lastX = point.x + + if self.options.shouldFill: + # Close the path to the start point + y = ((1.0 - self.origin) * self.layout.chart.h + + self.layout.chart.y) + cx.line_to(lastX * self.layout.chart.w + + self.layout.chart.x, y) + cx.line_to(self.layout.chart.x, y) + cx.close_path() + else: + cx.set_source_rgb(*self.colorScheme[storeName]) + cx.stroke() + + cx.save() + cx.set_line_width(self.options.stroke.width) + if self.options.shouldFill: + + def drawLine(storeName): + if self.options.stroke.shadow: + # draw shadow + cx.save() + cx.set_source_rgba(0, 0, 0, 0.15) + cx.translate(2, -2) + preparePath(storeName) + cx.fill() + cx.restore() + + # fill the line + cx.set_source_rgb(*self.colorScheme[storeName]) + preparePath(storeName) + cx.fill() + + if not self.options.stroke.hide: + # draw stroke + cx.set_source_rgb(*hex2rgb(self.options.stroke.color)) + preparePath(storeName) + cx.stroke() + + # draw the lines + for key in self._getDatasetsKeys(): + drawLine(key) + else: + for key in self._getDatasetsKeys(): + preparePath(key) + + cx.restore() + + +class Point(object): + + def __init__(self, x, y, xval, yval, name): + self.x, self.y = x, y + self.xval, self.yval = xval, yval + self.name = name + + def __str__(self): + return "" % (self.x, self.y) diff --git a/src/jarabe/util/sugarpycha/pie.py b/src/jarabe/util/sugarpycha/pie.py new file mode 100644 index 0000000000..e09f0dd972 --- /dev/null +++ b/src/jarabe/util/sugarpycha/pie.py @@ -0,0 +1,368 @@ +# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +import math + +import cairo + +from jarabe.util.sugarpycha.chart import Chart, Option, Layout, Area, get_text_extents +from jarabe.util.sugarpycha.color import hex2rgb + + +class PieChart(Chart): + + def __init__(self, surface=None, options={}, debug=False): + super(PieChart, self).__init__(surface, options, debug) + self.slices = [] + self.centerx = 0 + self.centery = 0 + self.layout = PieLayout(self.slices) + + self.colors = [[0, 255, 0], + [0, 200, 204], + [120, 203, 0], + [107, 0, 202], + [194, 0, 0], + ] + + def _updateChart(self): + """Evaluates measures for pie charts""" + slices = [dict(name=key, + value=(i, value[0][1])) + for i, (key, value) in enumerate(self.datasets)] + + s = float(sum([slice['value'][1] for slice in slices])) + + fraction = angle = 0.0 + + del self.slices[:] + for slice in slices: + if slice['value'][1] > 0: + angle += fraction + fraction = slice['value'][1] / s + self.slices.append(Slice(slice['name'], fraction, + slice['value'][0], slice['value'][1], + angle)) + + def _updateTicks(self): + """Evaluates pie ticks""" + self.xticks = [] + if self.options.axis.x.ticks: + lookup = dict([(_slice.xval, _slice) for _slice in self.slices]) + for tick in self.options.axis.x.ticks: + if not isinstance(tick, Option): + tick = Option(tick) + _slice = lookup.get(tick.v, None) + label = tick.label or str(tick.v) + if _slice is not None: + label += ' (%.0f%%)' % (_slice.fraction * 100) + self.xticks.append((tick.v, label)) + else: + for _slice in self.slices: + label = '%s (%.1f%%)' % (_slice.name, _slice.fraction * 100) + self.xticks.append((_slice.xval, label)) + + def _renderLines(self, cx): + """Aux function for _renderBackground""" + # there are no lines in a Pie Chart + + def _renderChart(self, cx): + """Renders a pie chart""" + self.centerx = self.layout.chart.x + self.layout.chart.w * 0.5 + self.centery = self.layout.chart.y + self.layout.chart.h * 0.5 + + cx.set_line_join(cairo.LINE_JOIN_ROUND) + + if self.options.stroke.shadow and False: + cx.save() + cx.set_source_rgba(0, 0, 0, 0.15) + + cx.new_path() + cx.move_to(self.centerx, self.centery) + cx.arc(self.centerx + 1, self.centery + 2, + self.layout.radius + 1, 0, math.pi * 2) + cx.line_to(self.centerx, self.centery) + cx.close_path() + cx.fill() + cx.restore() + + cx.save() + + ctr = 0 + color_len = len(self.colors) + + for slice in self.slices: + if slice.isBigEnough(): + if ctr == color_len - 1: + ctr = 0 + else: + ctr = ctr + 1 + cx.set_source_rgb(*self.colors[ctr]) + + if self.options.shouldFill: + slice.draw(cx, self.centerx, self.centery, + self.layout.radius) + cx.fill() + + if not self.options.stroke.hide: + slice.draw(cx, self.centerx, self.centery, + self.layout.radius) + cx.set_line_width(self.options.stroke.width) + cx.set_source_rgb(*hex2rgb(self.options.stroke.color)) + #cx.stroke() + + cx.restore() + + if self.debug: + cx.set_source_rgba(1, 0, 0, 0.5) + px = max(cx.device_to_user_distance(1, 1)) + for x, y in self.layout._lines: + cx.arc(x, y, 5 * px, 0, 2 * math.pi) + cx.fill() + cx.new_path() + cx.move_to(self.centerx, self.centery) + cx.line_to(x, y) + cx.stroke() + + def _renderAxis(self, cx): + """Renders the axis for pie charts""" + if self.options.axis.x.hide or not self.xticks: + return + + self.xlabels = [] + + if self.debug: + px = max(cx.device_to_user_distance(1, 1)) + cx.set_source_rgba(0, 0, 1, 0.5) + for x, y, w, h in self.layout.ticks: + cx.rectangle(x, y, w, h) + cx.stroke() + cx.arc(x + w / 2.0, y + h / 2.0, 5 * px, 0, 2 * math.pi) + cx.fill() + cx.arc(x, y, 2 * px, 0, 2 * math.pi) + cx.fill() + + cx.select_font_face(self.options.axis.tickFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cx.set_font_size(10) + + cx.set_source_rgb(*hex2rgb(self.options.axis.labelColor)) + + for i, tick in enumerate(self.xticks): + label = tick[1] + x, y, w, h = self.layout.ticks[i] + + xb, yb, width, height, xa, ya = cx.text_extents(label) + + # draw label with text tick[1] + cx.move_to(x - xb, y - yb) + cx.show_text(label) + self.xlabels.append(label) + + +class Slice(object): + + def __init__(self, name, fraction, xval, yval, angle): + self.name = name + self.fraction = fraction + self.xval = xval + self.yval = yval + self.startAngle = 2 * angle * math.pi + self.endAngle = 2 * (angle + fraction) * math.pi + + def __str__(self): + return ("" % + (self.startAngle, self.endAngle, self.fraction)) + + def isBigEnough(self): + return abs(self.startAngle - self.endAngle) > 0.001 + + def draw(self, cx, centerx, centery, radius): + cx.new_path() + cx.move_to(centerx, centery) + cx.arc(centerx, centery, radius - 10, -self.endAngle, -self.startAngle) + cx.close_path() + + def getNormalisedAngle(self): + normalisedAngle = (self.startAngle + self.endAngle) / 2 + + if normalisedAngle > math.pi * 2: + normalisedAngle -= math.pi * 2 + elif normalisedAngle < 0: + normalisedAngle += math.pi * 2 + + return normalisedAngle + + +class PieLayout(Layout): + """Set of chart areas for pie charts""" + + def __init__(self, slices): + self.slices = slices + + self.title = Area() + self.chart = Area() + + self.ticks = [] + self.radius = 0 + + self._areas = ( + (self.title, (1, 126 / 255.0, 0)), # orange + (self.chart, (75 / 255.0, 75 / 255.0, 1.0)), # blue + ) + + self._lines = [] + + def update(self, cx, options, width, height, xticks, yticks): + self.title.x = options.padding.left + self.title.y = options.padding.top + self.title.w = width - (options.padding.left + options.padding.right) + self.title.h = get_text_extents(cx, + options.title, + options.titleFont, + options.titleFontSize, + options.encoding)[1] + + lookup = dict([(slice.xval, slice) for slice in self.slices]) + + self.chart.x = self.title.x + self.chart.y = self.title.y + self.title.h + self.chart.w = self.title.w + self.chart.h = height - self.title.h - (options.padding.top + + options.padding.bottom) + + centerx = self.chart.x + self.chart.w * 0.5 + centery = self.chart.y + self.chart.h * 0.5 + + self.radius = min(self.chart.w / 2.0, self.chart.h / 2.0) + for tick in xticks: + _slice = lookup.get(tick[0], None) + width, height = get_text_extents(cx, tick[1], + options.axis.tickFont, + options.axis.tickFontSize, + options.encoding) + angle = _slice.getNormalisedAngle() + radius = self._get_min_radius(angle, centerx, centery, + width, height) + self.radius = min(self.radius, radius) + + # Now that we now the radius we move the ticks as close as we can + # to the circle + for i, tick in enumerate(xticks): + _slice = lookup.get(tick[0], None) + angle = _slice.getNormalisedAngle() + self.ticks[i] = self._get_tick_position(self.radius, angle, + self.ticks[i], + centerx, centery) + + def _get_min_radius(self, angle, centerx, centery, width, height): + min_radius = None + + # precompute some common values + tan = math.tan(angle) + half_width = width / 2.0 + half_height = height / 2.0 + offset_x = half_width * tan + offset_y = half_height / tan + + def intersect_horizontal_line(y): + return centerx + (centery - y) / tan + + def intersect_vertical_line(x): + return centery - tan * (x - centerx) + + # computes the intersection between the rect that has + # that angle with the X axis and the bounding chart box + if 0.25 * math.pi <= angle < 0.75 * math.pi: + # intersects with the top rect + y = self.chart.y + x = intersect_horizontal_line(y) + self._lines.append((x, y)) + + x1 = x - half_width - offset_y + self.ticks.append((x1, self.chart.y, width, height)) + + min_radius = abs((y + height) - centery) + elif 0.75 * math.pi <= angle < 1.25 * math.pi: + # intersects with the left rect + x = self.chart.x + y = intersect_vertical_line(x) + self._lines.append((x, y)) + + y1 = y - half_height - offset_x + self.ticks.append((x, y1, width, height)) + + min_radius = abs(centerx - (x + width)) + elif 1.25 * math.pi <= angle < 1.75 * math.pi: + # intersects with the bottom rect + y = self.chart.y + self.chart.h + x = intersect_horizontal_line(y) + self._lines.append((x, y)) + + x1 = x - half_width + offset_y + self.ticks.append((x1, y - height, width, height)) + + min_radius = abs((y - height) - centery) + else: + # intersects with the right rect + x = self.chart.x + self.chart.w + y = intersect_vertical_line(x) + self._lines.append((x, y)) + + y1 = y - half_height + offset_x + self.ticks.append((x - width, y1, width, height)) + + min_radius = abs((x - width) - centerx) + + return min_radius + + def _get_tick_position(self, radius, angle, tick, centerx, centery): + text_width, text_height = tick[2:4] + half_width = text_width / 2.0 + half_height = text_height / 2.0 + + if 0 <= angle < 0.5 * math.pi: + # first quadrant + k1 = j1 = k2 = 1 + j2 = -1 + elif 0.5 * math.pi <= angle < math.pi: + # second quadrant + k1 = k2 = -1 + j1 = j2 = 1 + elif math.pi <= angle < 1.5 * math.pi: + # third quadrant + k1 = j1 = k2 = -1 + j2 = 1 + elif 1.5 * math.pi <= angle < 2 * math.pi: + # fourth quadrant + k1 = k2 = 1 + j1 = j2 = -1 + + cx = radius * math.cos(angle) + k1 * half_width + cy = radius * math.sin(angle) + j1 * half_height + + radius2 = math.sqrt(cx * cx + cy * cy) + + tan = math.tan(angle) + x = math.sqrt((radius2 * radius2) / (1 + tan * tan)) + y = tan * x + + x = centerx + k2 * x + y = centery + j2 * y + + return x - half_width, y - half_height, text_width, text_height diff --git a/src/jarabe/util/sugarpycha/polygonal.py b/src/jarabe/util/sugarpycha/polygonal.py new file mode 100644 index 0000000000..54460bcd25 --- /dev/null +++ b/src/jarabe/util/sugarpycha/polygonal.py @@ -0,0 +1,372 @@ +# Copyright(c) 2011 by Roberto Garcia Carvajal +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +import math + +import cairo + +from jarabe.util.sugarpycha.chart import Chart +from jarabe.util.sugarpycha.line import Point +from jarabe.util.sugarpycha.color import hex2rgb +from jarabe.util.sugarpycha.utils import safe_unicode + + +class PolygonalChart(Chart): + + def __init__(self, surface=None, options={}): + super(PolygonalChart, self).__init__(surface, options) + self.points = [] + + def _updateChart(self): + """Evaluates measures for polygonal charts""" + self.points = [] + + for i, (name, store) in enumerate(self.datasets): + for item in store: + xval, yval = item + x = (xval - self.minxval) * self.xscale + y = 1.0 - (yval - self.minyval) * self.yscale + point = Point(x, y, xval, yval, name) + + if 0.0 <= point.x <= 1.0 and 0.0 <= point.y <= 1.0: + self.points.append(point) + + def _renderBackground(self, cx): + """Renders the background area of the chart""" + if self.options.background.hide: + return + + cx.save() + + if self.options.background.baseColor: + cx.set_source_rgb(*hex2rgb(self.options.background.baseColor)) + cx.paint() + + if self.options.background.chartColor: + cx.set_source_rgb(*hex2rgb(self.options.background.chartColor)) + cx.set_line_width(10.0) + cx.new_path() + init = None + count = len(self.xticks) + for index, tick in enumerate(self.xticks): + ang = math.pi / 2 - index * 2 * math.pi / count + x = (self.layout.chart.x + self.layout.chart.w / 2 + - math.cos(ang) + * min(self.layout.chart.w / 2, self.layout.chart.h / 2)) + y = (self.layout.chart.y + self.layout.chart.h / 2 + - math.sin(ang) + * min(self.layout.chart.w / 2, self.layout.chart.h / 2)) + if init is None: + cx.move_to(x, y) + init = (x, y) + else: + cx.line_to(x, y) + cx.line_to(init[0], init[1]) + cx.close_path() + cx.fill() + + if self.options.background.lineColor: + cx.set_source_rgb(*hex2rgb(self.options.background.lineColor)) + cx.set_line_width(self.options.axis.lineWidth) + self._renderLines(cx) + + cx.restore() + + def _renderLine(self, cx, tick, horiz): + """Aux function for _renderLines""" + + rad = (self.layout.chart.h / 2) * (1 - tick[0]) + cx.new_path() + init = None + count = len(self.xticks) + for index, tick in enumerate(self.xticks): + ang = math.pi / 2 - index * 2 * math.pi / count + x = (self.layout.chart.x + self.layout.chart.w / 2 + - math.cos(ang) * rad) + y = (self.layout.chart.y + self.layout.chart.h / 2 + - math.sin(ang) * rad) + if init is None: + cx.move_to(x, y) + init = (x, y) + else: + cx.line_to(x, y) + cx.line_to(init[0], init[1]) + cx.close_path() + cx.stroke() + + def _renderXAxis(self, cx): + """Draws the horizontal line representing the X axis""" + + count = len(self.xticks) + + centerx = self.layout.chart.x + self.layout.chart.w / 2 + centery = self.layout.chart.y + self.layout.chart.h / 2 + + for i in range(0, count): + offset1 = i * 2 * math.pi / count + offset = math.pi / 2 - offset1 + + rad = self.layout.chart.h / 2 + (r1, r2) = (0, rad + 5) + + x1 = centerx - math.cos(offset) * r1 + x2 = centerx - math.cos(offset) * r2 + y1 = centery - math.sin(offset) * r1 + y2 = centery - math.sin(offset) * r2 + + cx.new_path() + cx.move_to(x1, y1) + cx.line_to(x2, y2) + cx.close_path() + cx.stroke() + + def _renderYTick(self, cx, tick, center): + """Aux method for _renderAxis""" + + i = tick + tick = self.yticks[i] + + count = len(self.yticks) + + if callable(tick): + return + + x = center[0] + y = center[1] - i * (self.layout.chart.h / 2) / count + + cx.new_path() + cx.move_to(x, y) + cx.line_to(x - self.options.axis.tickSize, y) + cx.close_path() + cx.stroke() + + cx.select_font_face(self.options.axis.tickFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cx.set_font_size(self.options.axis.tickFontSize) + + label = safe_unicode(tick[1], self.options.encoding) + extents = cx.text_extents(label) + labelWidth = extents[2] + labelHeight = extents[3] + + if self.options.axis.y.rotate: + radians = math.radians(self.options.axis.y.rotate) + cx.move_to(x - self.options.axis.tickSize + - (labelWidth * math.cos(radians)) + - 4, + y + (labelWidth * math.sin(radians)) + + labelHeight / (2.0 / math.cos(radians))) + cx.rotate(-radians) + cx.show_text(label) + cx.rotate(radians) # this is probably faster than a save/restore + else: + cx.move_to(x - self.options.axis.tickSize - labelWidth - 4, + y + labelHeight / 2.0) + cx.rel_move_to(0.0, -labelHeight / 2.0) + cx.show_text(label) + + return label + + def _renderYAxis(self, cx): + """Draws the vertical line for the Y axis""" + + centerx = self.layout.chart.x + self.layout.chart.w / 2 + centery = self.layout.chart.y + self.layout.chart.h / 2 + + offset = math.pi / 2 + + r1 = self.layout.chart.h / 2 + + x1 = centerx - math.cos(offset) * r1 + y1 = centery - math.sin(offset) * r1 + + cx.new_path() + cx.move_to(centerx, centery) + cx.line_to(x1, y1) + cx.close_path() + cx.stroke() + + def _renderAxis(self, cx): + """Renders axis""" + if self.options.axis.x.hide and self.options.axis.y.hide: + return + + cx.save() + cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) + cx.set_line_width(self.options.axis.lineWidth) + + centerx = self.layout.chart.x + self.layout.chart.w / 2 + centery = self.layout.chart.y + self.layout.chart.h / 2 + + if not self.options.axis.y.hide: + if self.yticks: + + count = len(self.yticks) + + for i in range(0, count): + self._renderYTick(cx, i, (centerx, centery)) + + if self.options.axis.y.label: + self._renderYAxisLabel(cx, self.options.axis.y.label) + + self._renderYAxis(cx) + + if not self.options.axis.x.hide: + fontAscent = cx.font_extents()[0] + if self.xticks: + + count = len(self.xticks) + + for i in range(0, count): + self._renderXTick(cx, i, fontAscent, (centerx, centery)) + + if self.options.axis.x.label: + self._renderXAxisLabel(cx, self.options.axis.x.label) + + self._renderXAxis(cx) + + cx.restore() + + def _renderXTick(self, cx, i, fontAscent, center): + tick = self.xticks[i] + if callable(tick): + return + + count = len(self.xticks) + cx.select_font_face(self.options.axis.tickFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cx.set_font_size(self.options.axis.tickFontSize) + + label = safe_unicode(tick[1], self.options.encoding) + extents = cx.text_extents(label) + labelWidth = extents[2] + labelHeight = extents[3] + + x, y = center + cx.move_to(x, y) + + if self.options.axis.x.rotate: + radians = math.radians(self.options.axis.x.rotate) + cx.move_to(x - (labelHeight * math.cos(radians)), + y + self.options.axis.tickSize + + (labelHeight * math.cos(radians)) + + 4.0) + cx.rotate(radians) + cx.show_text(label) + cx.rotate(-radians) + else: + offset1 = i * 2 * math.pi / count + offset = math.pi / 2 - offset1 + + rad = self.layout.chart.h / 2 + 10 + + x = center[0] - math.cos(offset) * rad + y = center[1] - math.sin(offset) * rad + + cx.move_to(x, y) + cx.rotate(offset - math.pi / 2) + + if math.sin(offset) < 0.0: + cx.rotate(math.pi) + cx.rel_move_to(0.0, 5.0) + + cx.rel_move_to(-labelWidth / 2.0, 0) + cx.show_text(label) + if math.sin(offset) < 0.0: + cx.rotate(-math.pi) + + cx.rotate(-(offset - math.pi / 2)) + return label + + def _renderChart(self, cx): + """Renders a polygonal chart""" + # draw the polygon. + def preparePath(storeName): + cx.new_path() + firstPoint = True + + count = len(self.points) / len(self.datasets) + centerx = self.layout.chart.x + self.layout.chart.w / 2 + centery = self.layout.chart.y + self.layout.chart.h / 2 + + firstPointCoord = None + + for index, point in enumerate(self.points): + if point.name == storeName: + offset1 = index * 2 * math.pi / count + offset = math.pi / 2 - offset1 + + rad = (self.layout.chart.h / 2) * (1 - point.y) + + x = centerx - math.cos(offset) * rad + y = centery - math.sin(offset) * rad + + if firstPointCoord is None: + firstPointCoord = (x, y) + + if not self.options.shouldFill and firstPoint: + # starts the first point of the line + cx.move_to(x, y) + firstPoint = False + continue + cx.line_to(x, y) + + if not firstPointCoord is None: + cx.line_to(firstPointCoord[0], firstPointCoord[1]) + + if self.options.shouldFill: + # Close the path to the start point + y = ((1.0 - self.origin) + * self.layout.chart.h + self.layout.chart.y) + else: + cx.set_source_rgb(*self.colorScheme[storeName]) + cx.stroke() + + cx.save() + cx.set_line_width(self.options.stroke.width) + if self.options.shouldFill: + + def drawLine(storeName): + if self.options.stroke.shadow: + # draw shadow + cx.save() + cx.set_source_rgba(0, 0, 0, 0.15) + cx.translate(2, -2) + preparePath(storeName) + cx.fill() + cx.restore() + + # fill the line + cx.set_source_rgb(*self.colorScheme[storeName]) + preparePath(storeName) + cx.fill() + + if not self.options.stroke.hide: + # draw stroke + cx.set_source_rgb(*hex2rgb(self.options.stroke.color)) + preparePath(storeName) + cx.stroke() + + # draw the lines + for key in self._getDatasetsKeys(): + drawLine(key) + else: + for key in self._getDatasetsKeys(): + preparePath(key) + cx.restore() diff --git a/src/jarabe/util/sugarpycha/radial.py b/src/jarabe/util/sugarpycha/radial.py new file mode 100644 index 0000000000..b1dadab29c --- /dev/null +++ b/src/jarabe/util/sugarpycha/radial.py @@ -0,0 +1,346 @@ +# Copyright(c) 2011 by Roberto Garcia Carvajal +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +import math + +import cairo + +from jarabe.util.sugarpycha.chart import Chart +from jarabe.util.sugarpycha.line import Point +from jarabe.util.sugarpycha.color import hex2rgb +from jarabe.util.sugarpycha.utils import safe_unicode + + +class RadialChart(Chart): + + def __init__(self, surface=None, options={}): + super(RadialChart, self).__init__(surface, options) + self.points = [] + + def _updateChart(self): + """Evaluates measures for radial charts""" + self.points = [] + + for i, (name, store) in enumerate(self.datasets): + for item in store: + xval, yval = item + x = (xval - self.minxval) * self.xscale + y = 1.0 - (yval - self.minyval) * self.yscale + point = Point(x, y, xval, yval, name) + + if 0.0 <= point.x <= 1.0 and 0.0 <= point.y <= 1.0: + self.points.append(point) + + def _renderBackground(self, cx): + """Renders the background area of the chart""" + if self.options.background.hide: + return + + cx.save() + + if self.options.background.baseColor: + cx.set_source_rgb(*hex2rgb(self.options.background.baseColor)) + cx.paint() + + if self.options.background.chartColor: + cx.set_source_rgb(*hex2rgb(self.options.background.chartColor)) + cx.set_line_width(10.0) + cx.arc(self.layout.chart.x + self.layout.chart.w / 2, + self.layout.chart.y + self.layout.chart.h / 2, + min(self.layout.chart.w / 2, self.layout.chart.h / 2), + 0, 2 * math.pi) + cx.fill() + + if self.options.background.lineColor: + cx.set_source_rgb(*hex2rgb(self.options.background.lineColor)) + cx.set_line_width(self.options.axis.lineWidth) + self._renderLines(cx) + + cx.restore() + + def _renderLine(self, cx, tick, horiz): + """Aux function for _renderLines""" + + rad = (self.layout.chart.h / 2) * (1 - tick[0]) + cx.arc(self.layout.chart.x + self.layout.chart.w / 2, + self.layout.chart.y + self.layout.chart.h / 2, + rad, 0, 2 * math.pi) + cx.stroke() + + def _renderXAxis(self, cx): + """Draws the horizontal line representing the X axis""" + + count = len(self.xticks) + + centerx = self.layout.chart.x + self.layout.chart.w / 2 + centery = self.layout.chart.y + self.layout.chart.h / 2 + + for i in range(0, count): + offset1 = i * 2 * math.pi / count + offset = math.pi / 2 - offset1 + + rad = self.layout.chart.h / 2 + (r1, r2) = (0, rad + 5) + + x1 = centerx - math.cos(offset) * r1 + x2 = centerx - math.cos(offset) * r2 + y1 = centery - math.sin(offset) * r1 + y2 = centery - math.sin(offset) * r2 + + cx.new_path() + cx.move_to(x1, y1) + cx.line_to(x2, y2) + cx.close_path() + cx.stroke() + + def _renderYTick(self, cx, tick, center): + """Aux method for _renderAxis""" + + i = tick + tick = self.yticks[i] + + count = len(self.yticks) + + if callable(tick): + return + + x = center[0] + y = center[1] - i * (self.layout.chart.h / 2) / count + + cx.new_path() + cx.move_to(x, y) + cx.line_to(x - self.options.axis.tickSize, y) + cx.close_path() + cx.stroke() + + cx.select_font_face(self.options.axis.tickFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cx.set_font_size(self.options.axis.tickFontSize) + + label = safe_unicode(tick[1], self.options.encoding) + extents = cx.text_extents(label) + labelWidth = extents[2] + labelHeight = extents[3] + + if self.options.axis.y.rotate: + radians = math.radians(self.options.axis.y.rotate) + cx.move_to(x - self.options.axis.tickSize + - (labelWidth * math.cos(radians)) + - 4, + y + (labelWidth * math.sin(radians)) + + labelHeight / (2.0 / math.cos(radians))) + cx.rotate(-radians) + cx.show_text(label) + cx.rotate(radians) # this is probably faster than a save/restore + else: + cx.move_to(x - self.options.axis.tickSize - labelWidth - 4, + y + labelHeight / 2.0) + cx.rel_move_to(0.0, -labelHeight / 2.0) + cx.show_text(label) + + return label + + def _renderYAxis(self, cx): + """Draws the vertical line for the Y axis""" + + centerx = self.layout.chart.x + self.layout.chart.w / 2 + centery = self.layout.chart.y + self.layout.chart.h / 2 + + offset = math.pi / 2 + + r1 = self.layout.chart.h / 2 + + x1 = centerx - math.cos(offset) * r1 + y1 = centery - math.sin(offset) * r1 + + cx.new_path() + cx.move_to(centerx, centery) + cx.line_to(x1, y1) + cx.close_path() + cx.stroke() + + def _renderAxis(self, cx): + """Renders axis""" + if self.options.axis.x.hide and self.options.axis.y.hide: + return + + cx.save() + cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor)) + cx.set_line_width(self.options.axis.lineWidth) + + centerx = self.layout.chart.x + self.layout.chart.w / 2 + centery = self.layout.chart.y + self.layout.chart.h / 2 + + if not self.options.axis.y.hide: + if self.yticks: + + count = len(self.yticks) + + for i in range(0, count): + self._renderYTick(cx, i, (centerx, centery)) + + if self.options.axis.y.label: + self._renderYAxisLabel(cx, self.options.axis.y.label) + + self._renderYAxis(cx) + + if not self.options.axis.x.hide: + fontAscent = cx.font_extents()[0] + if self.xticks: + + count = len(self.xticks) + + for i in range(0, count): + self._renderXTick(cx, i, fontAscent, (centerx, centery)) + + if self.options.axis.x.label: + self._renderXAxisLabel(cx, self.options.axis.x.label) + + self._renderXAxis(cx) + + cx.restore() + + def _renderXTick(self, cx, i, fontAscent, center): + tick = self.xticks[i] + if callable(tick): + return + + count = len(self.xticks) + cx.select_font_face(self.options.axis.tickFont, + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cx.set_font_size(self.options.axis.tickFontSize) + + label = safe_unicode(tick[1], self.options.encoding) + extents = cx.text_extents(label) + labelWidth = extents[2] + labelHeight = extents[3] + + x, y = center + cx.move_to(x, y) + + if self.options.axis.x.rotate: + radians = math.radians(self.options.axis.x.rotate) + cx.move_to(x - (labelHeight * math.cos(radians)), + y + self.options.axis.tickSize + + (labelHeight * math.cos(radians)) + + 4.0) + cx.rotate(radians) + cx.show_text(label) + cx.rotate(-radians) + else: + offset1 = i * 2 * math.pi / count + offset = math.pi / 2 - offset1 + + rad = self.layout.chart.h / 2 + 10 + + x = center[0] - math.cos(offset) * rad + y = center[1] - math.sin(offset) * rad + + cx.move_to(x, y) + cx.rotate(offset - math.pi / 2) + + if math.sin(offset) < 0.0: + cx.rotate(math.pi) + cx.rel_move_to(0.0, 5.0) + + cx.rel_move_to(-labelWidth / 2.0, 0) + cx.show_text(label) + if math.sin(offset) < 0.0: + cx.rotate(-math.pi) + + cx.rotate(-(offset - math.pi / 2)) + return label + + def _renderChart(self, cx): + """Renders a line chart""" + + # draw the circle + def preparePath(storeName): + cx.new_path() + firstPoint = True + + count = len(self.points) / len(self.datasets) + centerx = self.layout.chart.x + self.layout.chart.w / 2 + centery = self.layout.chart.y + self.layout.chart.h / 2 + + firstPointCoord = None + + for index, point in enumerate(self.points): + if point.name == storeName: + offset1 = index * 2 * math.pi / count + offset = math.pi / 2 - offset1 + + rad = (self.layout.chart.h / 2) * (1 - point.y) + + x = centerx - math.cos(offset) * rad + y = centery - math.sin(offset) * rad + + if firstPointCoord is None: + firstPointCoord = (x, y) + + if not self.options.shouldFill and firstPoint: + # starts the first point of the line + cx.move_to(x, y) + firstPoint = False + continue + cx.line_to(x, y) + + if not firstPointCoord is None: + cx.line_to(firstPointCoord[0], firstPointCoord[1]) + + if self.options.shouldFill: + # Close the path to the start point + y = ((1.0 - self.origin) + * self.layout.chart.h + self.layout.chart.y) + else: + cx.set_source_rgb(*self.colorScheme[storeName]) + cx.stroke() + + cx.save() + cx.set_line_width(self.options.stroke.width) + if self.options.shouldFill: + + def drawLine(storeName): + if self.options.stroke.shadow: + # draw shadow + cx.save() + cx.set_source_rgba(0, 0, 0, 0.15) + cx.translate(2, -2) + preparePath(storeName) + cx.fill() + cx.restore() + + # fill the line + cx.set_source_rgb(*self.colorScheme[storeName]) + preparePath(storeName) + cx.fill() + + if not self.options.stroke.hide: + # draw stroke + cx.set_source_rgb(*hex2rgb(self.options.stroke.color)) + preparePath(storeName) + cx.stroke() + + # draw the lines + for key in self._getDatasetsKeys(): + drawLine(key) + else: + for key in self._getDatasetsKeys(): + preparePath(key) + cx.restore() diff --git a/src/jarabe/util/sugarpycha/scatter.py b/src/jarabe/util/sugarpycha/scatter.py new file mode 100644 index 0000000000..3079952d99 --- /dev/null +++ b/src/jarabe/util/sugarpycha/scatter.py @@ -0,0 +1,38 @@ +# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +import math + +from jarabe.util.sugarpycha.line import LineChart + + +class ScatterplotChart(LineChart): + + def _renderChart(self, cx): + """Renders a scatterplot""" + + def drawSymbol(point, size): + ox = point.x * self.layout.chart.w + self.layout.chart.x + oy = point.y * self.layout.chart.h + self.layout.chart.y + cx.arc(ox, oy, size, 0.0, 2 * math.pi) + cx.fill() + + for key in self._getDatasetsKeys(): + cx.set_source_rgb(*self.colorScheme[key]) + for point in self.points: + if point.name == key: + drawSymbol(point, self.options.stroke.width) diff --git a/src/jarabe/util/sugarpycha/stackedbar.py b/src/jarabe/util/sugarpycha/stackedbar.py new file mode 100644 index 0000000000..350b269667 --- /dev/null +++ b/src/jarabe/util/sugarpycha/stackedbar.py @@ -0,0 +1,122 @@ +# Copyright(c) 2009 by Yaco S.L. +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + +from jarabe.util.sugarpycha.bar import BarChart, VerticalBarChart, HorizontalBarChart, Rect +from jarabe.util.sugarpycha.chart import uniqueIndices +from functools import reduce + + +class StackedBarChart(BarChart): + + def __init__(self, surface=None, options={}, debug=False): + super(StackedBarChart, self).__init__(surface, options, debug) + self.barWidth = 0.0 + + def _updateXY(self): + super(StackedBarChart, self)._updateXY() + # each dataset is centered around a line segment. that's why we + # need n + 1 divisions on the x axis + self.xscale = 1 / (self.xrange + 1.0) + + if self.options.axis.y.range is None: + # Fix the yscale as we accumulate the y values + stores = self._getDatasetsValues() + n_stores = len(stores) + flat_y = [pair[1] for pair in reduce(lambda a, b: a + b, stores)] + store_size = len(flat_y) / n_stores + accum = [sum(flat_y[j]for j in range(i, + i + store_size * n_stores, + store_size)) + for i in range(len(flat_y) / n_stores)] + self.yrange = float(max(accum)) + if self.yrange == 0: + self.yscale = 1.0 + else: + self.yscale = 1.0 / self.yrange + + def _updateChart(self): + """Evaluates measures for vertical bars""" + stores = self._getDatasetsValues() + uniqx = uniqueIndices(stores) + + if len(uniqx) == 1: + self.minxdelta = 1.0 + else: + self.minxdelta = min([abs(uniqx[j] - uniqx[j - 1]) + for j in range(1, len(uniqx))]) + + k = self.minxdelta * self.xscale + self.barWidth = k * self.options.barWidthFillFraction + self.barMargin = k * (1.0 - self.options.barWidthFillFraction) / 2 + + self.bars = [] + + +class StackedVerticalBarChart(StackedBarChart, VerticalBarChart): + + def _updateChart(self): + """Evaluates measures for vertical bars""" + super(StackedVerticalBarChart, self)._updateChart() + + accumulated_heights = {} + for i, (name, store) in enumerate(self.datasets): + for item in store: + xval, yval = item + x = ((xval - self.minxval) * self.xscale) + self.barMargin + w = self.barWidth + h = abs(yval) * self.yscale + if yval > 0: + y = (1.0 - h) - self.origin + else: + y = 1 - self.origin + + accumulated_height = accumulated_heights.setdefault(xval, 0) + y -= accumulated_height + accumulated_heights[xval] += h + + rect = Rect(x, y, w, h, xval, yval, name) + + if (0.0 <= rect.x <= 1.0) and (0.0 <= rect.y <= 1.0): + self.bars.append(rect) + + +class StackedHorizontalBarChart(StackedBarChart, HorizontalBarChart): + + def _updateChart(self): + """Evaluates measures for horizontal bars""" + super(StackedHorizontalBarChart, self)._updateChart() + + accumulated_widths = {} + for i, (name, store) in enumerate(self.datasets): + for item in store: + xval, yval = item + y = ((xval - self.minxval) * self.xscale) + self.barMargin + h = self.barWidth + w = abs(yval) * self.yscale + if yval > 0: + x = self.origin + else: + x = self.origin - w + + accumulated_width = accumulated_widths.setdefault(xval, 0) + x += accumulated_width + accumulated_widths[xval] += w + + rect = Rect(x, y, w, h, xval, yval, name) + + if (0.0 <= rect.x <= 1.0) and (0.0 <= rect.y <= 1.0): + self.bars.append(rect) diff --git a/src/jarabe/util/sugarpycha/utils.py b/src/jarabe/util/sugarpycha/utils.py new file mode 100644 index 0000000000..ccd014d7da --- /dev/null +++ b/src/jarabe/util/sugarpycha/utils.py @@ -0,0 +1,40 @@ +# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez +# 2009-2010 by Yaco S.L. +# +# This file is part of PyCha. +# +# PyCha is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyCha 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with PyCha. If not, see . + + +def clamp(minValue, maxValue, value): + """Make sure value is between minValue and maxValue""" + if value < minValue: + return minValue + if value > maxValue: + return maxValue + return value + + +def safe_unicode(obj, encoding=None): + """Return a unicode value from the argument""" + if isinstance(obj, str): + return obj + elif isinstance(obj, str): + if encoding is None: + return str(obj) + else: + return str(obj, encoding) + else: + # it may be an int or a float + return str(obj) diff --git a/src/jarabe/util/utils.py b/src/jarabe/util/utils.py new file mode 100644 index 0000000000..1b6d681d12 --- /dev/null +++ b/src/jarabe/util/utils.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# utils.py by: +# Agustin Zubiaga + +# Copyright (C) 2019 Hrishi Patel +# This program 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. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +import os + +from sugar3 import profile +from sugar3.graphics.style import Color + + +def rgb2html(color): + """Returns a html string from a Gdk color""" + red = "%x" % int(color.red / 65535.0 * 255) + if len(red) == 1: + red = "0%s" % red + + green = "%x" % int(color.green / 65535.0 * 255) + + if len(green) == 1: + green = "0%s" % green + + blue = "%x" % int(color.blue / 65535.0 * 255) + + if len(blue) == 1: + blue = "0%s" % blue + + new_color = "#%s%s%s" % (red, green, blue) + + return new_color + + +def get_user_fill_color(type='gdk'): + """Returns the user fill color""" + color = profile.get_color() + if type == 'gdk': + rcolor = Color(color.get_fill_color()).get_gdk_color() + + elif type == 'str': + rcolor = color.get_fill_color() + + return rcolor + + +def get_user_stroke_color(type='gdk'): + """Returns the user stroke color""" + color = profile.get_color() + + if type == 'gdk': + rcolor = Color(color.get_stroke_color()).get_gdk_color() + + elif type == 'str': + rcolor = color.get_stroke_color() + + return rcolor + + +def get_chart_file(activity_dir): + """Returns a path for write the chart in a png image""" + chart_file = os.path.join(activity_dir, "chart-1.png") + num = 0 + + while os.path.exists(chart_file): + num += 1 + chart_file = os.path.join(activity_dir, "chart-" + str(num) + ".png") + + return chart_file + + +def get_decimals(number): + """Returns the decimals count of a number""" + return str(len(number.split('.')[1])) + + +def get_channels(): + path = os.path.join('/sys/class/dmi/id', 'product_version') + try: + product = open(path).readline().strip() + except: + product = None + + if product == '1' or product == '1.0': + return 1 + else: + return 2