import sys import tkinter as tk import warnings from inspect import stack from os.path import abspath, dirname, pardir, join from PIL import ImageTk from tkinter import ttk, filedialog try: import pyproj import shapefile import shapely.geometry except ImportError: from tkinter import messagebox tk.messagebox.showinfo('Some libraries are missing', 'Pyproj, Shapefile and Shapely are required (see README)') sys.exit(1) try: import xlrd except ImportError: warnings.warn('Excel libraries missing: excel import/export disabled') # prevent python from writing *.pyc files / __pycache__ folders sys.dont_write_bytecode = True path_app = dirname(abspath(stack()[0][1])) if path_app not in sys.path: sys.path.append(path_app) class Controller(tk.Tk): def __init__(self, path_app): super().__init__() self.title('Extended PyGISS') path_icon = abspath(join(path_app, pardir, 'images')) # generate the PSF tk images img_psf = ImageTk.Image.open(join(path_icon, 'node.png')) selected_img_psf = ImageTk.Image.open(join(path_icon, 'selected_node.png')) self.psf_button_image = ImageTk.PhotoImage(img_psf.resize((100, 100))) self.node_image = ImageTk.PhotoImage(img_psf.resize((40, 40))) self.selected_node_image = ImageTk.PhotoImage(selected_img_psf.resize((40, 40))) for widget in ( 'Button', 'Label', 'Labelframe', 'Labelframe.Label', ): ttk.Style().configure('T' + widget, background='#A1DBCD') self.map = Map(self) self.map.pack(side='right', fill='both', expand=1) self.menu = Menu(self) self.menu.pack(side='right', fill='both', expand=1) menu = tk.Menu(self) menu.add_command(label="Import shapefile", command=self.map.import_map) self.config(menu=menu) # if motion is called, the left-click button was released and we # can stop the drag and drop process self.bind_all('<Motion>', self.stop_drag_and_drop) self.drag_and_drop = False self.image = None self.bind_all('<B1-Motion>', lambda _:_) def stop_drag_and_drop(self, event): self.drag_and_drop = False def start_drag_and_drop(self, event): self.drag_and_drop = True class Menu(tk.Frame): def __init__(self, controller): super().__init__(controller) self.configure(background='#A1DBCD') lf_creation = ttk.Labelframe( self, text = 'Object management', padding = (6, 6, 12, 12) ) lf_creation.grid(row=0, column=0, padx=5, pady=5) psf_object_label = tk.Label( self, image = controller.psf_button_image, relief = 'flat', bg = '#A1DBCD' ) psf_object_label.bind('<Button-1>', controller.start_drag_and_drop) psf_object_label.grid(row=0, column=0, pady=10, padx=55, in_=lf_creation) import_nodes_button = ttk.Button( self, text='Import nodes', command=controller.map.import_nodes, width=20 ) import_nodes_button.grid(row=2, column=0, pady=5, in_=lf_creation) lf_projection = ttk.Labelframe( self, text = 'Projection management', padding = (6, 6, 12, 12) ) lf_projection.grid(row=1, column=0, padx=5, pady=5) self.projection_list = ttk.Combobox(self, width=18) self.projection_list['values'] = tuple(controller.map.projections) self.projection_list.current(0) self.projection_list.grid(row=0, column=0, in_=lf_projection) change_projection_button = ttk.Button( self, text='Change projection', command=controller.map.change_projection, width=20 ) change_projection_button.grid(row=1, column=0, pady=5, in_=lf_projection) lf_map_management = ttk.Labelframe( self, text = 'Map management', padding = (6, 6, 12, 12) ) lf_map_management.grid(row=2, column=0, padx=5, pady=5) delete_map = ttk.Button( self, text='Delete map', command=controller.map.delete_map, width=20 ) delete_map.grid(row=0, column=0, pady=5, in_=lf_map_management) delete_selection = ttk.Button( self, text='Delete selected nodes', command=controller.map.delete_selected_nodes, width=20 ) delete_selection.grid(row=1, column=0, pady=5, in_=lf_map_management) class PSF_Object(): type = 'node' def __init__(self, id, label_id, x, y): self.id = id self.label_id = label_id self.x, self.y = x, y self.longitude, self.latitude = 0, 0 class Map(tk.Canvas): projections = { 'Mercator': pyproj.Proj(init="epsg:3395"), 'Azimuthal orthographic': pyproj.Proj('+proj=ortho +lon_0=28 +lat_0=47') } size = 10 def __init__(self, controller): super().__init__(controller, bg='white', width=1300, height=800) self.controller = controller self.node_id_to_node = {} self.drag_item = None self.start_position = [None]*2 self.start_pos_main_node = [None]*2 self.dict_start_position = {} self.selected_nodes = set() self.filepath = None self.proj = 'Mercator' self.ratio, self.offset = 1, (0, 0) self.bind('<MouseWheel>', self.zoomer) self.bind('<Button-4>', lambda e: self.zoomer(e, 1.3)) self.bind('<Button-5>', lambda e: self.zoomer(e, 0.7)) self.bind('<ButtonPress-3>', lambda e: self.scan_mark(e.x, e.y)) self.bind('<B3-Motion>', lambda e: self.scan_dragto(e.x, e.y, gain=1)) self.bind('<Enter>', self.drag_and_drop, add='+') self.bind('<ButtonPress-1>', self.start_point_select_objects, add='+') self.bind('<B1-Motion>', self.rectangle_drawing) self.bind('<ButtonRelease-1>', self.end_point_select_nodes, add='+') self.tag_bind('node', '<Button-1>', self.find_closest_node) self.tag_bind('node', '<B1-Motion>', self.node_motion) def update_coordinates(function): def wrapper(self, event, *others): event.x, event.y = self.canvasx(event.x), self.canvasy(event.y) function(self, event, *others) return wrapper def to_canvas_coordinates(self, longitude, latitude): px, py = self.projections[self.proj](longitude, latitude) return px*self.ratio + self.offset[0], -py*self.ratio + self.offset[1] def to_geographical_coordinates(self, x, y): px, py = (x - self.offset[0])/self.ratio, (self.offset[1] - y)/self.ratio return self.projections[self.proj](px, py, inverse=True) def import_map(self): filepath = tk.filedialog.askopenfilenames(title='Import shapefile') if not filepath: return else: self.filepath ,= filepath self.draw_map() def draw_map(self): if not self.filepath: return self.delete('land', 'water') self.ratio, self.offset = 1, (0, 0) self.draw_water() sf = shapefile.Reader(self.filepath) polygons = sf.shapes() for polygon in polygons: polygon = shapely.geometry.shape(polygon) if polygon.geom_type == 'Polygon': polygon = [polygon] for land in polygon: self.create_polygon( sum((self.to_canvas_coordinates(*c) for c in land.exterior.coords), ()), fill = 'green3', outline = 'black', tags = ('land',) ) self.redraw_nodes() def delete_map(self): self.delete('land', 'water') self.filepath = None def delete_selected_nodes(self): for node in self.selected_nodes: self.node_id_to_node.pop(node.id) self.delete(node.id, node.label_id) self.selected_nodes.clear() def draw_water(self): if self.proj == 'Mercator': x0, y0 = self.to_canvas_coordinates(-180, 84) x1, y1 = self.to_canvas_coordinates(180, -84) self.water_id = self.create_rectangle(x1, y1, x0, y0, outline='black', fill='deep sky blue', tags=('water',)) else: cx, cy = self.to_canvas_coordinates(28, 47) R = 6378000*self.ratio self.water_id = self.create_oval(cx - R, cy - R, cx + R, cy + R, outline='black', fill='deep sky blue', tags=('water',)) def change_projection(self): self.proj = self.controller.menu.projection_list.get() self.draw_map() def redraw_nodes(self): for node_id, node in self.node_id_to_node.items(): cx, cy = self.to_canvas_coordinates(node.longitude, node.latitude) node.x, node.y = cx, cy self.coords(node_id, cx, cy) self.update_node_label(node) self.tag_raise(node_id) self.tag_raise(node.label_id) @update_coordinates def zoomer(self, event, factor=None): if not factor: factor = 1.3 if event.delta > 0 else 0.7 self.scale('all', event.x, event.y, factor, factor) self.configure(scrollregion=self.bbox('all')) self.ratio *= float(factor) self.offset = (self.offset[0]*factor + event.x*(1 - factor), self.offset[1]*factor + event.y*(1 - factor)) # we update all node's coordinates for node_id, node in self.node_id_to_node.items(): node.x, node.y = self.coords(node_id) self.update_node_label(node) def update_node_label(self, node): node.longitude, node.latitude = self.to_geographical_coordinates( node.x, node.y) label = '({:.5f}, {:.5f})'.format(node.longitude, node.latitude) self.coords(node.label_id, node.x - 5, node.y + 30) self.itemconfig(node.label_id, text=label) @update_coordinates def drag_and_drop(self, event): if controller.drag_and_drop: self.create_object(event.x, event.y) controller.drag_and_drop = False def create_object(self, x, y): # create the node's image id = self.create_image(x, y,image = controller.node_image, tags = ('node',)) # create the node's label label_id = self.create_text(x - 5, y + 30) # create the node object node = PSF_Object(id, label_id, x, y) # update the value of its label self.update_node_label(node) # store the node in the (node ID -> node) dictionnary self.node_id_to_node[id] = node @update_coordinates def find_closest_node(self, event): self.dict_start_position.clear() self.drag_item = self.find_closest(event.x, event.y)[0] main_node_selected = self.node_id_to_node[self.drag_item] self.start_pos_main_node = event.x, event.y if main_node_selected in self.selected_nodes: for sn in self.selected_nodes: self.dict_start_position[sn] = [sn.x, sn.y] else: self.unselect_all() self.dict_start_position[main_node_selected] = self.start_pos_main_node self.select_objects(main_node_selected) def select_objects(self, *objects): for obj in objects: self.selected_nodes.add(obj) self.itemconfig( obj.id, image = self.controller.selected_node_image ) def unselect_objects(self, *objects): for obj in objects: self.selected_nodes.discard(obj) self.itemconfig( obj.id, image = self.controller.node_image ) def unselect_all(self): self.unselect_objects(*self.selected_nodes) @update_coordinates def start_point_select_objects(self, event): # create the temporary line, only if there is nothing below # this is to avoid drawing a rectangle when moving a node below = self.find_overlapping(event.x-1, event.y-1, event.x+1, event.y+1) tags_below = ''.join(''.join(self.itemcget(id, 'tags')) for id in below) # if no object is below the selection process can start if 'node' not in tags_below: self.unselect_all() self.start_position = event.x, event.y self.temp_rectangle = self.create_rectangle( event.x, event.y, event.x, event.y ) self.tag_raise(self.temp_rectangle) @update_coordinates def rectangle_drawing(self, event): # draw the line only if they were created in the first place if self.start_position != [None]*2: # update the position of the temporary lines x0, y0 = self.start_position self.coords(self.temp_rectangle, x0, y0, event.x, event.y) @update_coordinates def end_point_select_nodes(self, event): if self.start_position != [None]*2: # delete the temporary lines self.delete(self.temp_rectangle) # select all nodes enclosed in the rectangle start_x, start_y = self.start_position for obj in self.find_enclosed(start_x, start_y, event.x, event.y): if obj in self.node_id_to_node: enclosed_obj = self.node_id_to_node[obj] self.select_objects(enclosed_obj) self.start_position = [None]*2 @update_coordinates def node_motion(self, event): node = self.node_id_to_node[self.drag_item] for selected_node in self.selected_nodes: # the main node initial position, the main node current position, # and the other node initial position form a rectangle. # we find the position of the fourth vertix. x0, y0 = self.start_pos_main_node x1, y1 = self.dict_start_position[selected_node] selected_node.x = x1 + (event.x - x0) selected_node.y = y1 + (event.y - y0) # move the node itself self.coords(selected_node.id, selected_node.x, selected_node.y) # update the label self.update_node_label(selected_node) def import_nodes(self): filepath = filedialog.askopenfilenames(filetypes = (('xls files','*.xls'),)) if not filepath: return else: filepath ,= filepath book = xlrd.open_workbook(filepath) try: sheet = book.sheet_by_index(0) # if the sheet cannot be found, there's nothing to import except xlrd.biffh.XLRDError: warnings.warn('the excel file is empty: import failed') for row_index in range(1, sheet.nrows): x, y = self.to_canvas_coordinates(*sheet.row_values(row_index)) self.create_object(x, y) if str.__eq__(__name__, '__main__'): controller = Controller(path_app) controller.mainloop()