#!/usr/bin/env python # pytopo: show topographic maps using data files from Topo! CD # to generate a map at the specified coordinates. # Home page: http://shallowsky.com/software/topo/ # Copyright 2005 - 2011 by Akkana Peck, akkana@shallowsky.com # Please feel free to use, distribute or modify this program # under the terms of the GPL v2 or, at your option, a later GPL version. # I'd appreciate hearing about it if you make any changes. # We will do all calculations in decimal degrees, # but take inputs in deg.decimal_minutes. """ 1.1 Release notes: This is mostly a reliability/stability release. Fixed a lot of little bugs associated with some of the new features: bulk downloading works better (though still needs more work), saved sites and loaded track logs work much better, no error if pin.png isn't in the right place, etc. 1.0 Release notes: PyTopo now supports dragging the map! Wahoo! 0.9 Release notes: NEW FORMAT OSMMapCollection handles collections of tiles (maplets) downloaded from the openstreetmap project Finding zero-length files: find Maps/opencyclemap/13 -size 0 cd Maps/opencyclemap/13/1429 #wget http://a.andy.sandbox.cloudmade.com/tiles/cycle/13/1429/3230.png wget http://b.tile.opencyclemap.org/cycle/13/1473/3236.png Downloading your own file collection (faster than doing it interactively): cd Maps/opencyclemap foreach level (*) cd Maps/opencyclemap/$level foreach dir (*) cd Maps/opencyclemap/$level/$dir foreach fil (`find . -size 0`) echo $dir/$fil rm $fil #wget http://a.andy.sandbox.cloudmade.com/tiles/cycle/$level/$dir/$fil wget http://b.tile.opencyclemap.org/cycle/$level/$dir/$fil end end end end TODO: allow configuration of colors, line thickness etc. """ VersionString = "PyTopo Version 1.0 by Akkana Peck" import sys, os, glob, math, gtk, gobject, gc settings = gtk.settings_get_default() settings.set_property("gtk-touchscreen-mode", True) HaveDOM = True try : import xml.dom.minidom except ImportError: HaveDOM = False import types, urllib, re #import pdb #import traceback #import cProfile ######################################################### # # Global Variables that you can override in your .pytopo # (which may live in $HOME or in $home/.config/pytopo): # # Map collections you have, with format information. # You must define at least one collection in .pytopo. # It should be a list of instances of MapCollection subtypes. # Example: #Collections = [ # Topo1MapCollection( "mojave", "/home/name/Maps/emj_data", 7.5, 269, 328 ), # GenericMapCollection( "pa-geo", "/home/name/Maps/pa-geo-300", # "pa-geo-300-", ".jpg", # -122.497, 37.498, 300, 400, 10746, 13124, # 2, True, False ) #] # The currently supported types are: # # Topo1MapCollection: data from local-area Topo! packages, # the kind that have 7.5 minute and 15 minute varieties included. # (self, _name, _location, _series, tile_w, tile_h) : # # Topo2MapCollection: data from local-area Topo! packages that # have only the 7.5-minute series and use jpg instead of gif. # (collection_name, directory_path, file_prefix, tile_w, tile_h) : # # GenericMapCollection: a more general reader, for maps you split up # yourself or the Topo! national park maps. # ( collection_name, directory_path, filename_prefix, filename_suffix, # left_longitude, top_latitude, x_scale, y_scale, # num_digits, use_dash, latitude_first ) # Filenames might look like: pa-map-03-17.png # where prefix and suffix are pa-map- and .png, # left_longitude and top_latitude specify the top left corner of # the (0, 0) image in degrees.decimal_minutes, # x_scale and y_scale are in pixels per degree, # num_digits is the number of digits used to specify grid points, # usedash specifies whether to put a dash between grid numbers, # and latitude_first indicates that latitude changes more rapidly # than longitude (i.e. in pa-map-03-17.png, it's the third map over # and the 17th map down). # GenericMapCollection is subject to change (to add new parameters) as # different types of map are added and the rules need to be generalized. #Collections = [] # Named sites you might want to use as starting points. # Format: [ sitename, longitude, latitude, collection_name ] # Coordinates are in degrees.decimal_minutes. # Example: # KnownSites = [ # # San Francisco Bay Area # [ "saratogagap", 122.0725, 37.155, "sfr" ], # [ "lexington", 121.594, 37.12, "sfr" ], # # Death Valley # [ "zabriskie", 116.475, 36.245, "deathvalley" ], # # From the Big Sur map: # [ "pinnacles", 121.0865, 36.3247, "bigsur" ], # ] #KnownSites = [] # # End of variables likely to need customization. # ######################################################### ######################################################### # # Types of map collections we understand. # If you split your own map into maplets, you may # want to define your own subclass to handle it: # see GenericMapCollection for an example. # # You can put your own subclasses in ~/.pytopo, # but please consider contributing them so I can # integrate them into future PyTopo releases! # Debug = False class MapCollection : """A MapCollection is a set of maplet tiles on disk, combined with knowledge about the geographic coordinates and scale of those tiles so they can be drawn in a map window. Child classes implementing MapCollection must define functions __init__, get_maplet, draw_map, and get_top_left. Get_top_left() is only for debugging, when you're trying to figure out map coordinates and need a starting place. Should probably remove it. """ def __init__(self, _name, _location) : self.name = _name self.location = _location # Set some defaults so that we can test pytopo with a null collection: self.img_width = 100 self.img_height = 100 self.xscale = 100. self.yscale = 100. def get_maplet(self, longitude, latitude) : """Returns pixbuf, x_offset, y_offset: - the pixbuf for the maplet image (or null) - the offset in pixels into the image for the given coordinates, from top left. """ return None, 0, 0 def draw_map(self, center_lon, center_lat, drawwin) : """Draw a map in a window, centered around the specified coordinates. drawwin is a DrawWin object.""" return def get_top_left(self) : """A way to display some part of a map collection even if we're fuzzy on the coordinates -- get the coordinate of the first maplet and return as longitude, latitude.""" return 0, 0 def zoom(self, amount, latitude=45) : """Zoom by the given number of steps (positive to zoom in, negative to zoom out). Pass amount=0 to recalculate/redraw. Some map collections need to know latitude to determine scale. """ return def zoom_to(self, newzoom, latitude=45) : """Zoom to a specific zoom level and recalculate scales. Some map collections need to know latitude to determine scale. """ return def exists(self) : """Does the collection have its map files in place?""" self.location = os.path.expanduser(self.location) return os.access(self.location, os.X_OK) class TiledMapCollection(MapCollection) : """Code common to map collections that have raster tiles of a fixed size. TiledMapCollection classes must implement (pixbuf, x_off, y_off, pathname) = get_maplet(curlon, curlat) (pixbuf, newpath) = get_next_maplet(oldpath, dX, dY) """ def __init__(self, _name, _location, _tile_w, _tile_h) : MapCollection.__init__(self, _name, _location) self.img_width = _tile_w self.img_height = _tile_h # For collections that support downloading new tiles, # keep a list of tiles that still need downloading: self.download_tiles = DownloadTileQueue() self.download_func = None self.download_failures = 0 def draw_map(self, center_lon, center_lat, mapwin) : """Draw maplets at the specified coordinates, to fill the mapwin.""" # Get the current window size: win_width, win_height = mapwin.get_size() if (Debug) : print "Window is", win_width, "x", win_height # Now that we have a latitude, call zoom so we can finally # set the x and y scales accurately. self.zoom(0, center_lat) # Find the coordinate boundaries for the set of maps to draw. # This may (indeed, usually will) include maps partly off the screen, # so the coordinates will span a greater area than the visible window. if (Debug) : print "Calculating boundaries: min =", \ MapUtils.DecDegToDegMinStr(center_lon), \ center_lon, "+/-", win_width, \ "/", self.xscale, "/ 2" min_lon = center_lon - win_width / self.xscale / 2 max_lon = center_lon + win_width / self.xscale / 2 min_lat = center_lat - win_height / self.yscale / 2 max_lat = center_lat + win_height / self.yscale / 2 if (Debug) : print "Map from", min_lon, MapUtils.DecDegToDegMinStr(min_lon), \ MapUtils.DecDegToDegMinStr(min_lat), \ "to", MapUtils.DecDegToDegMinStr(max_lon), \ MapUtils.DecDegToDegMinStr(max_lat) # Start from the upper left: min_lon, max_lat #pdb.set_trace() curlat = max_lat cur_y = 0 y_maplet_name = None initial_x_off = None while cur_y < win_height: curlon = min_lon cur_x = 0 x_maplet_name = None while cur_x < win_width : # Reset the expected image size: w = self.img_width h = self.img_height # Is it the first maplet in this row? if x_maplet_name == None : # Is it the first maplet in the map -- # usually the one in the upper left corner? # Then we need to specify coordinates. if y_maplet_name == None : pixbuf, x_off, y_off, x_maplet_name = \ self.get_maplet(curlon, curlat) # Save the x offset: we'll need it for the # beginning of each subsequent row. initial_x_off = x_off # Not upper left corner -- # must be the beginning of a new row. # Get the maplet below the beginning of the last row. else : pixbuf, x_maplet_name = \ self.get_next_maplet(y_maplet_name, 0, 1) x_off = initial_x_off y_off = 0 # Either way, whether or not we got a pixbuf, # if we're at the beginning of a row, save the # beginning-of-row maplet name and the offset: if cur_x == 0 : y_maplet_name = x_maplet_name # Continuing an existing row. # Get the maplet to the right of the last one. else : pixbuf, x_maplet_name = self.get_next_maplet(x_maplet_name, 1, 0) x_off = 0 if Debug : print " ", x_maplet_name x = cur_x y = cur_y # If the pixbuf wasn't available, the collection may return # a URL to be downloaded. Check for that: if type(pixbuf) == types.TupleType : # XXX Make sure it's not not already queued for download: self.download_tiles.push(pixbuf[0], pixbuf[1], x, y, x_off, y_off, mapwin) pixbuf = None w, h = self.draw_one_tile(pixbuf, mapwin, x, y, x_off, y_off) # You may ask, why not just do this subtraction before # draw_pixbuf so we don't have to subtract w and h twice? # Alas, we may not have the real w and h until we've done # pixbuf.get_width(), so we'd be subtracting the wrong thing. # XXX Not really true any more, since we're assuming fixed # XXX tile size. Revisit this! cur_x += w curlon += float(w) / self.xscale if (Debug) : print " " print "New row: adding y =", h, print "Subtracting lat", float(h) / self.yscale cur_y += h curlat -= float(h) / self.yscale #curlat -= float(self.img_height) / self.yscale # Free all pixbuf data. Just letting pixbuf go out of scope # isn't enough; it's necessary to force garbage collection # otherwise Python will let the process grow until it # fills all of memory. # http://www.daa.com.au/pipermail/pygtk/2003-December/006499.html # (At this indentation level, we free after draing the whole map.) gc.collect() # If we queued any downloads, schedule a function to take care of that: if len(self.download_tiles) > 0 and self.download_func == None : gobject.timeout_add(300, self.download_more) def get_next_maplet_name(self, fullpathname, dX, dY) : """Starting from a maplet name, get the one a set distance away.""" return def get_next_maplet(self, fullpathname, dX, dY) : """Given a maplet's pathname, get the next or previous one. May not work for jumps more than 1 in any direction. Returns pixbuf, newpath (either may be None). """ return def draw_one_tile(self, pixbuf, mapwin, x, y, x_off, y_off) : """Draw a single tile, perhaps after downloading it.""" if pixbuf != None : w = pixbuf.get_width() - x_off h = pixbuf.get_height() - y_off if (Debug) : print "img size:", pixbuf.get_width(), \ pixbuf.get_height() # If the image won't completely fill the grid space, # fill the whole rectangle first with black. # Note: this may not guard against images with # transparent areas. Don't do that. if (pixbuf.get_width() < self.img_width or pixbuf.get_height() < self.img_height) : mapwin.set_bg_color() mapwin.draw_rectangle(1, x, y, self.img_width, self.img_height) if (Debug) : print "Filling in background:", x, y, print self.img_width, self.img_height # if (Debug) : # print "Drawing maplet for", # print MapUtils.DecDegToDegMinStr(curlon), # print MapUtils.DecDegToDegMinStr(curlat), # print "at", x, y, "offset", x_off, y_off, # print "size", w, h mapwin.draw_pixbuf(pixbuf, x_off, y_off, x, y, w, h) # Make sure the pixbuf goes out of scope properly: pixbuf = 0 else : # if (Debug) : # print "No maplet for", curlon, curlat, # print "at", x, y, "offset", x_off, y_off mapwin.set_bg_color() w = self.img_width - x_off h = self.img_height - y_off mapwin.draw_rectangle(1, x, y, w, h) # Useful when testing: if (Debug) : mapwin.set_grid_color() mapwin.draw_rectangle(0, x, y, w, h) mapwin.draw_line(x, y, x+w, y+h) mapwin.set_bg_color() return w, h def download_finished(self, path) : """Callback when a tile finishes downloading. The path argument is either the local file path just downloaded, or an exception, e.g. IOError. """ # If we got too many failures -- usually IOError, # perhaps we're offline -- path will be None here. # In that case, just give up on downloading. if path == None : self.download_failures += 1 if self.download_failures > 5 : print "Download failed; giving up" self.download_func = None # Clear self.download_tiles, so that if the net returns # we'll start on new stuff, not old stuff. # Not clear if this is the right thing to do or not. self.download_tiles = DownloadTileQueue() self.download_failures = 0 return # Otherwise, we got a path for a successful tile download. # Reset the failure counter: #self.download_failures += 1 # Draw it on the map: url, path, x, y, x_off, y_off, mapwin = self.download_tiles.pop() try : pixbuf = gtk.gdk.pixbuf_new_from_file(path) self.draw_one_tile(pixbuf, mapwin, x, y, x_off, y_off) except Exception, e: print "Couldn't draw tile:", e self.download_failures += 1 # Redraw any trackpoints, since they might have been overwritten: mapwin.draw_trackpoints() # It's okay to start a new download now: self.download_func = None # Anything more to download? if len(self.download_tiles) > 0 : self.download_more() def download_more(self) : """Idle/timeout proc to download any pending tiles. Should always return False so it won't get rescheduled. Eventually this should download in a separate thread. """ # If we already have a download going, don't start another one # (eventually we'll want to run several in parallel). if self.download_func != None : if Debug : print "There's already a download going; not downloading more" return False # If there are no more tiles to download, we're done: if len(self.download_tiles) == 0 : self.download_func = None return False url, path, x, y, x_off, y_off, mapwin = self.download_tiles.peek() # Don't actually pop() it until it has downloaded. #urllib.urlretrieve(url, path) self.download_func = start_job(download_job(url, path, self.download_finished)) if Debug : print "Started download %s to %s" % (url, path) return False class OSMMapCollection(TiledMapCollection) : """ A collection of tiles downloaded from the OpenStreetMap project or one of its renderers, using the OSM naming scheme. See also http://tfischernet.wordpress.com/2009/05/04/drawing-gps-traces-on-map-tiles-from-openstreetmap/ """ def __init__(self, _name, _location, _ext, _img_width, _img_height, _init_zoom, _download_url=None) : """arguments: name -- user-visible name of the collection location -- directory on disk where the maps reside ext -- filename extension including the dot, e.g. .jpg img_width -- width of each maplet img_height -- height of each maplet init_zoom -- default initial zoom level download_url -- try to download missing maplets from here """ TiledMapCollection.__init__(self, _name, _location, _img_width, _img_height) self.ext = _ext self.img_width = _img_width self.img_height = _img_height self.zoomlevel = _init_zoom self.powzoom = 2.0 ** self.zoomlevel # to avoid re-re-calculating self.download_url = _download_url self.location = os.path.expanduser(self.location) # Handle ~ format for location # If we're download-capable, we'd better have a directory # to download to, so make it if it's not there already: if self.download_url and not os.access(self.location, os.W_OK) : # XXX wrap in a try, show user-visible error dialog! os.makedirs(self.location) # Call zoom so we set all scales appropriately: self.zoom(0) # Utilities for mapping tiles to/from degrees. # From http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames def deg2num(self, lat_deg, lon_deg, zoom=None): """Map coordinates to tile numbers and offsets""" if zoom : powzoom = 2.0 ** zoom else : powzoom = self.powzoom lat_rad = math.radians(lat_deg) xtilef = (lon_deg + 180.0) / 360.0 * powzoom ytilef = ((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * powzoom) xtile = int(xtilef) ytile = int(ytilef) tilesize = 256 x_off = int((xtilef - xtile) * tilesize) y_off = int((ytilef - ytile) * tilesize) return(xtile, ytile, x_off, y_off) def num2deg(self, xtile, ytile): """Map file numbers to coordinates""" lon_deg = xtile / self.powzoom * 360.0 - 180.0 lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / self.powzoom))) lat_deg = math.degrees(lat_rad) return(lat_deg, lon_deg) def zoom_to(self, newzoom, latitude=45) : """Zoom to a specific zoom level, updating scales accordingly. Pass latitude for map collections (e.g. OSM) that cover large areas so scale will tend to vary with latitude. """ if self.zoomlevel != newzoom : self.zoomlevel = newzoom self.powzoom = 2.0 ** self.zoomlevel # Get scale, in pixels / degree. # (2 ** zoomlevel) tiles covers the whole world. self.xscale = self.powzoom * 180./256. # But because of the Mercator projection, # yscale has to be adjusted for latitude. (xtile, ytile, x_off, y_off) = self.deg2num(latitude, 180) (lat1, lon1) = self.num2deg(xtile, ytile) (lat2, lon2) = self.num2deg(xtile+1, ytile-1) self.xscale = 256. / (lon2 - lon1) self.yscale = 256. / (lat2 - lat1) if Debug : print "Zoom to %d: Calculated scales: %f, %f" \ % (self.zoomlevel, self.xscale, self.yscale) return def zoom(self, amount, latitude=45) : """Zoom in or out by the specified amount, updating the scales appropriately. Call zoom(0) to update x/y scales without changing zoom level. Pass latitude for map collections (e.g. OSM) that cover large areas so scale will tend to vary with latitude. """ self.zoom_to(self.zoomlevel + amount, latitude) def get_maplet(self, longitude, latitude) : """Fetch or queue download for the maplet containing the specified coordinates. Input coordinates are in decimal degrees. Returns pixbuf, x_offset, y_offset, filename where offsets are pixels from top left of the specified coords and pixbuf or (less often) filename may be None. """ (xtile, ytile, x_off, y_off) = self.deg2num(latitude, longitude) filename = os.path.join(self.location, str(self.zoomlevel), str(xtile), str(ytile)) + self.ext pixbuf = self.fetch_or_download_maplet(filename) return pixbuf, x_off, y_off, filename # maplet size is 256. Files per dir: # at zoomlevel 12, 28 # at zoomlevel 13, 53 # at zoomlevel 14, 107 def get_next_maplet_name(self, fullpathname, dX, dY) : """Starting from a maplet name, get the one a set distance away.""" fulldir, filename = os.path.split(fullpathname) ystr, ext = os.path.splitext(filename) zoomdir, xstr = os.path.split(fulldir) xstr = str(int(xstr) + dX) ystr = str(int(ystr) + dY) return os.path.join(zoomdir, xstr, ystr + ext) def get_next_maplet(self, fullpathname, dX, dY) : """Given a maplet's pathname, get the next or previous one. May not work for jumps more than 1 in any direction. Returns pixbuf, newpath (either may be None). """ newpath = self.get_next_maplet_name(fullpathname, dX, dY) if newpath == None : return None, newpath pixbuf = self.fetch_or_download_maplet(newpath) return pixbuf, newpath def url_from_path(self, path, zoomlevel=None) : """URL we need to get the given tile file""" if not zoomlevel : zoomlevel = self.zoomlevel xdir, basename = os.path.split(path) xdir = os.path.basename(xdir) return self.download_url + '/' + str(zoomlevel) + '/' \ + xdir + '/' + basename def fetch_or_download_maplet(self, path) : """Return a pixbuf if the file is on disk, else (url, path)""" if not os.access(path, os.R_OK) : if not self.download_url : if Debug : print "Downloads not enabled; skipping", path return None # path is a full path on the local filesystem, OS independent. # We need to turn it into a url (Unix path) with slashes. thedir = os.path.dirname(path) if not os.access(thedir, os.W_OK) : os.makedirs(thedir) return (self.url_from_path(path), path) try : pixbuf = gtk.gdk.pixbuf_new_from_file(path) except gobject.GError : return None return pixbuf def coords_to_filename(self, longitude, latitude) : """Given coordinates in decimal degrees, map to the closest filename""" return None def get_top_left(self) : """Get the coordinates of the top left corner of the map.""" return None, None class GenericMapCollection(TiledMapCollection) : """ A GenericMapCollection is tiled, like the Topo collections, but uses a less specific naming scheme: prefix-nn-mm.ext, with or without the dashes. """ def __init__(self, _name, _location, _prefix, _ext, _left_long, _top_lat, _img_width, _img_height, _xscale, _yscale, _numdigits, _usedash, _latfirst) : """arguments: name -- user-visible name of the collection location -- directory on disk where the maps reside prefix -- initial part of each maplet filename ext -- filename extension including the dot, e.g. .jpg left_long -- longitude of the left edge top_lat -- latitude of the top edge img_width -- width of each maplet in pixels img_height -- height of each maplet in pixels xscale -- pixels per degree longitude yscale -- pixels per degree latitude numdigits -- number of digits in x and y file specifiers usedash -- Boolean, use a dash between x and y in filenames? latfirst -- Boolean, is latitude the first of the two numbers? """ TiledMapCollection.__init__(self, _name, _location, _img_width, _img_height, ) self.prefix = _prefix self.numdigits = _numdigits self.usedash = _usedash self.ext = _ext self.latfirst = _latfirst self.img_width = _img_width self.img_height = _img_height self.left_longitude = _left_long # Left of 00-00 image self.top_latitude = _top_lat # Top of 00-00 image self.xscale = float(_xscale) # Pixels per degree self.yscale = float(_yscale) # Pixels per degree def get_maplet(self, longitude, latitude) : """Get the maplet containing the specified coordinates. Returns pixbuf, x_offset, y_offset, filename where offsets are pixels from top left of the specified coords and pixbuf or (less often) filename may be None. """ filename = self.coords_to_filename(longitude, latitude) if (Debug) : print "Generic get_maplet", longitude, latitude, "->", filename if filename == None or not os.access(filename, os.R_OK) : #print "Can't open", filename, "for", longitude, latitude return None, 0, 0, filename #print "Opened", filename, "for", longitude, latitude pixbuf = gtk.gdk.pixbuf_new_from_file(filename) # Offsets aren't implemented yet: x_off = 0 y_off = 0 return pixbuf, x_off, y_off, filename def get_next_maplet(self, fullpathname, dX, dY) : """Given a maplet's pathname, get the next or previous one. Does not currently work for jumps more than 1 in any direction. Returns pixbuf, newpath (either may be None). """ pathname, filename = os.path.split(fullpathname) if (Debug) : print "Generic get_next_maplet", filename, dX, dY name, ext = os.path.splitext(filename) #traceback.print_stack() mapb = int(name[-self.numdigits:]) if self.usedash : mapa = int(name[-self.numdigits*2 - 1 : -self.numdigits-1]) else : mapa = int(name[-self.numdigits*2 : -self.numdigits]) if self.latfirst : newa = MapUtils.ohstring(mapa + dX, self.numdigits) newb = MapUtils.ohstring(mapb + dY, self.numdigits) else : newa = MapUtils.ohstring(mapa + dY, self.numdigits) newb = MapUtils.ohstring(mapb + dX, self.numdigits) if self.usedash : newname = self.prefix + newa + "-" + newb else : newname = self.prefix + newa + newb newpath = os.path.join(self.location, newname + ext) if filename == None or not os.access(filename, os.R_OK) : return None, newpath pixbuf = gtk.gdk.pixbuf_new_from_file(newpath) return pixbuf, newpath def coords_to_filename(self, longitude, latitude) : """Given coordinates in decimal degrees, map to the closest filename""" if self.left_longitude > longitude or self.top_latitude < latitude : return None x_grid = MapUtils.intTrunc((longitude - self.left_longitude) * self.xscale / self.img_width) y_grid = MapUtils.intTrunc((self.top_latitude - latitude) * self.yscale / self.img_height) if not self.latfirst : temp = x_grid x_grid = y_grid y_grid = temp retstr = os.path.join(self.location, self.prefix + MapUtils.ohstring(x_grid, self.numdigits)) if self.usedash: retstr = retstr + "-" retstr = retstr + MapUtils.ohstring(y_grid, self.numdigits) + self.ext return retstr def get_top_left(self) : """Get the coordinates of the top left corner of the map.""" return self.left_longitude, self.top_latitude class TopoMapCollection(TiledMapCollection) : """TiledMapCollections using the Topo! map datasets. Filenames are named according to a fairly strict convention. Some variants can toggle between more than one scale (series). """ def __init__(self, _name, _location, _series, _tile_w, _tile_h, _ser7prefix="012t", _ser15prefix="024t", _img_ext=".gif") : """arguments: name -- user-visible name of the collection location -- directory on disk where the maps reside series -- initial series to use, 7.5 or 15 minutes of arc. tile_w -- width of each maplet in pixels tile_h -- height of each maplet in pixels img_ext -- filename extension including the dot, e.g. .jpg ser7prefix -- prefix for tile files implementing the 7.5-min series ser15prefix -- prefix for tile files implementing the 15-min series """ TiledMapCollection.__init__(self, _name, _location, _tile_w, _tile_h) self.set_series(_series) self.ser7prefix = _ser7prefix self.ser15prefix = _ser15prefix self.img_ext = _img_ext # _correction because Topo1 maps aren't in WGS 84. # Right now these numbers are EMPIRICAL and inaccurate. # Need to do them right! # http://www.ngs.noaa.gov/cgi-bin/nadcon.prl says the correction # in the Mojave area from NAD27 to NAD84 (nobody converts to # WGS84, alas) should be -0.05463', 2.99014' (-1.684m, 75.554m) self.lon_correction = 0 # 0.032778 / 1000 self.lat_correction = 0 # -1.794084 / 1000 def set_series(self, _series) : """Set the series to either 7.5 or 15 minutes.""" #traceback.print_stack() self.series = _series self.xscale = self.img_width * 600.0 / self.series self.yscale = self.img_height * 600.0 / self.series if (Debug) : print "set series to", self.series # 600 is minutes/degree * maplets/minute # The fraction of a degree that each maplet spans: self.frac = float(self.img_width) / self.xscale if (Debug) : if self.frac != float(self.img_height) / self.yscale : print "x and y fractions not equal!", print self.frac, float(self.img_height) / self.yscale def get_maplet(self, longitude, latitude) : """Get the maplet containing the specified coordinates. Returns pixbuf, x_offset, y_offset, filename where offsets are pixels from top left of the specified coords and pixbuf or (less often) filename may be None. """ filename = self.coords_to_filename(longitude - self.lon_correction, latitude - self.lat_correction) if (Debug) : print "T1MC get_maplet(", MapUtils.DecDegToDegMinStr(longitude), print ",", MapUtils.DecDegToDegMinStr(latitude), "):", filename # Calculate offsets. # Maplets are self.series minutes wide and tall, # so any offset from that is an offset into the maplet: # the number of pixels in X and Y that have to be added # to get from the maplet's upper left corner to the # indicated coordinates. # But then we have to correct to get to WGS84 coordinates. # XXX the WGS84 part doesn't work right yet. # longitude increases rightward: x_off = int((longitude - MapUtils.TruncateToFrac(longitude, self.frac) - self.lon_correction) * self.xscale) if (Debug) : print "truncated", MapUtils.DecDegToDegMinStr(longitude), "to", print MapUtils.DecDegToDegMinStr(MapUtils.TruncateToFrac(longitude, self.frac)) # Latitude decreases downward: y_off = int((MapUtils.TruncateToFrac(latitude, self.frac) + self.frac - latitude - self.lat_correction) * self.yscale) if (Debug) : print "truncated", MapUtils.DecDegToDegMinStr(latitude), "to", print MapUtils.DecDegToDegMinStr(MapUtils.TruncateToFrac(latitude, self.frac)) print "y_off is", y_off if not os.access(filename, os.R_OK) : return None, x_off, y_off, filename pixbuf = gtk.gdk.pixbuf_new_from_file(filename) return pixbuf, x_off, y_off, filename def get_next_maplet(self, fullpathname, dX, dY) : """Given a maplet's pathname, get the next or previous one. Does not currently work for jumps more than 1 in any direction. Returns pixbuf, newpath (either may be None). """ if (Debug) : print "get_next_maplet:", fullpathname, dX, dY pathname, filename = os.path.split(fullpathname) collecdir, mapdir = os.path.split(pathname) maplat = mapdir[1:3] maplon = mapdir[3:6] name, ext = os.path.splitext(filename) xdir = int(mapdir[-1]) ydir = ord(mapdir[-2]) - ord('a') # ydir is a letter a-h if self.series == 7.5 : serstr = self.ser7prefix grid = 10 else : serstr = self.ser15prefix grid = 5 x = int(name[-4:-2]) + dX y = int(name[-2:]) + dY if x < 1 : x = grid xdir = xdir + 1 if xdir > 8 : xdir = 1 if Debug : print mapdir, name, ": wrapping mapdir coordinates -x", print maplon maplon = str(int(maplon) + 1) if x > grid : x = 1 xdir = xdir - 1 if xdir < 1 : xdir = 8 if Debug : print mapdir, name, ": wrapping mapdir coordinates +x", print maplon maplon = str(int(maplon) - 1) if y > grid : y = 1 ydir = ydir - 1 if ydir < 0 : ydir = 7 if Debug : print mapdir, name, ": wrapping mapdir coordinates +y", print maplat maplat = str(int(maplat) - 1) if y < 1 : y = grid ydir = ydir + 1 if ydir > 7 : ydir = 0 if Debug : print mapdir, name, ": wrapping mapdir coordinates -y", print maplat maplat = str(int(maplat) + 1) # We're ready to piece the filename back together! newpath = os.path.join(collecdir, "q" + MapUtils.ohstring(maplat, 2) \ + MapUtils.ohstring(maplon, 3) \ + chr(ydir + ord('a')) + str(xdir), serstr + MapUtils.ohstring(x, 2) \ + MapUtils.ohstring(y, 2) + ext) if not os.access(newpath, os.R_OK) : if Debug : print "get_next_maplet(", fullpathname, dX, dY, ")" print " Can't open", newpath return None, newpath pixbuf = gtk.gdk.pixbuf_new_from_file(newpath) return pixbuf, newpath # # Quirk: Topo1 collections are numbered with WEST longitude -- # i.e. longitude is written as positive but it's actually negative. # # Second quirk: Topo1 collections aren't in the WGS 84 coordinate # system used by GPS receivers, and need to be translated. # http://en.wikipedia.org/wiki/Geographic_coordinate_system # http://en.wikipedia.org/wiki/Geodetic_system # def coords_to_filename(self, longitude, latitude) : """Given a pair of coordinates in deg.mmss, map to the containing filename, e.g. q37122c2/012t0501.gif. """ latDeg = MapUtils.intTrunc(latitude) longDeg = MapUtils.intTrunc(-longitude) latMin = (latitude - latDeg ) * 60. longMin = (-longitude - longDeg) * 60. # The 7.5 here is because of the 7.5 in the directory names above # (we're getting the offset of this image from the origin of # the 7.5-series map covered by the directory), # not the map series we're actually plotting now. longMinOrd = MapUtils.intTrunc(longMin / 7.5) latMinOrd = MapUtils.intTrunc(latMin / 7.5) dirname = "q" + MapUtils.ohstring(latDeg, 2) \ + MapUtils.ohstring(longDeg, 3) \ + chr(ord('a') + latMinOrd) + str(longMinOrd+1) # Find the difference between our desired coordinates # and the origin of the map this directory represents. # The 7.5 here is because of the 7.5 in the directory names above. latMinDiff = latMin - (latMinOrd * 7.5) longMinDiff = longMin - (longMinOrd * 7.5) latOffset = MapUtils.intTrunc(latMinDiff * 10 / self.series) longOffset = MapUtils.intTrunc(longMinDiff * 10 / self.series) # Now calculate the current filename. # Note that series is either 7.5 or 15 if (self.series > 13) : fileprefix = "024t" numcharts = 5 else : fileprefix = "012t" numcharts = 10 filename = fileprefix + MapUtils.ohstring(numcharts-longOffset, 2) + \ MapUtils.ohstring(numcharts-latOffset, 2) + self.img_ext return self.location + "/" + dirname + "/" + filename def dir_to_latlong(self, qdir) : """Given a directory, figure out the corresponding coords.""" letter = ord(qdir[6]) - ord('a') digit = int(qdir[7]) - 1 thislon = -int(qdir[3:6]) + (digit * 7.5 * 1.5 / 60) #thislon += self.lon_correction thislat = int(qdir[1:3]) + (letter * 7.5 * 1.5 / 60) #thislat += self.lat_correction return thislon, thislat def get_top_left(self) : """Get the coordinates of the top left corner of the map.""" minlong = 181 maxlat = -91 topleftdir = None mapdirs = os.listdir(self.location) # mapdirs.sort() for mapdir in mapdirs : if mapdir[0] == 'q' : # Now first_mapdir is some name like "qAAABBcD" ... decode it. thislong, thislat = self.dir_to_latlong(mapdir) #if thislong < minlong and thislat > maxlat : if thislong < minlong : minlong = thislong if thislat > maxlat : maxlat = thislat topleftdir = mapdir if maxlat < -90 or minlong > 180 : return 0, 0 # shouldn't happen # Now we have the top left directory. Still need the top left file: files = os.listdir(os.path.join(self.location, topleftdir)) return minlong, maxlat # End of TopoMapCollection class class Topo1MapCollection(TopoMapCollection) : """ Topo1MapCollection: data from local-area Topo! packages, the kind that have 7.5 minute and 15 minute varieties included. (self, _name, _location, _series, tile_w, tile_h) : """ def __init__(self, _name, _location, _series, _tile_w, _tile_h) : TopoMapCollection.__init__(self, _name, _location, _series, _tile_w, _tile_h, _ser7prefix="012t", _ser15prefix="024t", _img_ext=".gif") def zoom(self, amount, latitude=45) : if self.series == 7.5 and amount < 0 : self.set_series(15) elif self.series == 15 and amount > 0 : self.set_series(7.5) # A Topo2MapCollection is just a Topo1MapCollection that has only # 7.5-series and has a different file prefix. # On North Palisade 7.5 (q37118a5) we get 410x256 pixel images. class Topo2MapCollection(TopoMapCollection) : """ Topo2MapCollection: data from local-area Topo! packages that have only the 7.5-minute series and use jpg instead of gif. (collection_name, directory_path, file_prefix, tile_w, tile_h) : """ def __init__(self, _name, _location, _prefix, _tile_w, _tile_h) : TopoMapCollection.__init__(self, _name, _location, 7.5, _tile_w, _tile_h, _ser7prefix=_prefix, _ser15prefix=None, _img_ext=".jpg") class TrackPoints : """Parsing and handling of GPS track files. Currently only GPX format is supported. """ def __init__(self) : self.points = [] self.waypoints = [] self.minlon = 361 self.maxlon = -361 self.minlat = 91 self.maxlat = -91 def handleTrackPoint(self, point, waypoint=False) : #time = getVal(point, "time") lat = float(point.getAttribute("lat")) lon = float(point.getAttribute("lon")) #ele = float(getVal(point, "ele")) #ele = round(ele * 3.2808399, 2) # convert from meters to feet if lon < self.minlon : self.minlon = lon elif lon > self.maxlon : self.maxlon = lon if lat < self.minlat : self.minlat = lat elif lat > self.maxlat : self.maxlat = lat if (waypoint) : name = "WP" n = point.getElementsByTagName("name") if len(n) > 0 : n = n[0].childNodes if len(n) >= 1 and n[0].nodeType == n[0].TEXT_NODE : name = n[0].data self.waypoints.append([lon, lat, name]) else : self.points.append([lon, lat]) def get_bounds(self) : return self.minlon, self.minlat, self.maxlon, self.maxlat def readTrackFile(self, filename) : """Read a track file. Throw IOError if the file doesn't exist.""" global Debug if not os.path.exists(filename) : raise IOError("Can't open track file %s" % filename) if (Debug) : print "Using track file", filename if not HaveDOM : print "WARNING: Can't read track file: need module xml.dom.minidom" return if (Debug) : print "Reading track file", filename dom = xml.dom.minidom.parse(filename) # Handle track(s) trkpts = dom.getElementsByTagName("trkpt") for i in range (0, len(trkpts), 1) : self.handleTrackPoint(trkpts[i], False) # Handle waypoints waypts = dom.getElementsByTagName("wpt") for i in range (0, len(waypts), 1) : self.handleTrackPoint(waypts[i], True) # GPX also allows for routing, rtept, but I don't think we need those. class MapUtils : """MapUtils really just exists to contain a bunch of utility functions useful for mapping classes. """ @staticmethod def DegMinToDecDeg(coord) : """Convert degrees.minutes to decimal degrees""" deg = MapUtils.intTrunc(coord) dec = (coord - deg) / .6 return deg + dec @staticmethod def DecDegToDegMin(coord) : """Convert decimal degrees to degrees.minutes""" if coord < 0 : sgn = -1 coord = -coord else : sgn = 1 deg = MapUtils.intTrunc(coord) min = abs(coord - deg) * .6 return sgn * (deg + min) @staticmethod def DecDegToDegMinStr(coord) : """Convert decimal degrees to a nice degrees/minutes string""" if coord < 0 : sgnstr = '-' coord = -coord else : sgnstr = '' deg = MapUtils.intTrunc(coord) min = abs(coord - deg) * 60. min = MapUtils.TruncateToFrac(min, .01) return sgnstr + str(deg) + "^" + str(min) + "'" @staticmethod def angle_to_bearing(angle) : return (450 - angle) % 360 # Convert an angle (deg) to the appropriate quadrant string, e.g. N 57 E. @staticmethod def angle_to_quadrant(angle) : if angle > 180 : angle = angle - 360 if angle == 0 : return "N" if angle == -90 : return "W" if angle == 90 : return "E" if angle == 180 : return "S" if angle > -90 and angle < 90 : if angle < 0 : return "N " + str(-angle) + " W" return "N " + str(angle) + " E" if angle < 0 : return "S " + str(180 + angle) + " W" return "S " + str(180 - angle) + " E" @staticmethod def intTrunc(num) : """Truncate to an integer, but no .999999 stuff""" return int(num + .00001) @staticmethod def TruncateToFrac(num, frac) : """Truncate to a multiple of the given fraction""" t = float(MapUtils.intTrunc(num / frac)) * frac if num < 0 : t = t - frac return t @staticmethod def ohstring(num, numdigits) : """Return a zero-prefixed string of the given number of digits.""" fmt_arr = [ "", "%01d", "%02d", "%03d", "%04d", "%05d", "%06d", "%07d", "%08d" ] if numdigits < len(fmt_arr) : return fmt_arr[numdigits] % int(num) else : s = '%0' + str(numdigits) + 'd' return s % int(num) @staticmethod def ohstring_old(num, numdigits) : """Return a zero-prefixed string of the given number of digits.""" s = str(num) mult = pow(10, numdigits-1) while (numdigits > 1) : if (num < mult) : s = "0" + s mult = mult / 10 numdigits = numdigits-1 return s # End of "MapUtils" pseudo-class. class DownloadTileQueue : def __init__(self) : self.queue = [] # Will be a list def __len__(self) : return len(self.queue) def push(self, url, path, x, y, x_off, y_off, mapwin) : """Push details for a new tile onto the queue if not already there, or replace XY info if already there -- the map must have moved. """ for q in self.queue : if q[1] == path : # Are paths the same? # Replace XY info q[2] = x q[3] = y q[4] = x_off q[5] = y_off return if path : self.queue.insert(0, [url, path, x, y, x_off, y_off, mapwin]) def pop(self) : return self.queue.pop() def peek(self) : return self.queue[-1] class MapWindow() : """The PyTopo UI: the map window. This is intended to hold the GTK specific drawing code, and to be extensible into other widget libraries. To that end, it needs to implement the following methods that are expected by the MapCollection classes: win_width, win_height = get_size() set_bg_color(), set_grid_color(), set_map_color() draw_pixbuf(pixbuf, x_off, y_off, x, y, w, h) draw_rectangle(fill, x, y, width, height) draw_line(x, y, width, height) """ def __init__(self, _controller) : """Initialize variables, but don't create the widow yet.""" # Save a reference to the PyTopo object that created this window. # We'll need it to change locations, collections etc. self.controller = _controller # The current map collection being used: self.collection = None self.center_lon = 0 self.center_lat = 0 self.cur_lon = 0 self.cur_lat = 0 self.trackpoints = None try : self.pin = \ gtk.gdk.pixbuf_new_from_file("/usr/share/pytopo/pytopo-pin.png") except : try : self.pin = gtk.gdk.pixbuf_new_from_file("pytopo-pin.png") except : self.pin = None self.pin_lon = 0 self.pin_lat = 0 self.pin_xoff = -4 self.pin_yoff = -12 # Print distances in metric? # This should be set externally! self.use_metric = False # Where to save generated maps. The default is fine for most people. # Which is a good thing since there's currently no way to change it. self.map_save_dir = os.path.expanduser("~/Topo/") # X/gtk graphics variables we need: self.drawing_area = 0 self.xgc = 0 self.click_last_long = 0 self.click_last_lat = 0 self.is_dragging = False self.bg_color = gtk.gdk.color_parse("black") self.track_color = gtk.gdk.color_parse("magenta") self.waypoint_color = gtk.gdk.color_parse("blue") # Grid color is only needed when Debug, # but this is called before parse_args so we don't know Debug. self.grid_color = gtk.gdk.color_parse("green") # The timeout for long press events self.press_timeout = None def show_window(self, init_width, init_height) : """Create the initial window.""" win = gtk.Window() win.set_name("PyTopo") win.connect("destroy", self.graceful_exit) win.set_border_width(5) vbox = gtk.VBox(spacing=3) win.add(vbox) self.drawing_area = gtk.DrawingArea() self.drawing_area.set_size_request(init_width, init_height) vbox.pack_start(self.drawing_area) self.drawing_area.set_events(gtk.gdk.EXPOSURE_MASK | gtk.gdk.POINTER_MOTION_MASK | gtk.gdk.POINTER_MOTION_HINT_MASK | gtk.gdk.BUTTON_PRESS_MASK | gtk.gdk.BUTTON_RELEASE_MASK ) self.drawing_area.connect("expose-event", self.expose_event) self.drawing_area.connect("button-press-event", self.mousepress) self.drawing_area.connect("button-release-event", self.mouserelease) self.drawing_area.connect("motion_notify_event", self.drag_event) # The default focus in/out handlers on drawing area cause # spurious expose events. Trap the focus events, to block that: # XXX can we pass "pass" in to .connect? self.drawing_area.connect("focus-in-event", self.nop) self.drawing_area.connect("focus-out-event", self.nop) # Handle key presses on the drawing area. # If seeing spurious expose events, try setting them on win instead, # and comment out gtk.CAN_FOCUS. self.drawing_area.set_flags(gtk.CAN_FOCUS) self.drawing_area.connect("key-press-event", self.key_press_event) win.show_all() gtk.main() # # Draw maplets to fill the window, centered at center_lon, center_lat # def draw_map(self) : """Redraw the map, centered at center_lon, center_lat.""" global Debug if self.collection == None : print "No collection!" return if not self.drawing_area : # Not initialized yet, not ready to draw a map return # XXX Collection.draw_map wants center, but we only have lower right. if (Debug) : print ">>>>>>>>>>>>>>>>" print "window draw_map centered at", print MapUtils.DecDegToDegMinStr(self.center_lon), print MapUtils.DecDegToDegMinStr(self.center_lat) self.collection.draw_map(self.center_lon, self.center_lat, self) self.draw_trackpoints() if not self.is_dragging : self.draw_zoom_control() # draw pin win_width, win_height = self.drawing_area.window.get_size() pin_x, pin_y = self.coords2xy(self.pin_lon, self.pin_lat, win_width, win_height) if self.pin : self.draw_pixbuf(self.pin, 0,0,pin_x+self.pin_xoff, pin_y+self.pin_yoff,-1,-1) def draw_trackpoints(self) : # Now draw any trackpoints that are visible: if self.trackpoints != None : # may be trackpoints or waypoints win_width, win_height = self.drawing_area.window.get_size() if len(self.trackpoints.points) > 0 : cur_x = None cur_y = None self.xgc.line_style = gtk.gdk.LINE_ON_OFF_DASH self.xgc.line_width = 3 self.set_track_color() for pt in self.trackpoints.points : x = int((pt[0] - self.center_lon) * self.collection.xscale + win_width/2) y = int((self.center_lat - pt[1]) * self.collection.yscale + win_height/2) if ((x >= 0 and x < win_width and y >= 0 and y < win_height) or (cur_x < win_width and cur_y < win_height)) : if cur_x != None and cur_y != None : self.draw_line(cur_x, cur_y, x, y) cur_x = x cur_y = y else : #print "Skipping", pt[0], pt[1], \ # ": would be", x, ",", y cur_x = None cur_y = None if len(self.trackpoints.waypoints) > 0 : self.set_waypoint_color() self.xgc.line_style = gtk.gdk.LINE_SOLID self.xgc.line_width = 2 for pt in self.trackpoints.waypoints : x = int((pt[0] - self.center_lon) * self.collection.xscale + win_width/2) y = int((self.center_lat - pt[1]) * self.collection.yscale + win_height/2) if x >= 0 and x < win_width and y >= 0 and y < win_height : self.draw_string(x, y, pt[2]) self.draw_rectangle(True, x-3, y-3, 6, 6) def draw_zoom_control(self) : """Draw some zoom controls in case we're running on a tablet and have no keyboard to zoom or move around. Also draw any other controls we might need. """ win_width, win_height = self.drawing_area.window.get_size() self.zoom_btn_size = int(win_width / 25) self.zoom_X1 = 8 self.zoom_in_Y1 = 10 self.zoom_out_Y1 = self.zoom_in_Y1 + self.zoom_btn_size * 2 textoffset = self.zoom_btn_size / 5 self.xgc.line_style = gtk.gdk.LINE_SOLID self.set_bg_color() self.xgc.line_width = 3 # Draw the boxes self.draw_rectangle(False, self.zoom_X1, self.zoom_in_Y1, self.zoom_btn_size, self.zoom_btn_size) self.draw_rectangle(False, self.zoom_X1, self.zoom_out_Y1, self.zoom_btn_size, self.zoom_btn_size) midpointx = self.zoom_X1 + self.zoom_btn_size/2 # Draw the - midpointy = self.zoom_out_Y1 + self.zoom_btn_size/2 self.draw_line(self.zoom_X1 + textoffset, midpointy, self.zoom_X1 + self.zoom_btn_size - textoffset, midpointy) # Draw the + midpointy = self.zoom_in_Y1 + self.zoom_btn_size/2 self.draw_line(self.zoom_X1 + textoffset, midpointy, self.zoom_X1 + self.zoom_btn_size - textoffset, midpointy) self.draw_line(midpointx, self.zoom_in_Y1 + textoffset, midpointx, self.zoom_in_Y1 + self.zoom_btn_size - textoffset) def was_click_in_zoom(self, x, y) : """Do the coordinates fall within the zoom in or out buttons? Returns 0 for none, 1 for zoom in, -1 for zoom out. """ if x < self.zoom_X1 or x > self.zoom_X1 + self.zoom_btn_size : return 0 if y < self.zoom_in_Y1 or y > self.zoom_out_Y1 + self.zoom_btn_size : return 0 if y < self.zoom_in_Y1 + self.zoom_btn_size : return 1 if y > self.zoom_out_Y1 : return -1 # Must be between buttons return 0 def context_menu(self, event) : menu = gtk.Menu() # Don't need to show menus # Create the menu items centerpin_item = gtk.MenuItem("Go to pin...") pin_item = gtk.MenuItem("Pin this location") save_item = gtk.MenuItem("Save pin location...") locations_item = gtk.MenuItem("My Locations...") tracks_item = gtk.MenuItem("My Tracks...") download_item = gtk.MenuItem("Download Area...") quit_item = gtk.MenuItem("Quit") # Add them to the menu menu.append(centerpin_item) menu.append(pin_item) menu.append(save_item) menu.append(locations_item) menu.append(tracks_item) menu.append(download_item) menu.append(quit_item) # Attach the callback functions to the activate signal centerpin_item.connect("activate", self.set_center_to_pin) pin_item.connect("activate", self.set_pin_by_mouse) save_item.connect("activate", self.save_location) locations_item.connect("activate", self.mylocations) tracks_item.connect("activate", self.mytracks) download_item.connect("activate", self.download_area) # We can attach the Quit menu item to our exit function quit_item.connect_object ("activate", self.graceful_exit, "quit") # We do need to show menu items centerpin_item.show() pin_item.show() save_item.show() locations_item.show() tracks_item.show() download_item.show() quit_item.show() if event : button = event.button t = event.time else : button = 3 t = 0 # There's no documentation on what event.time is: it's # "the time of the event in milliseconds" -- but since when? # Not since the epoch. menu.popup(None, None, None, button, t) def mylocations(self, widget) : self.controller.location_select(self) def set_pin_by_mouse(self, widget) : """Set the pin at the current mouse location""" self.pin_lon, self.pin_lat = self.cur_lon, self.cur_lat self.draw_map() def set_center_to_pin(self, widget) : """Set the center at the current pin point""" self.center_lon, self.center_lat = self.pin_lon, self.pin_lat self.draw_map() def save_location(self, widget) : """Save the pinned location. XXX should save zoom level too, if different from collection default. """ dialog = gtk.Dialog("Save location", None, 0, (gtk.STOCK_CANCEL, gtk.RESPONSE_NONE, gtk.STOCK_OK, gtk.RESPONSE_OK)) dialog.set_size_request(200, 150) dialog.vbox.set_spacing(10) prompt = gtk.Label("Please specify a name:") dialog.vbox.pack_start(prompt, expand=False) nametext = gtk.Entry() dialog.vbox.pack_start(nametext, expand=True) comment = gtk.Label("") dialog.vbox.pack_start(comment, expand=False) dialog.show_all() while True : response = dialog.run() if response == gtk.RESPONSE_OK : name = nametext.get_text().strip() if not name : comment.set_text("Name can't be empty") continue # Add to KnownSites self.controller.append_known_site( [name, MapUtils.DecDegToDegMin(self.pin_lon), MapUtils.DecDegToDegMin(self.pin_lat), self.collection.name, self.collection.zoomlevel] ) dialog.destroy() return True else : dialog.destroy() return True def mytracks(self, widget) : self.controller.TrackSelect(self) if self.trackpoints != None : self.trackpoints_center() self.draw_map() def trackpoints_center(self) : minlon, minlat, maxlon, maxlat = self.trackpoints.get_bounds() self.center_lon = (maxlon + minlon) / 2 self.center_lat = (maxlat + minlat) / 2 def cancel_download(self, widget, data=None) : self.cancelled = True def download_area(self, widget) : global Debug if not self.collection.zoomlevel : print "Can't download an area for this collection" return # Get default values for area and zoom levels: win_width, win_height = self.get_size() halfwidth = win_width / self.collection.xscale / 2 halfheight = win_height / self.collection.yscale / 2 minlon = self.center_lon - halfwidth maxlon = self.center_lon + halfwidth minlat = self.center_lat - halfheight maxlat = self.center_lat + halfheight minzoom = self.collection.zoomlevel maxzoom = self.collection.zoomlevel + 4 # Prompt the user for any adjustments to area and zoom: dialog = gtk.Dialog("Download an area", None, 0, (gtk.STOCK_REFRESH, gtk.RESPONSE_APPLY, gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OK, gtk.RESPONSE_OK)) #dialog.set_size_request(200, 150) #dialog.vbox.set_spacing(10) frame = gtk.Frame("Current zoom = %d" % self.collection.zoomlevel) dialog.vbox.pack_start(frame, True, True, 0) table = gtk.Table(4, 3, False) table.set_border_width(5) table.set_row_spacings(5) table.set_col_spacings(10) frame.add(table) label = gtk.Label("Min longitude:") label.set_justify(gtk.JUSTIFY_RIGHT) table.attach(label, 0, 1, 0, 1, gtk.SHRINK, 0, 0, 0) minlon_entry = gtk.Entry() table.attach(minlon_entry, 1, 2, 0, 1, gtk.EXPAND | gtk.FILL, 0, 0, 0) label = gtk.Label("Max longitude:") label.set_justify(gtk.JUSTIFY_RIGHT) table.attach(label, 2, 3, 0, 1, gtk.SHRINK, 0, 0, 0) maxlon_entry = gtk.Entry() table.attach(maxlon_entry, 3, 4, 0, 1, gtk.EXPAND | gtk.FILL, 0, 0, 0) label = gtk.Label("Min latitude:") label.set_justify(gtk.JUSTIFY_RIGHT) table.attach(label, 0, 1, 1, 2, gtk.SHRINK, 0, 0, 0) minlat_entry = gtk.Entry() table.attach(minlat_entry, 1, 2, 1, 2, gtk.EXPAND | gtk.FILL, 0, 0, 0) label = gtk.Label("Max latitude:") label.set_justify(gtk.JUSTIFY_RIGHT) table.attach(label, 2, 3, 1, 2, gtk.SHRINK, 0, 0, 0) maxlat_entry = gtk.Entry() table.attach(maxlat_entry, 3, 4, 1, 2, gtk.EXPAND | gtk.FILL, 0, 0, 0) label = gtk.Label("Min zoom:") label.set_justify(gtk.JUSTIFY_RIGHT) table.attach(label, 0, 1, 2, 3, gtk.SHRINK, 0, 0, 0) minzoom_entry = gtk.Entry() table.attach(minzoom_entry, 1, 2, 2, 3, gtk.EXPAND | gtk.FILL, 0, 0, 0) label = gtk.Label("Max zoom:") label.set_justify(gtk.JUSTIFY_RIGHT) table.attach(label, 2, 3, 2, 3, gtk.SHRINK, 0, 0, 0) maxzoom_entry = gtk.Entry() table.attach(maxzoom_entry, 3, 4, 2, 3, gtk.EXPAND | gtk.FILL, 0, 0, 0) err_label = gtk.Label("") dialog.vbox.pack_start(err_label, True, True, 0) progress_label = gtk.Label("") dialog.vbox.pack_start(progress_label, True, True, 0) def flush_events() : while gtk.events_pending(): gtk.main_iteration(False) def reset_download_dialog() : minlon_entry.set_text(str(minlon)) maxlon_entry.set_text(str(maxlon)) minlat_entry.set_text(str(minlat)) maxlat_entry.set_text(str(maxlat)) minzoom_entry.set_text(str(minzoom)) maxzoom_entry.set_text(str(maxzoom)) reset_download_dialog() dialog.show_all() self.cancelled = False while True : response = dialog.run() if response == gtk.RESPONSE_CANCEL: dialog.destroy() return True if response == gtk.RESPONSE_APPLY: reset_download_dialog() continue # Else the response must have been OK. # So connect the cancel button to cancel_download(), # which means first we have to find the cancel button: # Starting with PyGTK 2.22 we can use this easier method: #cancelBtn = dialog.get_widget_for_response(gtk.RESPONSE_OK) # but for now: buttons = dialog.get_action_area().get_children() for b in buttons : if b.get_label() == 'gtk-cancel' : b.connect("clicked", self.cancel_download, str) break try : minlon = float(minlon_entry.get_text().strip()) maxlon = float(maxlon_entry.get_text().strip()) minlat = float(minlat_entry.get_text().strip()) maxlat = float(maxlat_entry.get_text().strip()) minzoom = int(minzoom_entry.get_text().strip()) maxzoom = int(maxzoom_entry.get_text().strip()) break except ValueError : err_label.set_text("Sorry, can't parse one of the values") continue if Debug : print "Downloading from %f - %f, %f - %f, zoom %d - %d" \ % (minlon, maxlon, minlat, maxlat, minzoom, maxzoom) for zoom in range(minzoom, maxzoom+1) : err_label.set_text("Downloading zoom level %d" % zoom) # Show a busy cursor on the dialog: busy_cursor = gtk.gdk.Cursor(gtk.gdk.WATCH) dialog.window.set_cursor(busy_cursor) flush_events() gtk.gdk.flush() if Debug : print "==== Zoom level", zoom # Find the start and end tiles (minxtile, minytile, x_off, y_off) = \ self.collection.deg2num(maxlat, minlon, zoom) (maxxtile, maxytile, x_off, y_off) = \ self.collection.deg2num(minlat, maxlon, zoom) if Debug : print "X tiles from", minxtile, "to", maxxtile print "Y tiles from", minytile, "to", maxytile pathlist = [] for ytile in range(minytile, maxytile+1) : for xtile in range(minxtile, maxxtile+1) : if Debug : print "Tile", xtile, ytile, filename = os.path.join(self.collection.location, str(zoom), str(xtile), str(ytile)) \ + self.collection.ext if os.access(filename, os.R_OK) : if Debug : print filename, "is already there" continue pathlist.append(filename) if Debug : print "appended as", filename numtiles = len(pathlist) err_label.set_text("Zoom level %d: %d tiles" % (zoom, numtiles)) flush_events() num_downloaded = 0 for filename in pathlist : if self.cancelled : dialog.destroy() return True url = self.collection.url_from_path(filename, zoom) # XXX Parallelize this! if Debug : print "Downloading", url, "to", filename thedir = os.path.dirname(filename) if not os.access(thedir, os.W_OK) : os.makedirs(thedir) #err_label.set_text("%d %%: %d of %d" % \ # (int(num_downloaded*100 / numtiles), # num_downloaded, numtiles)) if Debug : print "%d %%: %d of %d" % \ (int(num_downloaded*100 / numtiles), num_downloaded, numtiles) progress_label.set_text("%d: %s" % (num_downloaded, url)) flush_events() urllib.urlretrieve(url, filename) num_downloaded += 1 # XXX should show progress more graphically. dialog.destroy() return True # # Drawing-related routines: # def get_size(self) : """Return the width and height of the canvas.""" return self.drawing_area.window.get_size() def set_bg_color(self) : """Change to the normal background color (usually black).""" #self.xgc.set_rgb_fg_color(self.bg_color) self.xgc.foreground = self.xgc.background def set_grid_color(self) : """Change to the color used to show the grid (for debugging).""" self.xgc.set_rgb_fg_color(self.grid_color) def set_track_color(self) : """Change to the color used for tracks. May set line thickness too.""" self.xgc.set_rgb_fg_color(self.track_color) def set_waypoint_color(self) : """Change to the color used for tracks. May set line thickness too.""" self.xgc.set_rgb_fg_color(self.waypoint_color) def draw_pixbuf(self, pixbuf, x_off, y_off, x, y, w, h) : """Draw the pixbuf at the given position and size, starting at the specified offset.""" self.drawing_area.window.draw_pixbuf(self.xgc, pixbuf, x_off, y_off, x, y, w, h) def draw_rectangle(self, fill, x, y, w, h) : """Draw a rectangle.""" self.drawing_area.window.draw_rectangle(self.xgc, fill, x, y, w, h) def draw_line(self, x, y, x2, y2) : """Draw a line.""" self.drawing_area.window.draw_line(self.xgc, x, y, x2, y2) def draw_string(self, x, y, str) : """Draw a string.""" import pango layout = self.drawing_area.create_pango_layout (str) fontdesc = pango.FontDescription("Sans Bold 12") layout.set_font_description (fontdesc) self.drawing_area.window.draw_layout(self.xgc, x, y, layout) # Save the current map as something which could be gimped or printed. # XXX THIS IS BROKEN, code assumes start_lon/start_lat but has center_. def save_as(self) : """Save a static map. Somewhat BROKEN, needs rewriting.""" file_list = "" win_width, win_height = self.get_size() # Calculate dAngle in decimal degrees dAngle = self.collection.img_width / self.collection.xscale # Calculate number of charts based on window size, and round up # so the saved map shows at least as much as the window does. #win_width, win_height = drawing_area.window.get_size() num_lon = int (.8 + float(win_width) / self.collection.img_width) num_lat = int (.8 + float(win_height) / self.collection.img_height) ny = 0 curlat = self.center_lat + dAngle*num_lat * .25 while ny < num_lat : curlon = self.center_lon - dAngle*num_lon * .25 nx = 0 while nx < num_lon : file_list += " " + \ self.collection.coords_to_filename(curlon, curlat) curlon += dAngle nx += 1 curlat -= dAngle ny += 1 outfile = self.map_save_dir + "topo" + "_" + \ str(self.center_lon) + "_" + str(self.center_lat) + ".gif" cmdstr = "montage -geometry 262x328 -tile " + \ str(nx) + "x" + str(ny) + " " + \ file_list + " " + outfile #print "Running:", cmdstr os.system(cmdstr) if (os.access(outfile, os.R_OK)) : print "Saved:", outfile def expose_event(self, widget, event) : """Handle exposes on the canvas.""" #print "Expose:", event.type, "for object", self #print "area:", event.area.x, event.area.y, \ # event.area.width, event.area.height if self.xgc == 0 : self.xgc = self.drawing_area.window.new_gc() #self.xgc.set_foreground(white) #x, y, w, h = event.area self.draw_map() return True def key_quit(self, *args) : """Callback when the user types q.""" self.graceful_exit() def key_press_event(self, widget, event) : """Handle any key press.""" if event.string == "q" : self.graceful_exit() elif event.string == "+" or event.string == "=" : self.collection.zoom(1) elif event.string == "-" : self.collection.zoom(-1) elif event.keyval == gtk.keysyms.Left : self.center_lon -= \ float(self.collection.img_width) / self.collection.xscale elif event.keyval == gtk.keysyms.Right : self.center_lon += \ float(self.collection.img_width) / self.collection.xscale elif event.keyval == gtk.keysyms.Up : self.center_lat += \ float(self.collection.img_height) / self.collection.yscale elif event.keyval == gtk.keysyms.Down : self.center_lat -= \ float(self.collection.img_height) / self.collection.yscale elif event.keyval == gtk.keysyms.space : self.set_center_to_pin() elif event.keyval == gtk.keysyms.l and \ event.state == gtk.gdk.CONTROL_MASK : pass # Just fall through to draw_map() elif event.string == "m" : if PyTopo.selection_window(p, self) : self.set_center_to_pin() pass elif event.string == "s" : self.save_as() return True else : #print "Unknown key,", event.keyval return False self.draw_map() return True def xy2coords(self, x, y, win_width, win_height) : """Convert pixels to longitude/latitude.""" # collection.x_scale is in pixels per degree. return (self.center_lon - \ float(win_width/2 - x) / self.collection.xscale, self.center_lat + \ float(win_height/2 - y) / self.collection.yscale) def coords2xy(self, lon, lat, win_width, win_height) : """Convert lon/lat to pixels.""" return (int((lon - self.center_lon) * self.collection.xscale + win_width/2), int((self.center_lat - lat) * self.collection.yscale + win_height/2) ) def drag_event(self, widget, event) : """Move the map as the user drags.""" if self.press_timeout : gobject.source_remove(self.press_timeout) self.press_timeout = None # On a tablet (at least the ExoPC), almost every click registers # as a drag. So if a drag starts in the zoom control area, # it was probably really meant to be a single click. if self.was_click_in_zoom(event.x, event.y) : return False # The GTK documentation @ 24.2.1 # http://www.pygtk.org/pygtk2tutorial/sec-EventHandling.html # says the first event is a real motion event and subsequent # ones are hints; but in practice, nothing but hints are # ever sent. if event.is_hint: x, y, state = event.window.get_pointer() else: x = event.x y = event.y state = event.state if not state & gtk.gdk.BUTTON1_MASK : return False if not self.is_dragging : self.x_start_drag = x self.y_start_drag = y self.is_dragging = True self.move_to(x, y, widget) return True def move_to(self, x, y, widget) : if widget.drag_check_threshold(self.x_start_drag, self.y_start_drag, x, y) : dx = x - self.x_start_drag dy = y - self.y_start_drag self.center_lon -= dx / self.collection.xscale self.center_lat += dy / self.collection.yscale self.draw_map() # Reset the drag coordinates now that we're there self.x_start_drag = x self.y_start_drag = y def mousepress(self, widget, event) : """Handle mouse button presses""" if self.press_timeout : gobject.source_remove(self.press_timeout) # Was it a right click? if event.button == 3 : x, y, state = self.drawing_area.window.get_pointer() win_width, win_height = self.drawing_area.window.get_size() self.cur_lon, self.cur_lat = self.xy2coords(x, y, win_width, win_height) self.context_menu(event) return # If it wasn't a double click, set a timeout for LongPress if event.type != gtk.gdk._2BUTTON_PRESS : self.press_timeout = gobject.timeout_add(1000, self.longpress) return False # Zoom in if we get a double-click. win_width, win_height = self.drawing_area.window.get_size() cur_long, cur_lat = self.xy2coords(event.x, event.y, win_width, win_height) self.center_lon = cur_long self.center_lat = cur_lat self.collection.zoom(1) self.draw_map() return True def longpress(self) : if self.press_timeout : gobject.source_remove(self.press_timeout) x, y, state = self.drawing_area.window.get_pointer() win_width, win_height = self.drawing_area.window.get_size() self.cur_lon, self.cur_lat = self.xy2coords(x, y, win_width, win_height) self.context_menu(None) return True def mouserelease(self, widget, event) : """Handle button releases.""" if self.press_timeout : gobject.source_remove(self.press_timeout) self.press_timeout = None #return False if self.is_dragging: self.is_dragging = False x, y, state = event.window.get_pointer() self.move_to(x, y, widget) self.draw_zoom_control() return True if event.button == 1 : global Debug zoom = self.was_click_in_zoom(event.x, event.y) if zoom : self.collection.zoom(zoom) self.draw_map() return True win_width, win_height = self.drawing_area.window.get_size() cur_long, cur_lat = self.xy2coords(event.x, event.y, win_width, win_height) if Debug : print "Click:", \ MapUtils.DecDegToDegMinStr(cur_long), \ MapUtils.DecDegToDegMinStr(cur_lat) # Find angle and distance since last click. # You would think that we could just use the long and lat # differences, but it doesn't work that way, because maps # aren't square: away from the equator, longitude isn't # a great circle so a degree in longitude doesn't equal # a degree in latitude. (Even at the equator that's true, # due to the earth's oblateness, but that is probably small # enough to ignore.) # So use the current image aspect ratio to convert from angles # to pixels, then we'll convert from there to miles/km. # XXX This is probably still fairly inaccurate, check! if self.click_last_long != 0 and self.click_last_lat != 0 : xdiff = (cur_long - self.click_last_long) ydiff = (cur_lat - self.click_last_lat) dist = math.sqrt(xdiff*xdiff + ydiff*ydiff) # dist is now in degrees. # Convert to miles using latitude and radius of the earth. dist = dist *2. * math.pi / 360.0 * 7926 \ * math.cos(cur_lat*math.pi/180) if Debug and self.use_metric : print "Distance:", round(dist*1600,2), "meters,", \ round(dist*1.6,2), "km" elif Debug : print "Distance:", round(dist*5280,2), "feet,", \ round(dist,2), "miles" angle = int(math.atan2(-ydiff, -xdiff) * 180 / math.pi) angle = MapUtils.angle_to_bearing(angle) if Debug : print "Bearing:", angle, "=", \ MapUtils.angle_to_quadrant(angle) self.click_last_long = cur_long self.click_last_lat = cur_lat return True @staticmethod def nop(*args) : "Do nothing." return True def graceful_exit(self, extra=None) : """Clean up the window and exit. The "extra" argument is so it can be calld from GTK callbacks. """ self.controller.save_sites() # Tell PyTopo to save any new sites/tracks gtk.main_quit() # The python profilers don't work if you call sys.exit here. # # End of MapWindow class # class PyTopo : """A class to hold the mechanics of running the PyTopo program, plus some important variables including Collections and KnownSites. """ def __init__(self) : self.collections = [] self.KnownSites = [] self.KnownTracks = [] self.init_width = 800 self.init_height = 600 self.default_collection = None self.needs_saving = False self.config_dir = os.path.expanduser("~/.config/pytopo",) self.savefilename = os.path.join(self.config_dir, "saved.sites") @staticmethod def Usage() : global VersionString print VersionString print """ Usage: pytopo [-t trackfile] site_name pytopo [-t trackfile] start_lat start_long collection pytopo -p : list known sites pytopo -h : print this message Use degrees.decimal_minutes format for coordinates. Set up site names in ~/.config/pytopo.sites, track logs in ~/Tracks. Track files may contain track points and/or waypoints; multiple track files are allowed. Move around using arrow keys. q quits. Click in the map to print the coordinates of the clicked location. 's' will attempt to save the current map as a GIF file in ~/Topo/.""" sys.exit(1) @staticmethod def error_out(errstr) : """Print an error and exit cleanly.""" print "===============" print errstr print "===============\n" PyTopo.Usage() def append_known_site(self, site) : self.KnownSites.append(site) self.needs_saving = True def save_sites(self) : """Write any new KnownSites to file. Should only be called from graceful exit. """ if not self.needs_saving : return try : savefile = open(self.savefilename, "w") except : print "Couldn't open save file", self.savefilename return for site in self.KnownSites[self.first_saved_site:] : # All sites have a string, two floats and another string; # some sites may have additional ints after that. print >>savefile, '[ "%s", %f, %f, "%s"' % \ (site[0], site[1], site[2], site[3]), if len(site) > 4 : print >>savefile, ', ' + ', '.join(map(str, site[4:])), print >>savefile, "]" savefile.close() def print_sites(self) : """Print the list of known sites.""" for site in self.KnownSites : print site[0], "(", os.path.basename(site[3]), ")" #print site[0], site[1] sys.exit(0) def find_collection(self, collname) : """Find a collection with the given name.""" global Debug #print "Looking for a collection named", collname # Make sure collname is a MapCollection we know about: collection = None for coll in self.collections : if collname == coll.name : if not coll.exists() : PyTopo.error_out("Can't access location " + coll.location + " for collection " + collname) collection = coll if (Debug) : print "Found the collection", collection.name return collection return collection def selection_window(self, mapwin) : dialog = gtk.Dialog("Choose a point", None, 0, (gtk.STOCK_CLOSE, gtk.RESPONSE_NONE, gtk.STOCK_OK, gtk.RESPONSE_OK)) #dialog.connect('destroy', lambda win: gtk.main_quit()) dialog.set_size_request(400, 300) sw = gtk.ScrolledWindow() sw.set_shadow_type(gtk.SHADOW_ETCHED_IN) sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) # List store will hold name, collection-name and site object store = gtk.ListStore(str, str, object) # Create the list for site in self.KnownSites : store.append([site[0], site[3], site]) # http://pygtk.org/pygtk2tutorial/ch-TreeViewWidget.html # Make a treeview from the list: treeview = gtk.TreeView(store) renderer = gtk.CellRendererText() column = gtk.TreeViewColumn("Location", renderer, text=0) #column.pack_start(renderer, True) #column.set_resizable(True) treeview.append_column(column) renderer = gtk.CellRendererText() column = gtk.TreeViewColumn("Collection", renderer, text=1) #column.pack_start(renderer, False) treeview.append_column(column) #store.set_sort_column_id(0, gtk.SORT_ASCENDING) sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) sw.add(treeview) dialog.vbox.pack_start(sw, expand=True) dialog.show_all() response = dialog.run() if response == gtk.RESPONSE_OK: selection = treeview.get_selection() model, iter = selection.get_selected() if iter : #locname = store.get_value(iter, 0) #collname = store.get_value(iter, 1) site = store.get_value(iter, 2) self.use_site(site, mapwin) dialog.destroy() return True else : dialog.destroy() return False def TrackSelect(self, mapwin) : dialog = gtk.Dialog("Tracks", None, 0, (gtk.STOCK_CLOSE, gtk.RESPONSE_NONE, gtk.STOCK_OK, gtk.RESPONSE_OK)) dialog.set_size_request(400, 300) sw = gtk.ScrolledWindow() sw.set_shadow_type(gtk.SHADOW_ETCHED_IN) sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) # List store will hold Track name and Track file path store = gtk.ListStore(str, str) # Create the list for track in self.KnownTracks : store.append([ track[0], track[1] ]) treeview = gtk.TreeView(store) renderer = gtk.CellRendererText() column = gtk.TreeViewColumn("Track name", renderer, text=0) treeview.append_column(column) sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) sw.add(treeview) dialog.vbox.pack_start(sw, expand=True) dialog.show_all() response = dialog.run() if response == gtk.RESPONSE_OK: selection = treeview.get_selection() model, iter = selection.get_selected() if iter : trackfile = store.get_value(iter, 1) mapwin.trackpoints = TrackPoints() mapwin.trackpoints.readTrackFile(trackfile) # XXX Might want to handle IOError in case file doesn't exist dialog.destroy() return True else : dialog.destroy() return False def location_select(self, mapwin) : dialog = gtk.Dialog("Locations", None, 0, (gtk.STOCK_REMOVE, gtk.RESPONSE_APPLY, gtk.STOCK_CLOSE, gtk.RESPONSE_NONE, gtk.STOCK_OK, gtk.RESPONSE_OK)) dialog.set_size_request(400, 300) sw = gtk.ScrolledWindow() sw.set_shadow_type(gtk.SHADOW_ETCHED_IN) sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) # List store will hold name, collection-name and site object store = gtk.ListStore(str, str, object) # Create the list for site in self.KnownSites : store.append([site[0], site[3], site]) # Make a treeview from the list: treeview = gtk.TreeView(store) renderer = gtk.CellRendererText() column = gtk.TreeViewColumn("Location", renderer, text=0) treeview.append_column(column) renderer = gtk.CellRendererText() column = gtk.TreeViewColumn("Collection", renderer, text=1) treeview.append_column(column) sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) sw.add(treeview) dialog.vbox.pack_start(sw, expand=True) dialog.show_all() response = dialog.run() while response == gtk.RESPONSE_APPLY : selection = treeview.get_selection() model, iter = selection.get_selected() if iter : site = store.get_value(iter, 2) self.KnownSites.remove(site) store.remove(iter) response = dialog.run() if response == gtk.RESPONSE_OK: selection = treeview.get_selection() model, iter = selection.get_selected() if iter : site = store.get_value(iter, 2) self.use_site(site, mapwin) dialog.destroy() return True else : dialog.destroy() return False def use_site(self, site, mapwin) : collection = self.find_collection(site[3]) if not collection : return False mapwin.collection = collection # site[1] and site[2] are the long and lat in deg.minutes #print site[0], site[1], site[2] mapwin.center_lon = MapUtils.DegMinToDecDeg(site[1]) mapwin.center_lat = MapUtils.DegMinToDecDeg(site[2]) mapwin.pin_lon = mapwin.center_lon mapwin.pin_lat = mapwin.center_lat #print "Center in decimal degrees:", centerLon, centerLat if (Debug) : print site[0] + ":", \ MapUtils.DecDegToDegMinStr(mapwin.center_lon), \ MapUtils.DecDegToDegMinStr(mapwin.center_lat) if len(site) > 4 and collection.zoom_to : collection.zoom_to(site[4]) mapwin.draw_map() return True def parse_args(self, mapwin, args) : """Parse runtime arguments.""" global VersionString global Debug # Variables we expect from .pytopo: do_collection = False arg0 = args[0] args = args[1:] while len(args) > 0 : if args[0][0] == '-' and not args[0][1].isdigit() : if args[0] == "-v" or args[0] == "--version" : print VersionString sys.exit(0) elif args[0] == "-h" or args[0] == "--help" : PyTopo.Usage() # Next clause is impossible because of the prev isdigit check: #if args[0] == "-15" : # series = 15 elif args[0] == "-p" : self.print_sites() elif args[0] == "-c" : # Specify a collection: if len(args) < 2 : print "-c must specify collection" PyTopo.Usage() mapwin.collection = self.find_collection(args[1]) if mapwin.collection == None : PyTopo.error_out("I can't find a map collection called " + args[1]) # Start initially at top left, but subsequent args # may change this: mapwin.center_lon, mapwin.center_lat = \ mapwin.collection.get_top_left() if (Debug) : print "Collection", mapwin.collection.name, print "Starting at", \ MapUtils.DecDegToDegMinStr(mapwin.center_lon), \ ", ", MapUtils.DecDegToDegMinStr(mapwin.center_lat) args = args[1:] elif args[0] == "-d" : Debug = True elif args[0] == "-t" and len(args) > 1: if mapwin.trackpoints == None : mapwin.trackpoints = TrackPoints() # Is it a known track? for tr in self.KnownTracks : if args[1] == tr[0] : if Debug : print "Reading known track", tr[0], tr[1] args[1] = tr[1] break try : mapwin.trackpoints.readTrackFile(args[1]) except IOError : print "Can't read track file", args[1] args = args[1:] else : PyTopo.error_out("Unknown flag " + args[0]) # Done processing this flag args = args[1:] continue # args[0] doesn't start with '-'. Is it a gpx file? if len(args[0]) > 4 and args[0][-4:] == '.gpx' : if mapwin.trackpoints == None : mapwin.trackpoints = TrackPoints() try : mapwin.trackpoints.readTrackFile(args[0]) except IOError : print "Can't read track file", args[0] args = args[1:] continue # Try to match a known site: for site in self.KnownSites : if args[0] == site[0] : if not self.use_site(site, mapwin) : continue break if mapwin.collection and mapwin.center_lon and mapwin.center_lat : args = args[1:] continue # Doesn't match a known site. Maybe the args are coordinates? try : if len(args) >= 2 and \ len(args[0] > 1 and args[0][1].isdigit) and \ len(args[1] > 1 and args[1][1].isdigit) : mapwin.center_lon = MapUtils.DegMinToDecDeg(float(args[0])) mapwin.center_lat = MapUtils.DegMinToDecDeg(float(args[2])) mapwin.collection = self.find_collection(args[3]) args = args[2:] continue except ValueError, e : print "Couldn't parse coordinates" PyTopo.Usage() # If we get here, we still have an argument but it doesn't # match anything we know: flag, collection, site or coordinate. print "Remaining args:", args PyTopo.Usage() # Now we've parsed all the arguments. # If we didn't get a collection, use the default, if any: if not mapwin.collection and self.default_collection : mapwin.collection = self.find_collection(self.default_collection) # If we have a collection and a track but no center point, # center it on the trackpoints: if mapwin.trackpoints != None and mapwin.collection != None \ and not (mapwin.center_lat and mapwin.center_lon) : minlon, minlat, maxlon, maxlat = mapwin.trackpoints.get_bounds() mapwin.center_lon = (maxlon + minlon) / 2 mapwin.center_lat = (maxlat + minlat) / 2 # XXX Do something useful with min/max # XXX in terms of setting the map's zoom level # By now, we hope we have the mapwin positioned with a collection # and starting coordinates: if mapwin.collection and mapwin.center_lon and mapwin.center_lat : return # Didn't match any known run mode: # start in GUI mode choosing a location: if not self.selection_window(mapwin) : self.Usage() # Check for a user config file named .pytopo # in either $HOME/.config/pytopo or $HOME. # # Format of the user config file: # It is a python script, which can include arbitrary python code, # but the most useful will be KnownSites definitions, # with coordinates specified in degrees.decimal_minutes, # like this: # MapHome = "/cdrom" # KnownSites = [ # # Death Valley # [ "zabriskie", 116.475, 36.245, "dv_data" ], # [ "badwater", 116.445, 36.125, "dv_data" ], # # East Mojave # [ "zzyzyx", 116.05, 35.08, "emj_data" ] # ] def exec_config_file(self) : """Load the user's .pytopo config file, found either in $HOME/.config/pytopo/ or $HOME/pytopo. """ userfile = os.path.join(self.config_dir, "pytopo.sites") if not os.access(userfile, os.R_OK) : if Debug : print "Couldn't open", userfile userfile = os.path.expanduser("~/.pytopo") if not os.access(userfile, os.R_OK) : if Debug : print "Couldn't open", userfile, "either" userfile = os.path.join(self.config_dir, "pytopo", ".pytopo") if not os.access(userfile, os.R_OK) : if Debug : print "Couldn't open", userfile, "either" userfile = self.create_initial_config() if userfile == None : print "Couldn't create a new pytopo config file" return else : print "Suggestion: rename", userfile, \ "to ~/.config/pytopo/pytopo.sites" print userfile, "may eventually be deprecated" if Debug : print "Found", userfile # Now we'd better have a userfile # Now that we're in a function inside the PyTopo class, we can't # just execfile() and set a variable inside that file -- the file # can only change it inside a "locals" dictionary. # So set up the dictionary: locals = { 'Collections' : [ 3, 4 ], 'KnownSites' : [], 'init_width' : self.init_width, 'init_height' : self.init_height } execfile(userfile, globals(), locals) # Then extract the changed values back out: self.collections = locals['Collections'] self.KnownSites = locals['KnownSites'] self.init_width = locals["init_width"] self.init_height = locals["init_height"] self.default_collection = locals["defaultCollection"] def read_saved_sites(self) : """Read previously saved (favorite) sites.""" global Debug try : savefile = open(self.savefilename, "r") except : return # A line typically looks like this: # [ "san-francisco", -121.750000, 37.400000, "openstreetmap" ] # or, with an extra optional zoom level, # [ "san-francisco", -121.750000, 37.400000, "openstreetmap", 11 ] r = re.compile('\["([^"]*)",([-0-9\.]*),([-0-9\.]*),"([^"]*)",?([0-9]+)?\]') for line in savefile : # First remove all whitespace: line = re.sub(r'\s', '', line) match = r.search(line) if match : matches = match.groups() # Convert from strings to numbers site = [ matches[0], float(matches[1]), float(matches[2]), matches[3] ] if len(matches) == 5 and matches[4] != None : site.append(int(matches[4])) if Debug : print "Adding", site[0], "to KnownSites" self.KnownSites.append( site ) savefile.close() def read_tracks(self) : trackdir = os.path.expanduser('~/Tracks') if os.path.isdir(trackdir) : for file in glob.glob( os.path.join(trackdir, '*.gpx') ): head, gpx = os.path.split(file) filename = gpx.partition('.')[0] self.KnownTracks.append( [filename, file] ) def create_initial_config(self) : """Make an initial configuration file. If the user has a ~/.config, make ~/.config/pytopo/pytopo.sites else fall back on ~/.pytopo. """ confdir = os.path.expanduser("~/.config/pytopo") try : if not os.access(confdir, os.W_OK) : os.mkdir(confdir) userfile = os.path.join(confdir, "pytopo.sites") fp = open(userfile, 'w') except : fp = None if not fp : userfile = os.path.expanduser("~/.pytopo") try : fp = open(userfile, 'w') except : return None # Now we have fp open. Write a very basic config to it. print >>fp, """# Pytopo site file # Map collections Collections = [ OSMMapCollection( "openstreetmap", "~/Maps/openstreetmap", ".png", 256, 256, 10, "http://a.tile.openstreetmap.org" ), ] defaultCollection = "openstreetmap" KnownSites = [ # Some base values to get new users started. # Note that these coordinates are a bit northwest of the city centers; # they're the coordinates of the map top left, not center. [ "san-francisco", -121.75, 37.4, "openstreetmap" ], [ "new-york", -73.466, 40.392, "openstreetmap" ], [ "london", 0.1, 51.266, "openstreetmap" ], [ "sydney", 151.0, -33.5, "openstreetmap" ], ] """ fp.close() print """Welcome to Pytopo! Created an initial site file in %s You can add new sites and collections there; see the instructions at http://shallowsky.com/software/topo/ """ % (userfile) return userfile def main(self, pytopo_args) : """main execution routine for pytopo.""" self.exec_config_file() # Remember how many known sites we got from the config file; # the rest are read in from saved sites and may need to be re-saved. self.first_saved_site = len(self.KnownSites) self.read_saved_sites() self.read_tracks() gc.enable() mapwin = MapWindow(self) self.parse_args(mapwin, pytopo_args) # For cProfile testing, run with a dummy collection (no data needed): #mapwin.collection = MapCollection("dummy", "/tmp") #print cProfile.__file__ #cProfile.run('mapwin.show_window()', 'cprof.out') # http://docs.python.org/library/profile.html # To analyze cprof.out output, do this: # import pstats # p = pstats.Stats('fooprof') # p.sort_stats('time').print_stats(20) mapwin.show_window(self.init_width, self.init_height) ##################################################################### # Some global routines that really should get moved into a class: # # A relatively clean way of downloading files in a separate thread. # http://code.activestate.com/recipes/577129-run-asynchronous-tasks-using-coroutines/ # def start_job(generator): """Start a job (a coroutine that yield generic tasks).""" def _task_return(result): """Function to be sent to tasks to be used as task_return.""" def _advance_generator(): try: new_task = generator.send(result) except StopIteration: return new_task(_task_return) # make sure the generator is advanced in the main thread gobject.idle_add(_advance_generator) _task_return(None) return generator import threading gobject.threads_init() def threaded_task(function, *args, **kwargs): """Run function(*args, **kwargs) inside a thread and return the result.""" def _task(task_return): def _thread(): result = function(*args, **kwargs) gobject.idle_add(task_return, result) thread = threading.Thread(target=_thread, args=()) thread.setDaemon(True) thread.start() return _task def download_job(url, localpath, callback): global Debug def download(url, localpath, callback): if Debug : print "Downloading", url try : urllib.urlretrieve(url, localpath) return localpath except IOError, e : return None path = yield threaded_task(download, url, localpath, callback) if Debug : print >>sys.stderr, "[downloaded %s]" % (localpath) callback(path) # Conditional main: if __name__ == "__main__" : p = PyTopo() p.main(sys.argv)