# -*- coding: utf-8; Mode: Python; indent-tabs-mode: nil; tab-width: 4 -*- # Copyright (C) 2009 Canonical Ltd. # Written by Evan Dandrea . # # 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 # A simple timezone map that highlights timezone bands. import math import cairo import gtk import glib import gobject import os import datetime from ubiquity.segmented_bar import CairoExtensions # We need a color coded map so we can only select from the list of points that # are in the time zone band the user clicked on. It would be odd if the user # clicked within UTC-5, but it selected a point in UTC-6 because it was closer # to the mouse. color_codes = { # We don't handle UTC-12, but as that's just the US Minor Outlying Islands, I # think we're ok, as Wikipedia says, "As of 2008, none of the islands has any # permanent residents." '-11.0' : [43, 0, 0, 255], '-10.0' : [85, 0, 0, 255], '-9.5' : [102, 255, 0, 255], '-9.0' : [128, 0, 0, 255], '-8.0' : [170, 0, 0, 255], '-7.0' : [212, 0, 0, 255], '-6.0|north' : [255, 0, 1, 255], '-6.0|south' : [255, 0, 0, 255], '-5.0' : [255, 42, 42, 255], '-4.5' : [192, 255, 0, 255], '-4.0' : [255, 85, 85, 255], '-3.5' : [0, 255, 0, 255], '-3.0' : [255, 128, 128, 255], '-2.0' : [255, 170, 170, 255], '-1.0' : [255, 213, 213, 255], '0.0' : [43, 17, 0, 255], '1.0' : [85, 34, 0, 255], '2.0' : [128, 51, 0, 255], '3.0' : [170, 68, 0, 255], '3.5' : [0, 255, 102, 255], '4.0' : [212, 85, 0, 255], '4.5' : [0, 204, 255, 255], '5.0' : [255, 102, 0, 255], '5.5' : [0, 102, 255, 255], '5.75' : [0, 238, 207, 247], '6.0' : [255, 127, 42, 255], '6.5' : [204, 0, 254, 254], '7.0' : [255, 153, 85, 255], '8.0' : [255, 179, 128, 255], '9.0' : [255, 204, 170, 255], '9.5' : [170, 0, 68, 250], '10.0' : [255, 230, 213, 255], '10.5' : [212, 124, 21, 250], '11.0' : [212, 170, 0, 255], '11.5' : [249, 25, 87, 253], '12.0' : [255, 204, 0, 255], '12.75' : [254, 74, 100, 248], '13.0' : [255, 85, 153, 250], } # The South Pole is transformed from 0.0, -90.0 to 0.5, 1 before being adjusted # for the shifted and missing arctic section of the map. #def convert_longitude_to_x(longitude, map_width): # # Miller cylindrical map projection is just the longitude as the # # calculation is the longitude from the central meridian of the projection. # # Convert to radians. # x = (longitude * (math.pi / 180)) + math.pi # 0 ... 2pi # # Convert to a percentage. # x = x / (2 * math.pi) # x = x * map_width # # Adjust for the visible map starting near 170 degrees. # # Percentage shift required, grabbed from measurements using The GIMP. # x = x - (map_width * 0.039073402) # return x def convert_longitude_to_x(longitude, map_width): xdeg_offset = -6 x = (map_width * (180.0 + longitude) / 360.0) + (map_width * xdeg_offset / 180.0) x = x % map_width return x #def convert_latitude_to_y(latitude, map_height): # # Miller cylindrical map projection, as used in the source map from the CIA # # world factbook. Convert latitude to radians. # y = 1.25 * math.log(math.tan((0.25 * math.pi) + \ # (0.4 * (latitude * (math.pi / 180))))) # # Convert to a percentage. # y = abs(y - 2.30341254338) # 0 ... 4.606825 # y = y / 4.6068250867599998 # # Adjust for the visible map not including anything beyond 60 degrees south # # (150 degrees vs 180 degrees). # y = y * (map_height * 1.2) # return y def convert_latitude_to_y(latitude, map_height): bottom_lat = -59 top_lat = 81 top_per = top_lat / 180.0 y = 1.25 * math.log(math.tan(math.pi / 4.0 + 0.4 * math.radians(latitude))) full_range = 4.6068250867599998 top_offset = full_range * top_per map_range = abs(1.25 * math.log(math.tan(math.pi / 4.0 + 0.4 * math.radians(bottom_lat))) - top_offset) y = abs(y - top_offset) y = y / map_range y = y * map_height return y class TimezoneMap(gtk.Widget): __gtype_name__ = 'TimezoneMap' __gsignals__ = { 'city-selected' : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, (gobject.TYPE_STRING,)) } def __init__(self, database, image_path): gtk.Widget.__init__(self) self.tzdb = database self.image_path = image_path self.time_fmt = '%X' self.orig_background = \ gtk.gdk.pixbuf_new_from_file(os.path.join(self.image_path, 'bg.png')) self.orig_color_map = \ gtk.gdk.pixbuf_new_from_file(os.path.join(self.image_path, 'cc.png')) self.connect('button-press-event', self.button_press) self.connect('map-event', self.mapped) self.connect('unmap-event', self.unmapped) self.selected_offset = None self.selected = None self.update_timeout = None self.distances = [] self.previous_click = (-1, -1) self.dist_pos = 0 def set_time_format(self, time_fmt): self.time_fmt = time_fmt def do_size_request(self, requisition): requisition.width = self.orig_background.get_width() / 2 requisition.height = self.orig_background.get_height() / 2 gtk.Widget.do_size_request(self, requisition) def do_size_allocate(self, allocation): self.background = self.orig_background.scale_simple(allocation.width, allocation.height, gtk.gdk.INTERP_BILINEAR) color_map = self.orig_color_map.scale_simple(allocation.width, allocation.height, gtk.gdk.INTERP_BILINEAR) self.visible_map_pixels = color_map.get_pixels() self.visible_map_rowstride = color_map.get_rowstride() gtk.Widget.do_size_allocate(self, allocation) def do_realize(self): self.set_flags(self.flags() | gtk.REALIZED) self.window = gtk.gdk.Window( self.get_parent_window(), width=self.allocation.width, height=self.allocation.height, window_type=gtk.gdk.WINDOW_CHILD, wclass=gtk.gdk.INPUT_OUTPUT, event_mask=self.get_events() | gtk.gdk.EXPOSURE_MASK | gtk.gdk.BUTTON_PRESS_MASK) self.window.set_user_data(self) self.style.attach(self.window) self.style.set_background(self.window, gtk.STATE_NORMAL) self.window.move_resize(*self.allocation) cursor = gtk.gdk.Cursor(gtk.gdk.HAND2) self.window.set_cursor(cursor) def do_expose_event(self, unused_event): cr = self.window.cairo_create() cr.set_source_pixbuf(self.background, 0, 0) cr.paint() # Render highlight. # Possibly not the best solution, though in my head it seems better # than keeping two copies (original and resized) of every timezone in # memory. pixbuf = None if self.selected_offset != None: try: pixbuf = gtk.gdk.pixbuf_new_from_file(os.path.join(self.image_path, 'timezone_%s.png' % self.selected_offset.split('|')[0])) pixbuf = pixbuf.scale_simple(self.allocation.width, self.allocation.height, gtk.gdk.INTERP_BILINEAR) cr.set_source_pixbuf(pixbuf, 0, 0) cr.paint() except glib.GError, e: print 'Error setting the time zone band highlight:', str(e) return # Plot city and time. height = self.background.get_height() width = self.background.get_width() loc = self.selected and self.tzdb.get_loc(self.selected) if loc: pointx = convert_longitude_to_x(loc.longitude, width) pointy = convert_latitude_to_y(loc.latitude, height) cr.set_source_color(gtk.gdk.color_parse('#1e1e1e')) cr.arc(pointx, pointy, 4.5, 0, 2 * math.pi) cr.set_line_width(1.5) cr.fill_preserve() cr.set_source_color(gtk.gdk.color_parse('white')) cr.stroke() # Draw the time. now = datetime.datetime.now(loc.info) time_text = now.strftime(self.time_fmt) cr.select_font_face('Sans', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) cr.set_font_size(12.0) xbearing, ybearing, width, height, xadvance, yadvance = \ cr.text_extents(time_text) newy = pointy - (ybearing / 2) if pointx + width + 10 > self.allocation.width: newx = pointx - 12 - width - 4 else: newx = pointx + 12 cr.move_to(newx, newy) cr.set_source_color(gtk.gdk.color_parse('#1e1e1e')) CairoExtensions.rounded_rectangle(cr, newx - 5, newy + ybearing - 6, width + 10, height + 12, height / 6) cr.fill_preserve() cr.stroke() cr.set_source_color(gtk.gdk.color_parse('white')) cr.move_to(newx, newy) cr.show_text(time_text) cr.stroke() def timeout(self): self.queue_draw() return True def mapped(self, unused_widget, unused_event): if self.update_timeout is None: self.update_timeout = gobject.timeout_add(1000, self.timeout) def unmapped(self, unused_widget, unused_event): if self.update_timeout is not None: gobject.source_remove(self.update_timeout) self.update_timeout = None def select_city(self, city): self.selected = city loc = self.tzdb.get_loc(city) if loc: offset = (loc.raw_utc_offset.days * 24) + \ (loc.raw_utc_offset.seconds / 60.0 / 60.0) self.selected_offset = str(offset) self.queue_draw() def convert_xy_to_offset(self, x, y): pixels = self.visible_map_pixels rowstride = self.visible_map_rowstride x = int(x) y = int(y) try: c = [] c.append(ord(pixels[(rowstride * y + x * 4)])) c.append(ord(pixels[(rowstride * y + x * 4)+1])) c.append(ord(pixels[(rowstride * y + x * 4)+2])) c.append(ord(pixels[(rowstride * y + x * 4)+3])) for offset in color_codes: if color_codes[offset] == c: return offset except IndexError: print 'Mouse click outside of the map.' return None def button_press(self, unused_widget, event): x = int(event.x) y = int(event.y) o = self.convert_xy_to_offset(x, y) if not o: return self.selected_offset = o if (x, y) == self.previous_click and self.distances: self.dist_pos = (self.dist_pos + 1) % len(self.distances) zone = self.distances[self.dist_pos][1].zone else: self.distances = [] height = self.background.get_height() width = self.background.get_width() has_context = self.selected_offset.count('|') > 0 for loc in self.tzdb.locations: offset = (loc.raw_utc_offset.days * 24) + \ (loc.raw_utc_offset.seconds / 60.0 / 60.0) if str(offset) != self.selected_offset.split('|')[0]: continue pointx = convert_longitude_to_x(loc.longitude, width) pointy = convert_latitude_to_y(loc.latitude, height) if has_context: pointo = self.convert_xy_to_offset(pointx, pointy) same_context = pointo == o else: same_context = True dx = pointx - x dy = pointy - y dist = dx * dx + dy * dy self.distances.append((dist, loc, same_context)) self.distances.sort() # If this zone takes context into consideration (like # distinguishing sides of MX/US border), then move the # nearest city in the same context to the front. We # only do this for the first city (i.e. the first click). for i in range(len(self.distances)): if self.distances[i][2]: if i > 0: self.distances.insert(0, self.distances.pop(i)) break # Disable for now. As there are only a handful of cities in each # time zone band, it seemingly makes sense to cycle through all of # them. #self.distances = self.distances[:5] self.previous_click = (x, y) self.dist_pos = 0 zone = self.distances[0][1].zone self.emit('city-selected', zone) self.queue_draw() gobject.type_register(TimezoneMap)