import logging import tkinter as tk from PIL import Image, ImageTk from core.api.grpc import core_pb2 from core.gui import nodeutils from core.gui.dialogs.shapemod import ShapeDialog from core.gui.graph import tags from core.gui.graph.edges import CanvasEdge, CanvasWirelessEdge from core.gui.graph.enums import GraphMode, ScaleOption from core.gui.graph.node import CanvasNode from core.gui.graph.shape import Shape from core.gui.graph.shapeutils import ShapeType, is_draw_shape, is_marker from core.gui.images import Images from core.gui.nodeutils import NodeUtils ZOOM_IN = 1.1 ZOOM_OUT = 0.9 class CanvasGraph(tk.Canvas): def __init__(self, master, core, width, height): super().__init__(master, highlightthickness=0, background="#cccccc") self.app = master self.core = core self.mode = GraphMode.SELECT self.annotation_type = None self.selection = {} self.select_box = None self.selected = None self.node_draw = None self.context = None self.nodes = {} self.edges = {} self.shapes = {} self.wireless_edges = {} self.drawing_edge = None self.grid = None self.shape_drawing = False self.default_dimensions = (width, height) self.current_dimensions = self.default_dimensions self.ratio = 1.0 self.offset = (0, 0) self.cursor = (0, 0) self.marker_tool = None self.to_copy = [] # background related self.wallpaper_id = None self.wallpaper = None self.wallpaper_drawn = None self.wallpaper_file = "" self.scale_option = tk.IntVar(value=1) self.show_grid = tk.BooleanVar(value=True) self.adjust_to_dim = tk.BooleanVar(value=False) # throughput related self.throughput_threshold = 250.0 self.throughput_width = 10 self.throughput_color = "#FF0000" # bindings self.setup_bindings() # draw base canvas self.draw_canvas() self.draw_grid() def draw_canvas(self, dimensions=None): if self.grid is not None: self.delete(self.grid) if not dimensions: dimensions = self.default_dimensions self.current_dimensions = dimensions self.grid = self.create_rectangle( 0, 0, *dimensions, outline="#000000", fill="#ffffff", width=1, tags="rectangle", ) self.configure(scrollregion=self.bbox(tk.ALL)) def reset_and_redraw(self, session): """ Reset the private variables CanvasGraph object, redraw nodes given the new grpc client. :param core.api.grpc.core_pb2.Session session: session to draw :return: nothing """ # hide context self.hide_context() # delete any existing drawn items for tag in tags.COMPONENT_TAGS: self.delete(tag) # set the private variables to default value self.mode = GraphMode.SELECT self.annotation_type = None self.node_draw = None self.selected = None self.nodes.clear() self.edges.clear() self.shapes.clear() self.wireless_edges.clear() self.drawing_edge = None self.draw_session(session) def setup_bindings(self): """ Bind any mouse events or hot keys to the matching action :return: nothing """ self.bind("", self.click_press) self.bind("", self.click_release) self.bind("", self.click_motion) self.bind("", self.click_context) self.bind("", self.press_delete) self.bind("", self.ctrl_click) self.bind("", self.double_click) self.bind("", self.zoom) self.bind("", lambda e: self.zoom(e, ZOOM_IN)) self.bind("", lambda e: self.zoom(e, ZOOM_OUT)) self.bind("", lambda e: self.scan_mark(e.x, e.y)) self.bind("", lambda e: self.scan_dragto(e.x, e.y, gain=1)) def hide_context(self): if self.context: self.context.unpost() self.context = None def get_actual_coords(self, x, y): actual_x = (x - self.offset[0]) / self.ratio actual_y = (y - self.offset[1]) / self.ratio return actual_x, actual_y def get_scaled_coords(self, x, y): scaled_x = (x * self.ratio) + self.offset[0] scaled_y = (y * self.ratio) + self.offset[1] return scaled_x, scaled_y def inside_canvas(self, x, y): x1, y1, x2, y2 = self.bbox(self.grid) valid_x = x1 <= x <= x2 valid_y = y1 <= y <= y2 return valid_x and valid_y def valid_position(self, x1, y1, x2, y2): valid_topleft = self.inside_canvas(x1, y1) valid_bottomright = self.inside_canvas(x2, y2) return valid_topleft and valid_bottomright def set_throughputs(self, throughputs_event): for interface_throughput in throughputs_event.interface_throughputs: node_id = interface_throughput.node_id interface_id = interface_throughput.interface_id throughput = interface_throughput.throughput interface_to_edge_id = (node_id, interface_id) token = self.core.interface_to_edge.get(interface_to_edge_id) if not token: continue edge = self.edges.get(token) if edge: edge.set_throughput(throughput) else: del self.core.interface_to_edge[interface_to_edge_id] def draw_grid(self): """ Create grid. :return: nothing """ width, height = self.width_and_height() width = int(width) height = int(height) for i in range(0, width, 27): self.create_line(i, 0, i, height, dash=(2, 4), tags=tags.GRIDLINE) for i in range(0, height, 27): self.create_line(0, i, width, i, dash=(2, 4), tags=tags.GRIDLINE) self.tag_lower(tags.GRIDLINE) self.tag_lower(self.grid) def add_wireless_edge(self, src, dst): """ add a wireless edge between 2 canvas nodes :param CanvasNode src: source node :param CanvasNode dst: destination node :return: nothing """ token = tuple(sorted((src.id, dst.id))) x1, y1 = self.coords(src.id) x2, y2 = self.coords(dst.id) position = (x1, y1, x2, y2) edge = CanvasWirelessEdge(token, position, src.id, dst.id, self) self.wireless_edges[token] = edge src.wireless_edges.add(edge) dst.wireless_edges.add(edge) self.tag_raise(src.id) self.tag_raise(dst.id) def delete_wireless_edge(self, src, dst): token = tuple(sorted((src.id, dst.id))) edge = self.wireless_edges.pop(token) edge.delete() src.wireless_edges.remove(edge) dst.wireless_edges.remove(edge) def draw_session(self, session): """ Draw existing session. :return: nothing """ # draw existing nodes for core_node in session.nodes: # peer to peer node is not drawn on the GUI if NodeUtils.is_ignore_node(core_node.type): continue # draw nodes on the canvas logging.info("drawing core node: %s", core_node) image = NodeUtils.node_icon(core_node.type, core_node.model) if core_node.icon: try: image = Images.create(core_node.icon, nodeutils.ICON_SIZE) except OSError: logging.error("invalid icon: %s", core_node.icon) x = core_node.position.x y = core_node.position.y node = CanvasNode(self.master, x, y, core_node, image) self.nodes[node.id] = node self.core.canvas_nodes[core_node.id] = node # draw existing links for link in session.links: logging.info("drawing link: %s", link) canvas_node_one = self.core.canvas_nodes[link.node_one_id] node_one = canvas_node_one.core_node canvas_node_two = self.core.canvas_nodes[link.node_two_id] node_two = canvas_node_two.core_node token = tuple(sorted((canvas_node_one.id, canvas_node_two.id))) if link.type == core_pb2.LinkType.WIRELESS: self.add_wireless_edge(canvas_node_one, canvas_node_two) else: if token not in self.edges: edge = CanvasEdge( node_one.position.x, node_one.position.y, node_two.position.x, node_two.position.y, canvas_node_one.id, self, ) edge.token = token edge.dst = canvas_node_two.id edge.set_link(link) edge.check_wireless() canvas_node_one.edges.add(edge) canvas_node_two.edges.add(edge) self.edges[edge.token] = edge self.core.links[edge.token] = edge if link.HasField("interface_one"): canvas_node_one.interfaces.append(link.interface_one) if link.HasField("interface_two"): canvas_node_two.interfaces.append(link.interface_two) elif link.options.unidirectional: edge = self.edges[token] edge.asymmetric_link = link else: logging.error("duplicate link received: %s", link) # raise the nodes so they on top of the links self.tag_raise(tags.NODE) def stopped_session(self): # clear wireless edges for edge in self.wireless_edges.values(): edge.delete() src_node = self.nodes[edge.src] src_node.wireless_edges.remove(edge) dst_node = self.nodes[edge.dst] dst_node.wireless_edges.remove(edge) self.wireless_edges.clear() # clear all middle edge labels for edge in self.edges.values(): edge.reset() def canvas_xy(self, event): """ Convert window coordinate to canvas coordinate :param event: :rtype: (int, int) :return: x, y canvas coordinate """ x = self.canvasx(event.x) y = self.canvasy(event.y) return x, y def get_selected(self, event): """ Retrieve the item id that is on the mouse position :param event: mouse event :rtype: int :return: the item that the mouse point to """ x, y = self.canvas_xy(event) overlapping = self.find_overlapping(x, y, x, y) selected = None for _id in overlapping: if self.drawing_edge and self.drawing_edge.id == _id: continue if _id in self.nodes: selected = _id break if _id in self.shapes: selected = _id return selected def click_release(self, event): """ Draw a node or finish drawing an edge according to the current graph mode :param event: mouse event :return: nothing """ logging.debug("click release") x, y = self.canvas_xy(event) if not self.inside_canvas(x, y): return if self.context: self.hide_context() else: if self.mode == GraphMode.ANNOTATION: self.focus_set() if self.shape_drawing: shape = self.shapes[self.selected] shape.shape_complete(x, y) self.shape_drawing = False elif self.mode == GraphMode.SELECT: self.focus_set() if self.select_box: x0, y0, x1, y1 = self.coords(self.select_box.id) inside = [ x for x in self.find_enclosed(x0, y0, x1, y1) if "node" in self.gettags(x) or "shape" in self.gettags(x) ] for i in inside: self.select_object(i, True) self.select_box.disappear() self.select_box = None else: self.focus_set() self.selected = self.get_selected(event) logging.debug( f"click release selected({self.selected}) mode({self.mode})" ) if self.mode == GraphMode.EDGE: self.handle_edge_release(event) elif self.mode == GraphMode.NODE: self.add_node(x, y) elif self.mode == GraphMode.PICKNODE: self.mode = GraphMode.NODE self.selected = None def handle_edge_release(self, event): edge = self.drawing_edge self.drawing_edge = None # not drawing edge return if edge is None: return # edge dst must be a node logging.debug("current selected: %s", self.selected) dst_node = self.nodes.get(self.selected) if not dst_node: edge.delete() return # edge dst is same as src, delete edge if edge.src == self.selected: edge.delete() return # ignore repeated edges token = tuple(sorted((edge.src, self.selected))) if token in self.edges: edge.delete() return # set dst node and snap edge to center edge.complete(self.selected) logging.debug("drawing edge token: %s", edge.token) self.edges[edge.token] = edge node_src = self.nodes[edge.src] node_src.edges.add(edge) node_dst = self.nodes[edge.dst] node_dst.edges.add(edge) self.core.create_link(edge, node_src, node_dst) def select_object(self, object_id, choose_multiple=False): """ create a bounding box when a node is selected """ if not choose_multiple: self.clear_selection() # draw a bounding box if node hasn't been selected yet if object_id not in self.selection: x0, y0, x1, y1 = self.bbox(object_id) selection_id = self.create_rectangle( (x0 - 6, y0 - 6, x1 + 6, y1 + 6), activedash=True, dash="-", tags=tags.SELECTION, ) self.selection[object_id] = selection_id else: selection_id = self.selection.pop(object_id) self.delete(selection_id) def clear_selection(self): """ Clear current selection boxes. :return: nothing """ for _id in self.selection.values(): self.delete(_id) self.selection.clear() def move_selection(self, object_id, x_offset, y_offset): select_id = self.selection.get(object_id) if select_id is not None: self.move(select_id, x_offset, y_offset) def delete_selection_objects(self): edges = set() nodes = [] for object_id in self.selection: # delete selection box selection_id = self.selection[object_id] self.delete(selection_id) # delete node and related edges if object_id in self.nodes: canvas_node = self.nodes.pop(object_id) canvas_node.delete() nodes.append(canvas_node) is_wireless = NodeUtils.is_wireless_node(canvas_node.core_node.type) # delete related edges for edge in canvas_node.edges: if edge in edges: continue edges.add(edge) self.edges.pop(edge.token, None) edge.delete() # update node connected to edge being deleted other_id = edge.src other_interface = edge.src_interface if edge.src == object_id: other_id = edge.dst other_interface = edge.dst_interface other_node = self.nodes[other_id] other_node.edges.remove(edge) try: other_node.interfaces.remove(other_interface) except ValueError: pass if is_wireless: other_node.delete_antenna() # delete shape if object_id in self.shapes: shape = self.shapes.pop(object_id) shape.delete() self.selection.clear() return nodes def zoom(self, event, factor=None): if not factor: factor = ZOOM_IN if event.delta > 0 else ZOOM_OUT event.x, event.y = self.canvasx(event.x), self.canvasy(event.y) self.scale(tk.ALL, event.x, event.y, factor, factor) self.configure(scrollregion=self.bbox(tk.ALL)) self.ratio *= float(factor) self.offset = ( self.offset[0] * factor + event.x * (1 - factor), self.offset[1] * factor + event.y * (1 - factor), ) logging.info("ratio: %s", self.ratio) logging.info("offset: %s", self.offset) self.app.statusbar.zoom.config(text="%s" % (int(self.ratio * 100)) + "%") if self.wallpaper: self.redraw_wallpaper() def click_press(self, event): """ Start drawing an edge if mouse click is on a node :param event: mouse event :return: nothing """ x, y = self.canvas_xy(event) if not self.inside_canvas(x, y): return self.cursor = x, y selected = self.get_selected(event) logging.debug("click press(%s): %s", self.cursor, selected) x_check = self.cursor[0] - self.offset[0] y_check = self.cursor[1] - self.offset[1] logging.debug("clock press ofset(%s, %s)", x_check, y_check) is_node = selected in self.nodes if self.mode == GraphMode.EDGE and is_node: x, y = self.coords(selected) self.drawing_edge = CanvasEdge(x, y, x, y, selected, self) if self.mode == GraphMode.ANNOTATION: if is_marker(self.annotation_type): r = self.app.toolbar.marker_tool.radius self.create_oval( x - r, y - r, x + r, y + r, fill=self.app.toolbar.marker_tool.color, outline="", tags="marker", ) return if selected is None: shape = Shape(self.app, self, self.annotation_type, x, y) self.selected = shape.id self.shape_drawing = True self.shapes[shape.id] = shape if selected is not None: if selected not in self.selection: if selected in self.shapes: shape = self.shapes[selected] self.select_object(shape.id) self.selected = selected elif selected in self.nodes: node = self.nodes[selected] self.select_object(node.id) self.selected = selected logging.info( "selected coords: (%s, %s)", node.core_node.position.x, node.core_node.position.y, ) else: logging.debug("create selection box") if self.mode == GraphMode.SELECT: shape = Shape(self.app, self, ShapeType.RECTANGLE, x, y) self.select_box = shape self.clear_selection() def ctrl_click(self, event): # update cursor location x, y = self.canvas_xy(event) if not self.inside_canvas(x, y): return self.cursor = x, y # handle multiple selections logging.debug("control left click: %s", event) selected = self.get_selected(event) if ( selected not in self.selection and selected in self.shapes or selected in self.nodes ): self.select_object(selected, choose_multiple=True) def click_motion(self, event): """ Redraw drawing edge according to the current position of the mouse :param event: mouse event :return: nothing """ x, y = self.canvas_xy(event) if not self.inside_canvas(x, y): if self.select_box: self.select_box.delete() self.select_box = None if is_draw_shape(self.annotation_type) and self.shape_drawing: shape = self.shapes.pop(self.selected) shape.delete() self.shape_drawing = False return x_offset = x - self.cursor[0] y_offset = y - self.cursor[1] self.cursor = x, y if self.mode == GraphMode.EDGE and self.drawing_edge is not None: x1, y1, _, _ = self.coords(self.drawing_edge.id) self.coords(self.drawing_edge.id, x1, y1, x, y) if self.mode == GraphMode.ANNOTATION: if is_draw_shape(self.annotation_type) and self.shape_drawing: shape = self.shapes[self.selected] shape.shape_motion(x, y) elif is_marker(self.annotation_type): r = self.app.toolbar.marker_tool.radius self.create_oval( x - r, y - r, x + r, y + r, fill=self.app.toolbar.marker_tool.color, outline="", tags="marker", ) return if self.mode == GraphMode.EDGE: return # move selected objects if self.selection: for selected_id in self.selection: if selected_id in self.shapes: shape = self.shapes[selected_id] shape.motion(x_offset, y_offset) if selected_id in self.nodes: node = self.nodes[selected_id] node.motion(x_offset, y_offset, update=self.core.is_runtime()) else: if self.select_box and self.mode == GraphMode.SELECT: self.select_box.shape_motion(x, y) def click_context(self, event): logging.info("context event: %s", self.context) if not self.context: selected = self.get_selected(event) canvas_node = self.nodes.get(selected) if canvas_node: logging.debug("node context: %s", selected) self.context = canvas_node.create_context() self.context.post(event.x_root, event.y_root) else: self.hide_context() def press_delete(self, event): """ delete selected nodes and any data that relates to it :param event: :return: """ logging.debug("press delete key") nodes = self.delete_selection_objects() self.core.delete_graph_nodes(nodes) def double_click(self, event): selected = self.get_selected(event) if selected is not None and selected in self.shapes: shape = self.shapes[selected] dialog = ShapeDialog(self.app, self.app, shape) dialog.show() def add_node(self, x, y): if self.selected is None or self.selected in self.shapes: actual_x, actual_y = self.get_actual_coords(x, y) core_node = self.core.create_node( actual_x, actual_y, self.node_draw.node_type, self.node_draw.model ) node = CanvasNode(self.master, x, y, core_node, self.node_draw.image) self.core.canvas_nodes[core_node.id] = node self.nodes[node.id] = node return node def width_and_height(self): """ retrieve canvas width and height in pixels :return: nothing """ x0, y0, x1, y1 = self.coords(self.grid) canvas_w = abs(x0 - x1) canvas_h = abs(y0 - y1) return canvas_w, canvas_h def get_wallpaper_image(self): width = int(self.wallpaper.width * self.ratio) height = int(self.wallpaper.height * self.ratio) image = self.wallpaper.resize((width, height), Image.ANTIALIAS) return image def draw_wallpaper(self, image, x=None, y=None): if x is None and y is None: x1, y1, x2, y2 = self.bbox(self.grid) x = (x1 + x2) / 2 y = (y1 + y2) / 2 self.wallpaper_id = self.create_image((x, y), image=image, tags=tags.WALLPAPER) self.wallpaper_drawn = image def wallpaper_upper_left(self): self.delete(self.wallpaper_id) # create new scaled image, cropped if needed width, height = self.width_and_height() image = self.get_wallpaper_image() cropx = image.width cropy = image.height if image.width > width: cropx = image.width if image.height > height: cropy = image.height cropped = image.crop((0, 0, cropx, cropy)) image = ImageTk.PhotoImage(cropped) # draw on canvas x1, y1, _, _ = self.bbox(self.grid) x = (cropx / 2) + x1 y = (cropy / 2) + y1 self.draw_wallpaper(image, x, y) def wallpaper_center(self): """ place the image at the center of canvas :return: nothing """ self.delete(self.wallpaper_id) # dimension of the cropped image width, height = self.width_and_height() image = self.get_wallpaper_image() cropx = 0 if image.width > width: cropx = (image.width - width) / 2 cropy = 0 if image.height > height: cropy = (image.height - height) / 2 x1 = 0 + cropx y1 = 0 + cropy x2 = image.width - cropx y2 = image.height - cropy cropped = image.crop((x1, y1, x2, y2)) image = ImageTk.PhotoImage(cropped) self.draw_wallpaper(image) def wallpaper_scaled(self): """ scale image based on canvas dimension :return: nothing """ self.delete(self.wallpaper_id) canvas_w, canvas_h = self.width_and_height() image = self.wallpaper.resize((int(canvas_w), int(canvas_h)), Image.ANTIALIAS) image = ImageTk.PhotoImage(image) self.draw_wallpaper(image) def resize_to_wallpaper(self): self.delete(self.wallpaper_id) image = ImageTk.PhotoImage(self.wallpaper) self.redraw_canvas((image.width(), image.height())) self.draw_wallpaper(image) def redraw_canvas(self, dimensions=None): logging.info("redrawing canvas to dimensions: %s", dimensions) # reset scale and move back to original position logging.info("resetting scaling: %s %s", self.ratio, self.offset) factor = 1 / self.ratio self.scale(tk.ALL, self.offset[0], self.offset[1], factor, factor) self.move(tk.ALL, -self.offset[0], -self.offset[1]) # reset ratio and offset self.ratio = 1.0 self.offset = (0, 0) # redraw canvas rectangle self.draw_canvas(dimensions) # redraw gridlines to new canvas size self.delete(tags.GRIDLINE) self.draw_grid() self.update_grid() def redraw_wallpaper(self): if self.adjust_to_dim.get(): logging.info("drawing wallpaper to canvas dimensions") self.resize_to_wallpaper() else: option = ScaleOption(self.scale_option.get()) logging.info("drawing canvas using scaling option: %s", option) if option == ScaleOption.UPPER_LEFT: self.wallpaper_upper_left() elif option == ScaleOption.CENTERED: self.wallpaper_center() elif option == ScaleOption.SCALED: self.wallpaper_scaled() elif option == ScaleOption.TILED: logging.warning("tiled background not implemented yet") # raise items above wallpaper for component in tags.ABOVE_WALLPAPER_TAGS: self.tag_raise(component) def update_grid(self): logging.info("updating grid show: %s", self.show_grid.get()) if self.show_grid.get(): self.itemconfig(tags.GRIDLINE, state=tk.NORMAL) else: self.itemconfig(tags.GRIDLINE, state=tk.HIDDEN) def set_wallpaper(self, filename): logging.info("setting wallpaper: %s", filename) if filename: img = Image.open(filename) self.wallpaper = img self.wallpaper_file = filename self.redraw_wallpaper() else: if self.wallpaper_id is not None: self.delete(self.wallpaper_id) self.wallpaper = None self.wallpaper_file = None def is_selection_mode(self): return self.mode == GraphMode.SELECT def create_edge(self, source, dest): """ create an edge between source node and destination node :param CanvasNode source: source node :param CanvasNode dest: destination node :return: nothing """ if (source.id, dest.id) not in self.edges: pos0 = source.core_node.position x0 = pos0.x y0 = pos0.y edge = CanvasEdge(x0, y0, x0, y0, source.id, self) edge.complete(dest.id) self.edges[edge.token] = edge self.nodes[source.id].edges.add(edge) self.nodes[dest.id].edges.add(edge) self.core.create_link(edge, source, dest) def copy(self): if self.selection: logging.debug( "store current selection to to_copy, number of nodes: %s", len(self.selection), ) self.to_copy = self.selection.keys() def paste(self): logging.debug("copy") # maps original node canvas id to copy node canvas id copy_map = {} # the edges that will be copy over to_copy_edges = [] for canvas_nid in self.to_copy: core_node = self.nodes[canvas_nid].core_node actual_x = core_node.position.x + 50 actual_y = core_node.position.y + 50 scaled_x, scaled_y = self.get_scaled_coords(actual_x, actual_y) copy = self.core.create_node( actual_x, actual_y, core_node.type, core_node.model ) node = CanvasNode( self.master, scaled_x, scaled_y, copy, self.nodes[canvas_nid].image ) copy_map[canvas_nid] = node.id self.core.canvas_nodes[copy.id] = node self.nodes[node.id] = node self.core.copy_node_config(core_node.id, copy.id) edges = self.nodes[canvas_nid].edges for edge in edges: if edge.src not in self.to_copy or edge.dst not in self.to_copy: if canvas_nid == edge.src: self.create_edge(node, self.nodes[edge.dst]) elif canvas_nid == edge.dst: self.create_edge(self.nodes[edge.src], node) else: to_copy_edges.append(edge) # copy link and link config for edge in to_copy_edges: source_node_copy = self.nodes[copy_map[edge.token[0]]] dest_node_copy = self.nodes[copy_map[edge.token[1]]] self.create_edge(source_node_copy, dest_node_copy) copy_edge = self.edges[ tuple(sorted([source_node_copy.id, dest_node_copy.id])) ] copy_link = copy_edge.link options = edge.link.options copy_link.options.CopyFrom(options) interface_one = None if copy_link.HasField("interface_one"): interface_one = copy_link.interface_one.id interface_two = None if copy_link.HasField("interface_two"): interface_two = copy_link.interface_two.id if not options.unidirectional: copy_edge.asymmetric_link = None else: asym_interface_one = None if interface_one: asym_interface_one = core_pb2.Interface(id=interface_one) asym_interface_two = None if interface_two: asym_interface_two = core_pb2.Interface(id=interface_two) copy_edge.asymmetric_link = core_pb2.Link( node_one_id=copy_link.node_two_id, node_two_id=copy_link.node_one_id, interface_one=asym_interface_one, interface_two=asym_interface_two, options=edge.asymmetric_link.options, ) self.itemconfig( copy_edge.id, width=self.itemcget(edge.id, "width"), fill=self.itemcget(edge.id, "fill"), )