diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py
index 145f7029..d27da28e 100644
--- a/daemon/core/api/grpc/grpcutils.py
+++ b/daemon/core/api/grpc/grpcutils.py
@@ -67,6 +67,7 @@ def add_node_data(node_proto: core_pb2.Node) -> Tuple[NodeTypes, int, NodeOption
         image=node_proto.image,
         services=node_proto.services,
         config_services=node_proto.config_services,
+        canvas=node_proto.canvas,
     )
     if node_proto.emane:
         options.emane = node_proto.emane
@@ -290,6 +291,7 @@ def get_node_proto(session: Session, node: NodeBase) -> core_pb2.Node:
         config_services=config_services,
         dir=node_dir,
         channel=channel,
+        canvas=node.canvas,
     )
 
 
diff --git a/daemon/core/api/grpc/wrappers.py b/daemon/core/api/grpc/wrappers.py
index 8cc55446..1ef43be2 100644
--- a/daemon/core/api/grpc/wrappers.py
+++ b/daemon/core/api/grpc/wrappers.py
@@ -649,6 +649,7 @@ class Node:
     geo: Geo = None
     dir: str = None
     channel: str = None
+    canvas: int = None
 
     # configurations
     emane_model_configs: Dict[
@@ -683,6 +684,7 @@ class Node:
             geo=Geo.from_proto(proto.geo),
             dir=proto.dir,
             channel=proto.channel,
+            canvas=proto.canvas,
         )
 
     def to_proto(self) -> core_pb2.Node:
@@ -700,6 +702,7 @@ class Node:
             server=self.server,
             dir=self.dir,
             channel=self.channel,
+            canvas=self.canvas,
         )
 
 
diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py
index 9264ce84..e0f1c1a4 100644
--- a/daemon/core/emulator/session.py
+++ b/daemon/core/emulator/session.py
@@ -591,7 +591,6 @@ class Session:
         :raises core.CoreError: when node to update does not exist
         """
         node = self.get_node(node_id, NodeBase)
-        node.canvas = options.canvas
         node.icon = options.icon
         self.set_node_position(node, options)
         self.sdt.edit_node(node, options.lon, options.lat, options.alt)
diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py
index be744bb4..d404d923 100644
--- a/daemon/core/gui/app.py
+++ b/daemon/core/gui/app.py
@@ -13,7 +13,7 @@ from core.gui.coreclient import CoreClient
 from core.gui.dialogs.error import ErrorDialog
 from core.gui.frames.base import InfoFrameBase
 from core.gui.frames.default import DefaultInfoFrame
-from core.gui.graph.graph import CanvasGraph
+from core.gui.graph.manager import CanvasManager
 from core.gui.images import ImageEnum, Images
 from core.gui.menubar import Menubar
 from core.gui.nodeutils import NodeUtils
@@ -35,7 +35,7 @@ class Application(ttk.Frame):
         self.menubar: Optional[Menubar] = None
         self.toolbar: Optional[Toolbar] = None
         self.right_frame: Optional[ttk.Frame] = None
-        self.canvas: Optional[CanvasGraph] = None
+        self.manager: Optional[CanvasManager] = None
         self.statusbar: Optional[StatusBar] = None
         self.progress: Optional[Progressbar] = None
         self.infobar: Optional[ttk.Frame] = None
@@ -136,20 +136,8 @@ class Application(ttk.Frame):
         label.grid(sticky=tk.EW, pady=PADY)
 
     def draw_canvas(self) -> None:
-        canvas_frame = ttk.Frame(self.right_frame)
-        canvas_frame.rowconfigure(0, weight=1)
-        canvas_frame.columnconfigure(0, weight=1)
-        canvas_frame.grid(row=0, column=0, sticky=tk.NSEW, pady=1)
-        self.canvas = CanvasGraph(canvas_frame, self, self.core)
-        self.canvas.grid(sticky=tk.NSEW)
-        scroll_y = ttk.Scrollbar(canvas_frame, command=self.canvas.yview)
-        scroll_y.grid(row=0, column=1, sticky=tk.NS)
-        scroll_x = ttk.Scrollbar(
-            canvas_frame, orient=tk.HORIZONTAL, command=self.canvas.xview
-        )
-        scroll_x.grid(row=1, column=0, sticky=tk.EW)
-        self.canvas.configure(xscrollcommand=scroll_x.set)
-        self.canvas.configure(yscrollcommand=scroll_y.set)
+        self.manager = CanvasManager(self.right_frame, self, self.core)
+        self.manager.notebook.grid(sticky=tk.NSEW)
 
     def draw_status(self) -> None:
         self.statusbar = StatusBar(self.right_frame, self)
@@ -201,8 +189,10 @@ class Application(ttk.Frame):
 
     def joined_session_update(self) -> None:
         if self.core.is_runtime():
+            self.menubar.set_state(is_runtime=True)
             self.toolbar.set_runtime()
         else:
+            self.menubar.set_state(is_runtime=False)
             self.toolbar.set_design()
 
     def get_icon(self, image_enum: ImageEnum, width: int) -> PhotoImage:
diff --git a/daemon/core/gui/appconfig.py b/daemon/core/gui/appconfig.py
index 6bc213eb..3f629a80 100644
--- a/daemon/core/gui/appconfig.py
+++ b/daemon/core/gui/appconfig.py
@@ -185,7 +185,8 @@ class GuiConfig(yaml.YAMLObject):
 def copy_files(current_path: Path, new_path: Path) -> None:
     for current_file in current_path.glob("*"):
         new_file = new_path.joinpath(current_file.name)
-        shutil.copy(current_file, new_file)
+        if not new_file.exists():
+            shutil.copy(current_file, new_file)
 
 
 def find_terminal() -> Optional[str]:
@@ -197,16 +198,14 @@ def find_terminal() -> Optional[str]:
 
 
 def check_directory() -> None:
-    if HOME_PATH.exists():
-        return
-    HOME_PATH.mkdir()
-    BACKGROUNDS_PATH.mkdir()
-    CUSTOM_EMANE_PATH.mkdir()
-    CUSTOM_SERVICE_PATH.mkdir()
-    ICONS_PATH.mkdir()
-    MOBILITY_PATH.mkdir()
-    XMLS_PATH.mkdir()
-    SCRIPT_PATH.mkdir()
+    HOME_PATH.mkdir(exist_ok=True)
+    BACKGROUNDS_PATH.mkdir(exist_ok=True)
+    CUSTOM_EMANE_PATH.mkdir(exist_ok=True)
+    CUSTOM_SERVICE_PATH.mkdir(exist_ok=True)
+    ICONS_PATH.mkdir(exist_ok=True)
+    MOBILITY_PATH.mkdir(exist_ok=True)
+    XMLS_PATH.mkdir(exist_ok=True)
+    SCRIPT_PATH.mkdir(exist_ok=True)
 
     copy_files(LOCAL_ICONS_PATH, ICONS_PATH)
     copy_files(LOCAL_BACKGROUND_PATH, BACKGROUNDS_PATH)
diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py
index 01225c6b..31b312e6 100644
--- a/daemon/core/gui/coreclient.py
+++ b/daemon/core/gui/coreclient.py
@@ -6,7 +6,6 @@ import json
 import logging
 import os
 import tkinter as tk
-from pathlib import Path
 from tkinter import messagebox
 from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple
 
@@ -40,16 +39,14 @@ from core.api.grpc.wrappers import (
     SessionState,
     ThroughputsEvent,
 )
-from core.gui import appconfig
-from core.gui.appconfig import BACKGROUNDS_PATH, XMLS_PATH, CoreServer, Observer
+from core.gui.appconfig import XMLS_PATH, CoreServer, Observer
 from core.gui.dialogs.emaneinstall import EmaneInstallDialog
 from core.gui.dialogs.error import ErrorDialog
 from core.gui.dialogs.mobilityplayer import MobilityPlayer
 from core.gui.dialogs.sessions import SessionsDialog
 from core.gui.graph.edges import CanvasEdge
 from core.gui.graph.node import CanvasNode
-from core.gui.graph.shape import AnnotationData, Shape
-from core.gui.graph.shapeutils import ShapeType
+from core.gui.graph.shape import Shape
 from core.gui.interface import InterfaceManager
 from core.gui.nodeutils import NodeDraw, NodeUtils
 
@@ -207,27 +204,26 @@ class CoreClient:
         canvas_node2 = self.canvas_nodes[node2_id]
         if event.link.type == LinkType.WIRELESS:
             if event.message_type == MessageType.ADD:
-                self.app.canvas.add_wireless_edge(
+                self.app.manager.add_wireless_edge(
                     canvas_node1, canvas_node2, event.link
                 )
             elif event.message_type == MessageType.DELETE:
-                self.app.canvas.delete_wireless_edge(
+                self.app.manager.delete_wireless_edge(
                     canvas_node1, canvas_node2, event.link
                 )
             elif event.message_type == MessageType.NONE:
-                self.app.canvas.update_wireless_edge(
+                self.app.manager.update_wireless_edge(
                     canvas_node1, canvas_node2, event.link
                 )
             else:
                 logging.warning("unknown link event: %s", event)
         else:
             if event.message_type == MessageType.ADD:
-                self.app.canvas.add_wired_edge(canvas_node1, canvas_node2, event.link)
-                self.app.canvas.organize()
+                self.app.manager.add_wired_edge(canvas_node1, canvas_node2, event.link)
             elif event.message_type == MessageType.DELETE:
-                self.app.canvas.delete_wired_edge(event.link)
+                self.app.manager.delete_wired_edge(event.link)
             elif event.message_type == MessageType.NONE:
-                self.app.canvas.update_wired_edge(event.link)
+                self.app.manager.update_wired_edge(event.link)
             else:
                 logging.warning("unknown link event: %s", event)
 
@@ -243,13 +239,13 @@ class CoreClient:
                 canvas_node.update_icon(node.icon)
         elif event.message_type == MessageType.DELETE:
             canvas_node = self.canvas_nodes[node.id]
-            self.app.canvas.clear_selection()
-            self.app.canvas.select_object(canvas_node.id)
-            self.app.canvas.delete_selected_objects()
+            canvas_node.canvas.clear_selection()
+            canvas_node.canvas.select_object(canvas_node.id)
+            canvas_node.canvas.delete_selected_objects()
         elif event.message_type == MessageType.ADD:
             if node.id in self.session.nodes:
                 logging.error("core node already exists: %s", node)
-            self.app.canvas.add_core_node(node)
+            self.app.manager.add_core_node(node)
         else:
             logging.warning("unknown node event: %s", event)
 
@@ -262,7 +258,7 @@ class CoreClient:
         if self.handling_throughputs:
             self.handling_throughputs.cancel()
             self.handling_throughputs = None
-            self.app.canvas.clear_throughputs()
+            self.app.manager.clear_throughputs()
 
     def cancel_events(self) -> None:
         if self.handling_events:
@@ -293,7 +289,7 @@ class CoreClient:
             )
             return
         logging.debug("handling throughputs event: %s", event)
-        self.app.after(0, self.app.canvas.set_throughputs, event)
+        self.app.after(0, self.app.manager.set_throughputs, event)
 
     def handle_cpu_event(self, event: core_pb2.CpuUsageEvent) -> None:
         self.app.after(0, self.app.statusbar.set_cpu, event.usage)
@@ -315,9 +311,7 @@ class CoreClient:
                 self.session.id, self.handle_events
             )
             self.ifaces_manager.joined(self.session.links)
-            self.app.canvas.reset_and_redraw(self.session)
-            self.parse_metadata()
-            self.app.canvas.organize()
+            self.app.manager.join(self.session)
             if self.is_runtime():
                 self.show_mobility_players()
             self.app.after(0, self.app.joined_session_update)
@@ -334,23 +328,7 @@ class CoreClient:
         logging.debug("canvas metadata: %s", canvas_config)
         if canvas_config:
             canvas_config = json.loads(canvas_config)
-            gridlines = canvas_config.get("gridlines", True)
-            self.app.canvas.show_grid.set(gridlines)
-            fit_image = canvas_config.get("fit_image", False)
-            self.app.canvas.adjust_to_dim.set(fit_image)
-            wallpaper_style = canvas_config.get("wallpaper-style", 1)
-            self.app.canvas.scale_option.set(wallpaper_style)
-            width = self.app.guiconfig.preferences.width
-            height = self.app.guiconfig.preferences.height
-            dimensions = canvas_config.get("dimensions", [width, height])
-            self.app.canvas.redraw_canvas(dimensions)
-            wallpaper = canvas_config.get("wallpaper")
-            if wallpaper:
-                wallpaper = str(appconfig.BACKGROUNDS_PATH.joinpath(wallpaper))
-            self.app.canvas.set_wallpaper(wallpaper)
-        else:
-            self.app.canvas.redraw_canvas()
-            self.app.canvas.set_wallpaper(None)
+            self.app.manager.parse_metadata(canvas_config)
 
         # load saved shapes
         shapes_config = config.get("shapes")
@@ -358,28 +336,7 @@ class CoreClient:
             shapes_config = json.loads(shapes_config)
             for shape_config in shapes_config:
                 logging.debug("loading shape: %s", shape_config)
-                shape_type = shape_config["type"]
-                try:
-                    shape_type = ShapeType(shape_type)
-                    coords = shape_config["iconcoords"]
-                    data = AnnotationData(
-                        shape_config["label"],
-                        shape_config["fontfamily"],
-                        shape_config["fontsize"],
-                        shape_config["labelcolor"],
-                        shape_config["color"],
-                        shape_config["border"],
-                        shape_config["width"],
-                        shape_config["bold"],
-                        shape_config["italic"],
-                        shape_config["underline"],
-                    )
-                    shape = Shape(
-                        self.app, self.app.canvas, shape_type, *coords, data=data
-                    )
-                    self.app.canvas.shapes[shape.id] = shape
-                except ValueError:
-                    logging.exception("unknown shape: %s", shape_type)
+                Shape.from_metadata(self.app, shape_config)
 
         # load edges config
         edges_config = config.get("edges")
@@ -392,6 +349,17 @@ class CoreClient:
                 edge.color = edge_config["color"]
                 edge.redraw()
 
+        # read hidden nodes
+        hidden = config.get("hidden")
+        if hidden:
+            hidden = json.loads(hidden)
+            for _id in hidden:
+                canvas_node = self.canvas_nodes.get(_id)
+                if canvas_node:
+                    canvas_node.hide()
+                else:
+                    logging.warning("invalid node to hide: %s", _id)
+
     def create_new_session(self) -> None:
         """
         Create a new session
@@ -557,26 +525,14 @@ class CoreClient:
 
     def set_metadata(self) -> None:
         # create canvas data
-        wallpaper_path = None
-        if self.app.canvas.wallpaper_file:
-            wallpaper = Path(self.app.canvas.wallpaper_file)
-            if BACKGROUNDS_PATH == wallpaper.parent:
-                wallpaper_path = wallpaper.name
-            else:
-                wallpaper_path = str(wallpaper)
-        canvas_config = {
-            "wallpaper": wallpaper_path,
-            "wallpaper-style": self.app.canvas.scale_option.get(),
-            "gridlines": self.app.canvas.show_grid.get(),
-            "fit_image": self.app.canvas.adjust_to_dim.get(),
-            "dimensions": self.app.canvas.current_dimensions,
-        }
+        canvas_config = self.app.manager.get_metadata()
         canvas_config = json.dumps(canvas_config)
 
         # create shapes data
         shapes = []
-        for shape in self.app.canvas.shapes.values():
-            shapes.append(shape.metadata())
+        for canvas in self.app.manager.all():
+            for shape in canvas.shapes.values():
+                shapes.append(shape.metadata())
         shapes = json.dumps(shapes)
 
         # create edges config
@@ -588,8 +544,14 @@ class CoreClient:
             edges_config.append(edge_config)
         edges_config = json.dumps(edges_config)
 
+        # create hidden metadata
+        hidden = [x.core_node.id for x in self.canvas_nodes.values() if x.hidden]
+        hidden = json.dumps(hidden)
+
         # save metadata
-        metadata = dict(canvas=canvas_config, shapes=shapes, edges=edges_config)
+        metadata = dict(
+            canvas=canvas_config, shapes=shapes, edges=edges_config, hidden=hidden
+        )
         response = self.client.set_session_metadata(self.session.id, metadata)
         logging.debug("set session metadata %s, result: %s", metadata, response)
 
diff --git a/daemon/core/gui/data/icons/shadow.png b/daemon/core/gui/data/icons/shadow.png
new file mode 100644
index 00000000..6d6f3571
Binary files /dev/null and b/daemon/core/gui/data/icons/shadow.png differ
diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py
index e50bf986..8155cb57 100644
--- a/daemon/core/gui/dialogs/canvassizeandscale.py
+++ b/daemon/core/gui/dialogs/canvassizeandscale.py
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
 
 from core.gui import validation
 from core.gui.dialogs.dialog import Dialog
-from core.gui.graph.graph import CanvasGraph
+from core.gui.graph.manager import CanvasManager
 from core.gui.themes import FRAME_PAD, PADX, PADY
 
 if TYPE_CHECKING:
@@ -22,9 +22,9 @@ class SizeAndScaleDialog(Dialog):
         create an instance for size and scale object
         """
         super().__init__(app, "Canvas Size and Scale")
-        self.canvas: CanvasGraph = self.app.canvas
+        self.manager: CanvasManager = self.app.manager
         self.section_font: font.Font = font.Font(weight=font.BOLD)
-        width, height = self.canvas.current_dimensions
+        width, height = self.manager.current_dimensions
         self.pixel_width: tk.IntVar = tk.IntVar(value=width)
         self.pixel_height: tk.IntVar = tk.IntVar(value=height)
         location = self.app.core.session.location
@@ -189,9 +189,7 @@ class SizeAndScaleDialog(Dialog):
 
     def click_apply(self) -> None:
         width, height = self.pixel_width.get(), self.pixel_height.get()
-        self.canvas.redraw_canvas((width, height))
-        if self.canvas.wallpaper:
-            self.canvas.redraw_wallpaper()
+        self.manager.redraw_canvases((width, height))
         location = self.app.core.session.location
         location.x = self.x.get()
         location.y = self.y.get()
diff --git a/daemon/core/gui/dialogs/canvaswallpaper.py b/daemon/core/gui/dialogs/canvaswallpaper.py
index 629f9f36..505b6b74 100644
--- a/daemon/core/gui/dialogs/canvaswallpaper.py
+++ b/daemon/core/gui/dialogs/canvaswallpaper.py
@@ -23,7 +23,7 @@ class CanvasWallpaperDialog(Dialog):
         create an instance of CanvasWallpaper object
         """
         super().__init__(app, "Canvas Background")
-        self.canvas: CanvasGraph = self.app.canvas
+        self.canvas: CanvasGraph = self.app.manager.current()
         self.scale_option: tk.IntVar = tk.IntVar(value=self.canvas.scale_option.get())
         self.adjust_to_dim: tk.BooleanVar = tk.BooleanVar(
             value=self.canvas.adjust_to_dim.get()
@@ -161,7 +161,6 @@ class CanvasWallpaperDialog(Dialog):
     def click_apply(self) -> None:
         self.canvas.scale_option.set(self.scale_option.get())
         self.canvas.adjust_to_dim.set(self.adjust_to_dim.get())
-        self.canvas.show_grid.click_handler()
         filename = self.filename.get()
         if not filename:
             filename = None
diff --git a/daemon/core/gui/dialogs/find.py b/daemon/core/gui/dialogs/find.py
index 6bfac47b..3b899ef8 100644
--- a/daemon/core/gui/dialogs/find.py
+++ b/daemon/core/gui/dialogs/find.py
@@ -105,9 +105,13 @@ class FindDialog(Dialog):
             self.tree.selection_set(results[0])
 
     def close_dialog(self) -> None:
-        self.app.canvas.delete("find")
+        self.clear_find()
         self.destroy()
 
+    def clear_find(self):
+        for canvas in self.app.manager.all():
+            canvas.delete("find")
+
     def click_select(self, _event: tk.Event = None) -> None:
         """
         find the node that matches search criteria, circle around that node
@@ -116,13 +120,13 @@ class FindDialog(Dialog):
         """
         item = self.tree.selection()
         if item:
-            self.app.canvas.delete("find")
+            self.clear_find()
             node_id = int(self.tree.item(item, "text"))
             canvas_node = self.app.core.get_canvas_node(node_id)
-
-            x0, y0, x1, y1 = self.app.canvas.bbox(canvas_node.id)
+            self.app.manager.select(canvas_node.canvas.id)
+            x0, y0, x1, y1 = canvas_node.canvas.bbox(canvas_node.id)
             dist = 5 * self.app.guiconfig.scale
-            self.app.canvas.create_oval(
+            canvas_node.canvas.create_oval(
                 x0 - dist,
                 y0 - dist,
                 x1 + dist,
@@ -132,9 +136,9 @@ class FindDialog(Dialog):
                 width=3.0 * self.app.guiconfig.scale,
             )
 
-            _x, _y, _, _ = self.app.canvas.bbox(canvas_node.id)
-            oid = self.app.canvas.find_withtag("rectangle")
-            x0, y0, x1, y1 = self.app.canvas.bbox(oid[0])
+            _x, _y, _, _ = canvas_node.canvas.bbox(canvas_node.id)
+            oid = canvas_node.canvas.find_withtag("rectangle")
+            x0, y0, x1, y1 = canvas_node.canvas.bbox(oid[0])
             logging.debug("Dist to most left: %s", abs(x0 - _x))
             logging.debug("White canvas width: %s", abs(x0 - x1))
 
@@ -150,5 +154,5 @@ class FindDialog(Dialog):
                     xscroll_fraction = xscroll_fraction - 0.05
                 if yscroll_fraction > 0.05:
                     yscroll_fraction = yscroll_fraction - 0.05
-            self.app.canvas.xview_moveto(xscroll_fraction)
-            self.app.canvas.yview_moveto(yscroll_fraction)
+            canvas_node.canvas.xview_moveto(xscroll_fraction)
+            canvas_node.canvas.yview_moveto(yscroll_fraction)
diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py
index 6cb22862..e8fd2a07 100644
--- a/daemon/core/gui/dialogs/linkconfig.py
+++ b/daemon/core/gui/dialogs/linkconfig.py
@@ -70,10 +70,10 @@ class LinkConfigurationDialog(Dialog):
 
     def draw(self) -> None:
         self.top.columnconfigure(0, weight=1)
-        src_label = self.app.canvas.nodes[self.edge.src].core_node.name
+        src_label = self.edge.src.core_node.name
         if self.edge.link.iface1:
             src_label += f":{self.edge.link.iface1.name}"
-        dst_label = self.app.canvas.nodes[self.edge.dst].core_node.name
+        dst_label = self.edge.dst.core_node.name
         if self.edge.link.iface2:
             dst_label += f":{self.edge.link.iface2.name}"
         label = ttk.Label(
@@ -316,10 +316,8 @@ class LinkConfigurationDialog(Dialog):
         """
         populate link config to the table
         """
-        width = self.app.canvas.itemcget(self.edge.id, "width")
-        self.width.set(width)
-        color = self.app.canvas.itemcget(self.edge.id, "fill")
-        self.color.set(color)
+        self.width.set(self.edge.width)
+        self.color.set(self.edge.color)
         link = self.edge.link
         if link.options:
             self.bandwidth.set(str(link.options.bandwidth))
diff --git a/daemon/core/gui/dialogs/preferences.py b/daemon/core/gui/dialogs/preferences.py
index d0c58dfa..f78e5c48 100644
--- a/daemon/core/gui/dialogs/preferences.py
+++ b/daemon/core/gui/dialogs/preferences.py
@@ -134,7 +134,8 @@ class PreferencesDialog(Dialog):
 
         # scale toolbar and canvas items
         self.app.toolbar.scale()
-        self.app.canvas.scale_graph()
+        for canvas in self.app.manager.all():
+            canvas.scale_graph()
 
     def adjust_scale(self, arg1: str, arg2: str, arg3: str) -> None:
         scale_value = self.gui_scale.get()
diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py
index 71a33fd6..6d818f94 100644
--- a/daemon/core/gui/dialogs/sessions.py
+++ b/daemon/core/gui/dialogs/sessions.py
@@ -199,7 +199,10 @@ class SessionsDialog(Dialog):
         logging.debug("delete session: %s", self.selected_session)
         self.tree.delete(self.selected_id)
         self.app.core.delete_session(self.selected_session)
-        if self.selected_session == self.app.core.session.id:
+        session_id = None
+        if self.app.core.session:
+            session_id = self.app.core.session.id
+        if self.selected_session == session_id:
             self.click_new()
             self.destroy()
         self.click_select()
diff --git a/daemon/core/gui/dialogs/shapemod.py b/daemon/core/gui/dialogs/shapemod.py
index 255092ec..d0e200ee 100644
--- a/daemon/core/gui/dialogs/shapemod.py
+++ b/daemon/core/gui/dialogs/shapemod.py
@@ -27,7 +27,7 @@ class ShapeDialog(Dialog):
         else:
             title = "Add Text"
         super().__init__(app, title)
-        self.canvas: "CanvasGraph" = app.canvas
+        self.canvas: "CanvasGraph" = app.manager.current()
         self.fill: Optional[ttk.Label] = None
         self.border: Optional[ttk.Label] = None
         self.shape: "Shape" = shape
diff --git a/daemon/core/gui/dialogs/throughput.py b/daemon/core/gui/dialogs/throughput.py
index 0b59a6ac..493d4da4 100644
--- a/daemon/core/gui/dialogs/throughput.py
+++ b/daemon/core/gui/dialogs/throughput.py
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Optional
 
 from core.gui.dialogs.colorpicker import ColorPickerDialog
 from core.gui.dialogs.dialog import Dialog
-from core.gui.graph.graph import CanvasGraph
+from core.gui.graph.manager import CanvasManager
 from core.gui.themes import FRAME_PAD, PADX, PADY
 
 if TYPE_CHECKING:
@@ -17,16 +17,16 @@ if TYPE_CHECKING:
 class ThroughputDialog(Dialog):
     def __init__(self, app: "Application") -> None:
         super().__init__(app, "Throughput Config")
-        self.canvas: CanvasGraph = app.canvas
+        self.manager: CanvasManager = app.manager
         self.show_throughput: tk.IntVar = tk.IntVar(value=1)
         self.exponential_weight: tk.IntVar = tk.IntVar(value=1)
         self.transmission: tk.IntVar = tk.IntVar(value=1)
         self.reception: tk.IntVar = tk.IntVar(value=1)
         self.threshold: tk.DoubleVar = tk.DoubleVar(
-            value=self.canvas.throughput_threshold
+            value=self.manager.throughput_threshold
         )
-        self.width: tk.IntVar = tk.IntVar(value=self.canvas.throughput_width)
-        self.color: str = self.canvas.throughput_color
+        self.width: tk.IntVar = tk.IntVar(value=self.manager.throughput_width)
+        self.color: str = self.manager.throughput_color
         self.color_button: Optional[tk.Button] = None
         self.top.columnconfigure(0, weight=1)
         self.draw()
@@ -106,7 +106,7 @@ class ThroughputDialog(Dialog):
         self.color_button.config(bg=self.color, text=self.color, bd=0)
 
     def click_save(self) -> None:
-        self.canvas.throughput_threshold = self.threshold.get()
-        self.canvas.throughput_width = self.width.get()
-        self.canvas.throughput_color = self.color
+        self.manager.throughput_threshold = self.threshold.get()
+        self.manager.throughput_width = self.width.get()
+        self.manager.throughput_color = self.color
         self.destroy()
diff --git a/daemon/core/gui/dialogs/wlanconfig.py b/daemon/core/gui/dialogs/wlanconfig.py
index 05362cc6..237ca8a5 100644
--- a/daemon/core/gui/dialogs/wlanconfig.py
+++ b/daemon/core/gui/dialogs/wlanconfig.py
@@ -21,7 +21,7 @@ RANGE_WIDTH: int = 3
 class WlanConfigDialog(Dialog):
     def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None:
         super().__init__(app, f"{canvas_node.core_node.name} WLAN Configuration")
-        self.canvas: "CanvasGraph" = app.canvas
+        self.canvas: "CanvasGraph" = app.manager.current()
         self.canvas_node: "CanvasNode" = canvas_node
         self.node: Node = canvas_node.core_node
         self.config_frame: Optional[ConfigFrame] = None
diff --git a/daemon/core/gui/frames/link.py b/daemon/core/gui/frames/link.py
index 086f7ca8..bde0aec8 100644
--- a/daemon/core/gui/frames/link.py
+++ b/daemon/core/gui/frames/link.py
@@ -79,15 +79,13 @@ class WirelessEdgeInfoFrame(InfoFrameBase):
 
     def draw(self) -> None:
         link = self.edge.link
-        src_canvas_node = self.app.canvas.nodes[self.edge.src]
-        src_node = src_canvas_node.core_node
-        dst_canvas_node = self.app.canvas.nodes[self.edge.dst]
-        dst_node = dst_canvas_node.core_node
+        src_node = self.edge.src.core_node
+        dst_node = self.edge.dst.core_node
 
         # find interface for each node connected to network
         net_id = link.network_id
-        iface1 = get_iface(src_canvas_node, net_id)
-        iface2 = get_iface(dst_canvas_node, net_id)
+        iface1 = get_iface(self.edge.src, net_id)
+        iface2 = get_iface(self.edge.dst, net_id)
 
         frame = DetailsFrame(self)
         frame.grid(sticky=tk.EW)
diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py
index 216fc7f2..bf5ecbc1 100644
--- a/daemon/core/gui/graph/edges.py
+++ b/daemon/core/gui/graph/edges.py
@@ -1,18 +1,25 @@
+import functools
 import logging
 import math
 import tkinter as tk
-from typing import TYPE_CHECKING, Optional, Tuple
+from typing import TYPE_CHECKING, Optional, Tuple, Union
+
+from PIL.ImageTk import PhotoImage
 
 from core.api.grpc.wrappers import Interface, Link
 from core.gui import themes
 from core.gui.dialogs.linkconfig import LinkConfigurationDialog
 from core.gui.frames.link import EdgeInfoFrame, WirelessEdgeInfoFrame
 from core.gui.graph import tags
-from core.gui.nodeutils import NodeUtils
+from core.gui.images import ImageEnum
+from core.gui.nodeutils import ICON_SIZE
 from core.gui.utils import bandwidth_text, delay_jitter_text
 
 if TYPE_CHECKING:
+    from core.gui.app import Application
     from core.gui.graph.graph import CanvasGraph
+    from core.gui.graph.manager import CanvasManager
+    from core.gui.graph.node import CanvasNode
 
 TEXT_DISTANCE: int = 60
 EDGE_WIDTH: int = 3
@@ -33,6 +40,19 @@ def create_edge_token(link: Link) -> str:
     return f"{link.node1_id}-{iface1_id}-{link.node2_id}-{iface2_id}"
 
 
+def node_label_positions(
+    src_x: int, src_y: int, dst_x: int, dst_y: int
+) -> Tuple[Tuple[float, float], Tuple[float, float]]:
+    v_x, v_y = dst_x - src_x, dst_y - src_y
+    v_len = math.sqrt(v_x ** 2 + v_y ** 2)
+    if v_len == 0:
+        u_x, u_y = 0.0, 0.0
+    else:
+        u_x, u_y = v_x / v_len, v_y / v_len
+    offset_x, offset_y = TEXT_DISTANCE * u_x, TEXT_DISTANCE * u_y
+    return (src_x + offset_x, src_y + offset_y), (dst_x - offset_x, dst_y - offset_y)
+
+
 def arc_edges(edges) -> None:
     if not edges:
         return
@@ -62,24 +82,114 @@ def arc_edges(edges) -> None:
         edge.redraw()
 
 
+class ShadowNode:
+    def __init__(
+        self, app: "Application", canvas: "CanvasGraph", node: "CanvasNode"
+    ) -> None:
+        self.app: "Application" = app
+        self.canvas: "CanvasGraph" = canvas
+        self.node: "CanvasNode" = node
+        self.id: Optional[int] = None
+        self.text_id: Optional[int] = None
+        self.image: PhotoImage = self.app.get_icon(ImageEnum.SHADOW, ICON_SIZE)
+        self.draw()
+
+    def draw(self) -> None:
+        x, y = self.node.position()
+        self.id: int = self.canvas.create_image(
+            x, y, anchor=tk.CENTER, image=self.image, tags=tags.NODE
+        )
+        self.text_id = self.canvas.create_text(
+            x,
+            y + 20,
+            text=f"{self.node.get_label()} [{self.node.canvas.id}]",
+            tags=tags.NODE_LABEL,
+            font=self.app.icon_text_font,
+            fill="#0000CD",
+            state=self.app.manager.show_node_labels.state(),
+            justify=tk.CENTER,
+        )
+        self.canvas.shadow_nodes[self.id] = self
+        self.canvas.shadow_core_nodes[self.node.core_node.id] = self
+
+    def position(self) -> Tuple[int, int]:
+        return self.canvas.coords(self.id)
+
+    def should_delete(self) -> bool:
+        for edge in self.node.edges:
+            other_node = edge.src
+            if self.node == edge.src:
+                other_node = edge.dst
+            if not other_node.is_wireless() and other_node.canvas == self.canvas:
+                return False
+        return True
+
+    def motion(self, x_offset, y_offset) -> None:
+        original_position = self.position()
+        self.canvas.move(self.id, x_offset, y_offset)
+
+        # check new position
+        bbox = self.canvas.bbox(self.id)
+        if not self.canvas.valid_position(*bbox):
+            self.canvas.coords(self.id, original_position)
+            return
+
+        # move text and selection box
+        self.canvas.move(self.text_id, x_offset, y_offset)
+        self.canvas.move_selection(self.id, x_offset, y_offset)
+
+        # move edges
+        for edge in self.node.edges:
+            edge.move_shadow(self)
+        for edge in self.node.wireless_edges:
+            edge.move_shadow(self)
+
+    def delete(self):
+        self.canvas.shadow_nodes.pop(self.id, None)
+        self.canvas.shadow_core_nodes.pop(self.node.core_node.id, None)
+        self.canvas.delete(self.id)
+        self.canvas.delete(self.text_id)
+
+    def hide(self) -> None:
+        self.canvas.itemconfig(self.id, state=tk.HIDDEN)
+        self.canvas.itemconfig(self.text_id, state=tk.HIDDEN)
+
+    def show(self) -> None:
+        self.canvas.itemconfig(self.id, state=tk.NORMAL)
+        self.canvas.itemconfig(self.text_id, state=tk.NORMAL)
+
+
 class Edge:
     tag: str = tags.EDGE
 
-    def __init__(self, canvas: "CanvasGraph", src: int, dst: int = None) -> None:
-        self.canvas = canvas
+    def __init__(
+        self, app: "Application", src: "CanvasNode", dst: "CanvasNode" = None
+    ) -> None:
+        self.app: "Application" = app
+        self.manager: CanvasManager = app.manager
         self.id: Optional[int] = None
-        self.src: int = src
-        self.dst: int = dst
+        self.id2: Optional[int] = None
+        self.src: "CanvasNode" = src
+        self.src_shadow: Optional[ShadowNode] = None
+        self.dst: Optional["CanvasNode"] = dst
+        self.dst_shadow: Optional[ShadowNode] = None
         self.arc: int = 0
         self.token: Optional[str] = None
         self.src_label: Optional[int] = None
+        self.src_label2: Optional[int] = None
         self.middle_label: Optional[int] = None
+        self.middle_label2: Optional[int] = None
         self.dst_label: Optional[int] = None
+        self.dst_label2: Optional[int] = None
         self.color: str = EDGE_COLOR
         self.width: int = EDGE_WIDTH
+        self.linked_wireless: bool = False
+        self.hidden: bool = False
+        if self.dst:
+            self.linked_wireless = self.src.is_wireless() or self.dst.is_wireless()
 
     def scaled_width(self) -> float:
-        return self.width * self.canvas.app.app_scale
+        return self.width * self.app.app_scale
 
     def _get_arcpoint(
         self, src_pos: Tuple[float, float], dst_pos: Tuple[float, float]
@@ -110,11 +220,57 @@ class Edge:
         arc_y = (perp_m * arc_x) + b
         return arc_x, arc_y
 
-    def draw(
-        self, src_pos: Tuple[float, float], dst_pos: Tuple[float, float], state: str
-    ) -> None:
+    def arc_common_edges(self) -> None:
+        common_edges = list(self.src.edges & self.dst.edges)
+        common_edges += list(self.src.wireless_edges & self.dst.wireless_edges)
+        arc_edges(common_edges)
+
+    def has_shadows(self) -> bool:
+        # still drawing
+        if not self.dst:
+            return False
+        return self.src.canvas != self.dst.canvas
+
+    def draw(self, state: str) -> None:
+        if not self.has_shadows():
+            dst = self.dst if self.dst else self.src
+            self.id = self.draw_edge(self.src.canvas, self.src, dst, state)
+        elif self.linked_wireless:
+            if self.src.is_wireless():
+                self.src_shadow = self.dst.canvas.get_shadow(self.src)
+                self.id2 = self.draw_edge(
+                    self.dst.canvas, self.src_shadow, self.dst, state
+                )
+            if self.dst.is_wireless():
+                self.dst_shadow = self.src.canvas.get_shadow(self.dst)
+                self.id = self.draw_edge(
+                    self.src.canvas, self.src, self.dst_shadow, state
+                )
+        else:
+            # draw shadow nodes and 2 lines
+            self.src_shadow = self.dst.canvas.get_shadow(self.src)
+            self.dst_shadow = self.src.canvas.get_shadow(self.dst)
+            self.id = self.draw_edge(self.src.canvas, self.src, self.dst_shadow, state)
+            self.id2 = self.draw_edge(self.dst.canvas, self.src_shadow, self.dst, state)
+            self.src.canvas.organize()
+            self.dst.canvas.organize()
+        logging.info(
+            "drawed edge: src shadow(%s) dst shadow(%s)",
+            self.src_shadow,
+            self.dst_shadow,
+        )
+
+    def draw_edge(
+        self,
+        canvas: "CanvasGraph",
+        src: Union["CanvasNode", "ShadowNode"],
+        dst: Union["CanvasNode", "ShadowNode"],
+        state: str,
+    ) -> int:
+        src_pos = src.position()
+        dst_pos = dst.position()
         arc_pos = self._get_arcpoint(src_pos, dst_pos)
-        self.id = self.canvas.create_line(
+        return canvas.create_line(
             *src_pos,
             *arc_pos,
             *dst_pos,
@@ -126,112 +282,243 @@ class Edge:
         )
 
     def redraw(self) -> None:
-        self.canvas.itemconfig(self.id, width=self.scaled_width(), fill=self.color)
-        src_x, src_y, _, _, _, _ = self.canvas.coords(self.id)
-        src_pos = src_x, src_y
-        self.move_src(src_pos)
-
-    def middle_label_pos(self) -> Tuple[float, float]:
-        _, _, x, y, _, _ = self.canvas.coords(self.id)
-        return x, y
+        self.src.canvas.itemconfig(self.id, width=self.scaled_width(), fill=self.color)
+        self.move_src()
+        if self.id2:
+            self.dst.canvas.itemconfig(
+                self.id2, width=self.scaled_width(), fill=self.color
+            )
+            self.move_dst()
 
     def middle_label_text(self, text: str) -> None:
         if self.middle_label is None:
-            x, y = self.middle_label_pos()
-            self.middle_label = self.canvas.create_text(
+            _, _, x, y, _, _ = self.src.canvas.coords(self.id)
+            self.middle_label = self.src.canvas.create_text(
                 x,
                 y,
-                font=self.canvas.app.edge_font,
+                font=self.app.edge_font,
                 text=text,
                 tags=tags.LINK_LABEL,
                 justify=tk.CENTER,
-                state=self.canvas.show_link_labels.state(),
+                state=self.manager.show_link_labels.state(),
             )
+            if self.id2:
+                _, _, x, y, _, _ = self.dst.canvas.coords(self.id2)
+                self.middle_label2 = self.dst.canvas.create_text(
+                    x,
+                    y,
+                    font=self.app.edge_font,
+                    text=text,
+                    tags=tags.LINK_LABEL,
+                    justify=tk.CENTER,
+                    state=self.manager.show_link_labels.state(),
+                )
         else:
-            self.canvas.itemconfig(self.middle_label, text=text)
+            self.src.canvas.itemconfig(self.middle_label, text=text)
+            if self.middle_label2:
+                self.dst.canvas.itemconfig(self.middle_label2, text=text)
 
     def clear_middle_label(self) -> None:
-        self.canvas.delete(self.middle_label)
+        self.src.canvas.delete(self.middle_label)
         self.middle_label = None
-
-    def node_label_positions(self) -> Tuple[Tuple[float, float], Tuple[float, float]]:
-        src_x, src_y, _, _, dst_x, dst_y = self.canvas.coords(self.id)
-        v_x, v_y = dst_x - src_x, dst_y - src_y
-        v_len = math.sqrt(v_x ** 2 + v_y ** 2)
-        if v_len == 0:
-            u_x, u_y = 0.0, 0.0
-        else:
-            u_x, u_y = v_x / v_len, v_y / v_len
-        offset_x, offset_y = TEXT_DISTANCE * u_x, TEXT_DISTANCE * u_y
-        return (
-            (src_x + offset_x, src_y + offset_y),
-            (dst_x - offset_x, dst_y - offset_y),
-        )
+        if self.middle_label2:
+            self.dst.canvas.delete(self.middle_label2)
+            self.middle_label2 = None
 
     def src_label_text(self, text: str) -> None:
-        if self.src_label is None:
-            src_pos, _ = self.node_label_positions()
-            self.src_label = self.canvas.create_text(
-                *src_pos,
-                text=text,
-                justify=tk.CENTER,
-                font=self.canvas.app.edge_font,
-                tags=tags.LINK_LABEL,
-                state=self.canvas.show_link_labels.state(),
-            )
+        if self.src_label is None and self.src_label2 is None:
+            if self.id:
+                logging.info("src label id")
+                src_x, src_y, _, _, dst_x, dst_y = self.src.canvas.coords(self.id)
+                src_pos, _ = node_label_positions(src_x, src_y, dst_x, dst_y)
+                self.src_label = self.src.canvas.create_text(
+                    *src_pos,
+                    text=text,
+                    justify=tk.CENTER,
+                    font=self.app.edge_font,
+                    tags=tags.LINK_LABEL,
+                    state=self.manager.show_link_labels.state(),
+                )
+            if self.id2:
+                logging.info("src label id2")
+                src_x, src_y, _, _, dst_x, dst_y = self.dst.canvas.coords(self.id2)
+                src_pos, _ = node_label_positions(src_x, src_y, dst_x, dst_y)
+                self.src_label2 = self.dst.canvas.create_text(
+                    *src_pos,
+                    text=text,
+                    justify=tk.CENTER,
+                    font=self.app.edge_font,
+                    tags=tags.LINK_LABEL,
+                    state=self.manager.show_link_labels.state(),
+                )
         else:
-            self.canvas.itemconfig(self.src_label, text=text)
+            if self.src_label:
+                self.src.canvas.itemconfig(self.src_label, text=text)
+            if self.src_label2:
+                self.dst.canvas.itemconfig(self.src_label2, text=text)
 
     def dst_label_text(self, text: str) -> None:
-        if self.dst_label is None:
-            _, dst_pos = self.node_label_positions()
-            self.dst_label = self.canvas.create_text(
-                *dst_pos,
-                text=text,
-                justify=tk.CENTER,
-                font=self.canvas.app.edge_font,
-                tags=tags.LINK_LABEL,
-                state=self.canvas.show_link_labels.state(),
-            )
+        if self.dst_label is None and self.dst_label2 is None:
+            if self.id:
+                src_x, src_y, _, _, dst_x, dst_y = self.src.canvas.coords(self.id)
+                _, dst_pos = node_label_positions(src_x, src_y, dst_x, dst_y)
+                self.dst_label = self.src.canvas.create_text(
+                    *dst_pos,
+                    text=text,
+                    justify=tk.CENTER,
+                    font=self.app.edge_font,
+                    tags=tags.LINK_LABEL,
+                    state=self.manager.show_link_labels.state(),
+                )
+            if self.id2:
+                src_x, src_y, _, _, dst_x, dst_y = self.dst.canvas.coords(self.id2)
+                _, dst_pos = node_label_positions(src_x, src_y, dst_x, dst_y)
+                self.dst_label2 = self.dst.canvas.create_text(
+                    *dst_pos,
+                    text=text,
+                    justify=tk.CENTER,
+                    font=self.app.edge_font,
+                    tags=tags.LINK_LABEL,
+                    state=self.manager.show_link_labels.state(),
+                )
         else:
-            self.canvas.itemconfig(self.dst_label, text=text)
+            if self.dst_label:
+                self.src.canvas.itemconfig(self.dst_label, text=text)
+            if self.dst_label2:
+                self.dst.canvas.itemconfig(self.dst_label2, text=text)
 
-    def move_node(self, node_id: int, pos: Tuple[float, float]) -> None:
-        if self.src == node_id:
-            self.move_src(pos)
-        else:
-            self.move_dst(pos)
-
-    def move_dst(self, dst_pos: Tuple[float, float]) -> None:
-        src_x, src_y, _, _, _, _ = self.canvas.coords(self.id)
+    def drawing(self, pos: Tuple[float, float]) -> None:
+        src_x, src_y, _, _, _, _ = self.src.canvas.coords(self.id)
         src_pos = src_x, src_y
-        self.moved(src_pos, dst_pos)
+        self.moved(src_pos, pos)
 
-    def move_src(self, src_pos: Tuple[float, float]) -> None:
-        _, _, _, _, dst_x, dst_y = self.canvas.coords(self.id)
+    def move_node(self, node: "CanvasNode") -> None:
+        if self.src == node:
+            self.move_src()
+        else:
+            self.move_dst()
+
+    def move_shadow(self, node: "ShadowNode") -> None:
+        if self.src_shadow == node:
+            self.move_src_shadow()
+        elif self.dst_shadow == node:
+            self.move_dst_shadow()
+
+    def move_src_shadow(self) -> None:
+        if not self.id2:
+            return
+        _, _, _, _, dst_x, dst_y = self.dst.canvas.coords(self.id2)
         dst_pos = dst_x, dst_y
-        self.moved(src_pos, dst_pos)
+        self.moved2(self.src_shadow.position(), dst_pos)
+
+    def move_dst_shadow(self) -> None:
+        if not self.id:
+            return
+        src_x, src_y, _, _, _, _ = self.src.canvas.coords(self.id)
+        src_pos = src_x, src_y
+        self.moved(src_pos, self.dst_shadow.position())
+
+    def move_dst(self) -> None:
+        if self.dst.is_wireless() and self.has_shadows():
+            return
+        dst_pos = self.dst.position()
+        if self.id2:
+            src_x, src_y, _, _, _, _ = self.dst.canvas.coords(self.id2)
+            src_pos = src_x, src_y
+            self.moved2(src_pos, dst_pos)
+        elif self.id:
+            src_x, src_y, _, _, _, _ = self.dst.canvas.coords(self.id)
+            src_pos = src_x, src_y
+            self.moved(src_pos, dst_pos)
+
+    def move_src(self) -> None:
+        if not self.id:
+            return
+        _, _, _, _, dst_x, dst_y = self.src.canvas.coords(self.id)
+        dst_pos = dst_x, dst_y
+        self.moved(self.src.position(), dst_pos)
 
     def moved(self, src_pos: Tuple[float, float], dst_pos: Tuple[float, float]) -> None:
         arc_pos = self._get_arcpoint(src_pos, dst_pos)
-        self.canvas.coords(self.id, *src_pos, *arc_pos, *dst_pos)
+        self.src.canvas.coords(self.id, *src_pos, *arc_pos, *dst_pos)
         if self.middle_label:
-            self.canvas.coords(self.middle_label, *arc_pos)
-        src_pos, dst_pos = self.node_label_positions()
+            self.src.canvas.coords(self.middle_label, *arc_pos)
+        src_x, src_y, _, _, dst_x, dst_y = self.src.canvas.coords(self.id)
+        src_pos, dst_pos = node_label_positions(src_x, src_y, dst_x, dst_y)
         if self.src_label:
-            self.canvas.coords(self.src_label, *src_pos)
+            self.src.canvas.coords(self.src_label, *src_pos)
         if self.dst_label:
-            self.canvas.coords(self.dst_label, *dst_pos)
+            self.src.canvas.coords(self.dst_label, *dst_pos)
+
+    def moved2(
+        self, src_pos: Tuple[float, float], dst_pos: Tuple[float, float]
+    ) -> None:
+        arc_pos = self._get_arcpoint(src_pos, dst_pos)
+        self.dst.canvas.coords(self.id2, *src_pos, *arc_pos, *dst_pos)
+        if self.middle_label2:
+            self.dst.canvas.coords(self.middle_label2, *arc_pos)
+        src_x, src_y, _, _, dst_x, dst_y = self.dst.canvas.coords(self.id2)
+        src_pos, dst_pos = node_label_positions(src_x, src_y, dst_x, dst_y)
+        if self.src_label2:
+            self.dst.canvas.coords(self.src_label2, *src_pos)
+        if self.dst_label2:
+            self.dst.canvas.coords(self.dst_label2, *dst_pos)
 
     def delete(self) -> None:
         logging.debug("deleting canvas edge, id: %s", self.id)
-        self.canvas.delete(self.id)
-        self.canvas.delete(self.src_label)
-        self.canvas.delete(self.dst_label)
+        self.src.canvas.delete(self.id)
+        self.src.canvas.delete(self.src_label)
+        self.src.canvas.delete(self.dst_label)
+        if self.dst:
+            self.dst.canvas.delete(self.id2)
+            self.dst.canvas.delete(self.src_label2)
+            self.dst.canvas.delete(self.dst_label2)
+        if self.src_shadow and self.src_shadow.should_delete():
+            self.src_shadow.delete()
+            self.src_shadow = None
+        if self.dst_shadow and self.dst_shadow.should_delete():
+            self.dst_shadow.delete()
+            self.dst_shadow = None
         self.clear_middle_label()
         self.id = None
+        self.id2 = None
         self.src_label = None
+        self.src_label2 = None
         self.dst_label = None
+        self.dst_label2 = None
+        self.manager.edges.pop(self.token, None)
+
+    def hide(self) -> None:
+        self.hidden = True
+        if self.src_shadow:
+            self.src_shadow.hide()
+        if self.dst_shadow:
+            self.dst_shadow.hide()
+        self.src.canvas.itemconfigure(self.id, state=tk.HIDDEN)
+        self.src.canvas.itemconfigure(self.src_label, state=tk.HIDDEN)
+        self.src.canvas.itemconfigure(self.dst_label, state=tk.HIDDEN)
+        self.src.canvas.itemconfigure(self.middle_label, state=tk.HIDDEN)
+        if self.id2:
+            self.dst.canvas.itemconfigure(self.id2, state=tk.HIDDEN)
+            self.dst.canvas.itemconfigure(self.src_label2, state=tk.HIDDEN)
+            self.dst.canvas.itemconfigure(self.dst_label2, state=tk.HIDDEN)
+            self.dst.canvas.itemconfigure(self.middle_label2, state=tk.HIDDEN)
+
+    def show(self) -> None:
+        self.hidden = False
+        if self.src_shadow:
+            self.src_shadow.show()
+        if self.dst_shadow:
+            self.dst_shadow.show()
+        self.src.canvas.itemconfigure(self.id, state=tk.NORMAL)
+        self.src.canvas.itemconfigure(self.src_label, state=tk.NORMAL)
+        self.src.canvas.itemconfigure(self.dst_label, state=tk.NORMAL)
+        self.src.canvas.itemconfigure(self.middle_label, state=tk.NORMAL)
+        if self.id2:
+            self.dst.canvas.itemconfigure(self.id2, state=tk.NORMAL)
+            self.dst.canvas.itemconfigure(self.src_label2, state=tk.NORMAL)
+            self.dst.canvas.itemconfigure(self.dst_label2, state=tk.NORMAL)
+            self.dst.canvas.itemconfigure(self.middle_label2, state=tk.NORMAL)
 
 
 class CanvasWirelessEdge(Edge):
@@ -239,35 +526,38 @@ class CanvasWirelessEdge(Edge):
 
     def __init__(
         self,
-        canvas: "CanvasGraph",
-        src: int,
-        dst: int,
+        app: "Application",
+        src: "CanvasNode",
+        dst: "CanvasNode",
         network_id: int,
         token: str,
-        src_pos: Tuple[float, float],
-        dst_pos: Tuple[float, float],
         link: Link,
     ) -> None:
         logging.debug("drawing wireless link from node %s to node %s", src, dst)
-        super().__init__(canvas, src, dst)
+        super().__init__(app, src, dst)
         self.network_id: int = network_id
         self.link: Link = link
         self.token: str = token
         self.width: float = WIRELESS_WIDTH
         color = link.color if link.color else WIRELESS_COLOR
         self.color: str = color
-        self.draw(src_pos, dst_pos, self.canvas.show_wireless.state())
+        self.draw(self.manager.show_wireless.state())
         if link.label:
             self.middle_label_text(link.label)
         self.set_binding()
 
     def set_binding(self) -> None:
-        self.canvas.tag_bind(self.id, "<Button-1>", self.show_info)
+        self.src.canvas.tag_bind(self.id, "<Button-1>", self.show_info)
+        if self.id2 is not None:
+            self.dst.canvas.tag_bind(self.id2, "<Button-1>", self.show_info)
 
     def show_info(self, _event: tk.Event) -> None:
-        self.canvas.app.display_info(
-            WirelessEdgeInfoFrame, app=self.canvas.app, edge=self
-        )
+        self.app.display_info(WirelessEdgeInfoFrame, app=self.app, edge=self)
+
+    def delete(self) -> None:
+        self.src.wireless_edges.discard(self)
+        self.dst.wireless_edges.remove(self)
+        super().delete()
 
 
 class CanvasEdge(Edge):
@@ -276,47 +566,40 @@ class CanvasEdge(Edge):
     """
 
     def __init__(
-        self,
-        canvas: "CanvasGraph",
-        src: int,
-        src_pos: Tuple[float, float],
-        dst_pos: Tuple[float, float],
+        self, app: "Application", src: "CanvasNode", dst: "CanvasNode" = None
     ) -> None:
         """
         Create an instance of canvas edge object
         """
-        super().__init__(canvas, src)
+        super().__init__(app, src, dst)
         self.text_src: Optional[int] = None
         self.text_dst: Optional[int] = None
         self.link: Optional[Link] = None
-        self.linked_wireless: bool = False
         self.asymmetric_link: Optional[Link] = None
         self.throughput: Optional[float] = None
-        self.draw(src_pos, dst_pos, tk.NORMAL)
-        self.set_binding()
-        self.context: tk.Menu = tk.Menu(self.canvas)
-        self.create_context()
+        self.draw(tk.NORMAL)
 
     def is_customized(self) -> bool:
         return self.width != EDGE_WIDTH or self.color != EDGE_COLOR
 
-    def create_context(self) -> None:
-        themes.style_menu(self.context)
-        self.context.add_command(label="Configure", command=self.click_configure)
-        self.context.add_command(label="Delete", command=self.click_delete)
-
-    def set_binding(self) -> None:
-        self.canvas.tag_bind(self.id, "<ButtonRelease-3>", self.show_context)
-        self.canvas.tag_bind(self.id, "<Button-1>", self.show_info)
+    def set_bindings(self) -> None:
+        if self.id:
+            show_context = functools.partial(self.show_context, self.src.canvas)
+            self.src.canvas.tag_bind(self.id, "<ButtonRelease-3>", show_context)
+            self.src.canvas.tag_bind(self.id, "<Button-1>", self.show_info)
+        if self.id2:
+            show_context = functools.partial(self.show_context, self.dst.canvas)
+            self.dst.canvas.tag_bind(self.id2, "<ButtonRelease-3>", show_context)
+            self.dst.canvas.tag_bind(self.id2, "<Button-1>", self.show_info)
 
     def iface_label(self, iface: Interface) -> str:
         label = ""
-        if iface.name and self.canvas.show_iface_names.get():
+        if iface.name and self.manager.show_iface_names.get():
             label = f"{iface.name}"
-        if iface.ip4 and self.canvas.show_ip4s.get():
+        if iface.ip4 and self.manager.show_ip4s.get():
             label = f"{label}\n" if label else ""
             label += f"{iface.ip4}/{iface.ip4_mask}"
-        if iface.ip6 and self.canvas.show_ip6s.get():
+        if iface.ip6 and self.manager.show_ip6s.get():
             label = f"{label}\n" if label else ""
             label += f"{iface.ip6}/{iface.ip6_mask}"
         return label
@@ -346,77 +629,98 @@ class CanvasEdge(Edge):
             return
         if self.link.options.loss == EDGE_LOSS:
             state = tk.HIDDEN
-            self.canvas.addtag_withtag(tags.LOSS_EDGES, self.id)
+            if self.id:
+                self.src.canvas.addtag_withtag(tags.LOSS_EDGES, self.id)
+            if self.id2:
+                self.dst.canvas.addtag_withtag(tags.LOSS_EDGES, self.id2)
         else:
             state = tk.NORMAL
-            self.canvas.dtag(self.id, tags.LOSS_EDGES)
-        if self.canvas.show_loss_links.state() == tk.HIDDEN:
-            self.canvas.itemconfigure(self.id, state=state)
+            if self.id:
+                self.src.canvas.dtag(self.id, tags.LOSS_EDGES)
+            if self.id2:
+                self.dst.canvas.dtag(self.id2, tags.LOSS_EDGES)
+        if self.manager.show_loss_links.state() == tk.HIDDEN:
+            if self.id:
+                self.src.canvas.itemconfigure(self.id, state=state)
+            if self.id2:
+                self.dst.canvas.itemconfigure(self.id2, state=state)
 
     def set_throughput(self, throughput: float) -> None:
         throughput = 0.001 * throughput
         text = f"{throughput:.3f} kbps"
         self.middle_label_text(text)
-        if throughput > self.canvas.throughput_threshold:
-            color = self.canvas.throughput_color
-            width = self.canvas.throughput_width
+        if throughput > self.manager.throughput_threshold:
+            color = self.manager.throughput_color
+            width = self.manager.throughput_width
         else:
             color = self.color
             width = self.scaled_width()
-        self.canvas.itemconfig(self.id, fill=color, width=width)
+        self.src.canvas.itemconfig(self.id, fill=color, width=width)
+        if self.id2:
+            self.dst.canvas.itemconfig(self.id2, fill=color, width=width)
 
     def clear_throughput(self) -> None:
         self.clear_middle_label()
         if not self.linked_wireless:
             self.draw_link_options()
 
-    def complete(self, dst: int, linked_wireless: bool) -> None:
+    def complete(self, dst: "CanvasNode") -> None:
         self.dst = dst
-        self.linked_wireless = linked_wireless
-        dst_pos = self.canvas.coords(self.dst)
-        self.move_dst(dst_pos)
+        self.linked_wireless = self.src.is_wireless() or self.dst.is_wireless()
+        self.set_bindings()
         self.check_wireless()
         logging.debug("draw wired link from node %s to node %s", self.src, dst)
 
     def check_wireless(self) -> None:
-        if self.linked_wireless:
-            self.canvas.itemconfig(self.id, state=tk.HIDDEN)
-            self.canvas.dtag(self.id, tags.EDGE)
-            self._check_antenna()
-
-    def _check_antenna(self) -> None:
-        src_node = self.canvas.nodes[self.src]
-        dst_node = self.canvas.nodes[self.dst]
-        src_node_type = src_node.core_node.type
-        dst_node_type = dst_node.core_node.type
-        is_src_wireless = NodeUtils.is_wireless_node(src_node_type)
-        is_dst_wireless = NodeUtils.is_wireless_node(dst_node_type)
-        if is_src_wireless or is_dst_wireless:
-            if is_src_wireless and not is_dst_wireless:
-                dst_node.add_antenna()
-            elif not is_src_wireless and is_dst_wireless:
-                src_node.add_antenna()
-            else:
-                src_node.add_antenna()
+        if not self.linked_wireless:
+            return
+        if self.id:
+            self.src.canvas.itemconfig(self.id, state=tk.HIDDEN)
+            self.src.canvas.dtag(self.id, tags.EDGE)
+        if self.id2:
+            self.dst.canvas.itemconfig(self.id2, state=tk.HIDDEN)
+            self.dst.canvas.dtag(self.id2, tags.EDGE)
+        # add antenna to node
+        if self.src.is_wireless() and not self.dst.is_wireless():
+            self.dst.add_antenna()
+        elif not self.src.is_wireless() and self.dst.is_wireless():
+            self.src.add_antenna()
+        else:
+            self.src.add_antenna()
 
     def reset(self) -> None:
-        self.canvas.delete(self.middle_label)
-        self.middle_label = None
-        self.canvas.itemconfig(self.id, fill=self.color, width=self.scaled_width())
+        if self.middle_label:
+            self.src.canvas.delete(self.middle_label)
+            self.middle_label = None
+        if self.middle_label2:
+            self.dst.canvas.delete(self.middle_label2)
+            self.middle_label2 = None
+        if self.id:
+            self.src.canvas.itemconfig(
+                self.id, fill=self.color, width=self.scaled_width()
+            )
+        if self.id2:
+            self.dst.canvas.itemconfig(
+                self.id2, fill=self.color, width=self.scaled_width()
+            )
 
     def show_info(self, _event: tk.Event) -> None:
-        self.canvas.app.display_info(EdgeInfoFrame, app=self.canvas.app, edge=self)
+        self.app.display_info(EdgeInfoFrame, app=self.app, edge=self)
 
-    def show_context(self, event: tk.Event) -> None:
-        state = tk.DISABLED if self.canvas.core.is_runtime() else tk.NORMAL
-        self.context.entryconfigure(1, state=state)
-        self.context.tk_popup(event.x_root, event.y_root)
+    def show_context(self, canvas: "CanvasGraph", event: tk.Event) -> None:
+        context: tk.Menu = tk.Menu(canvas)
+        themes.style_menu(context)
+        context.add_command(label="Configure", command=self.click_configure)
+        context.add_command(label="Delete", command=self.click_delete)
+        state = tk.DISABLED if self.app.core.is_runtime() else tk.NORMAL
+        context.entryconfigure(1, state=state)
+        context.tk_popup(event.x_root, event.y_root)
 
     def click_delete(self) -> None:
-        self.canvas.delete_edge(self)
+        self.delete()
 
     def click_configure(self) -> None:
-        dialog = LinkConfigurationDialog(self.canvas.app, self)
+        dialog = LinkConfigurationDialog(self.app, self)
         dialog.show()
 
     def draw_link_options(self):
@@ -455,3 +759,20 @@ class CanvasEdge(Edge):
             lines.append(dup_line)
         label = "\n".join(lines)
         self.middle_label_text(label)
+
+    def delete(self) -> None:
+        self.src.edges.discard(self)
+        if self.dst:
+            self.dst.edges.discard(self)
+            if self.link.iface1:
+                del self.src.ifaces[self.link.iface1.id]
+            if self.link.iface2:
+                del self.dst.ifaces[self.link.iface2.id]
+            if self.src.is_wireless():
+                self.dst.delete_antenna()
+            if self.dst.is_wireless():
+                self.src.delete_antenna()
+            self.app.core.deleted_canvas_edges([self])
+        super().delete()
+        if self.dst:
+            self.arc_common_edges()
diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py
index 9862d2c8..8a66fecc 100644
--- a/daemon/core/gui/graph/graph.py
+++ b/daemon/core/gui/graph/graph.py
@@ -1,79 +1,59 @@
 import logging
 import tkinter as tk
 from copy import deepcopy
-from tkinter import BooleanVar
-from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple
 
 from PIL import Image
 from PIL.ImageTk import PhotoImage
 
-from core.api.grpc.wrappers import (
-    Interface,
-    Link,
-    LinkType,
-    Node,
-    Session,
-    ThroughputsEvent,
-)
+from core.api.grpc.wrappers import Interface, Link
+from core.gui import appconfig
 from core.gui.dialogs.shapemod import ShapeDialog
 from core.gui.graph import tags
-from core.gui.graph.edges import (
-    EDGE_WIDTH,
-    CanvasEdge,
-    CanvasWirelessEdge,
-    Edge,
-    arc_edges,
-    create_edge_token,
-    create_wireless_token,
-)
+from core.gui.graph.edges import EDGE_WIDTH, CanvasEdge, ShadowNode
 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 ImageEnum, TypeToImage
-from core.gui.nodeutils import NodeDraw, NodeUtils
+from core.gui.images import TypeToImage
+from core.gui.nodeutils import NodeUtils
 
 if TYPE_CHECKING:
     from core.gui.app import Application
+    from core.gui.graph.manager import CanvasManager
     from core.gui.coreclient import CoreClient
 
-ZOOM_IN = 1.1
-ZOOM_OUT = 0.9
-ICON_SIZE = 48
-MOVE_NODE_MODES = {GraphMode.NODE, GraphMode.SELECT}
-MOVE_SHAPE_MODES = {GraphMode.ANNOTATION, GraphMode.SELECT}
-
-
-class ShowVar(BooleanVar):
-    def __init__(self, canvas: "CanvasGraph", tag: str, value: bool) -> None:
-        super().__init__(value=value)
-        self.canvas = canvas
-        self.tag = tag
-
-    def state(self) -> str:
-        return tk.NORMAL if self.get() else tk.HIDDEN
-
-    def click_handler(self) -> None:
-        self.canvas.itemconfigure(self.tag, state=self.state())
+ZOOM_IN: float = 1.1
+ZOOM_OUT: float = 0.9
+ICON_SIZE: int = 48
+MOVE_NODE_MODES: Set[GraphMode] = {GraphMode.NODE, GraphMode.SELECT}
+MOVE_SHAPE_MODES: Set[GraphMode] = {GraphMode.ANNOTATION, GraphMode.SELECT}
+BACKGROUND_COLOR: str = "#cccccc"
 
 
 class CanvasGraph(tk.Canvas):
     def __init__(
-        self, master: tk.BaseWidget, app: "Application", core: "CoreClient"
+        self,
+        master: tk.BaseWidget,
+        app: "Application",
+        manager: "CanvasManager",
+        core: "CoreClient",
+        _id: int,
+        dimensions: Tuple[int, int],
     ) -> None:
-        super().__init__(master, highlightthickness=0, background="#cccccc")
+        super().__init__(master, highlightthickness=0, background=BACKGROUND_COLOR)
+        self.id: int = _id
         self.app: "Application" = app
+        self.manager: "CanvasManager" = manager
         self.core: "CoreClient" = core
-        self.mode: GraphMode = GraphMode.SELECT
-        self.annotation_type: Optional[ShapeType] = None
         self.selection: Dict[int, int] = {}
         self.select_box: Optional[Shape] = None
         self.selected: Optional[int] = None
-        self.node_draw: Optional[NodeDraw] = None
         self.nodes: Dict[int, CanvasNode] = {}
-        self.edges: Dict[str, CanvasEdge] = {}
+        self.shadow_nodes: Dict[int, ShadowNode] = {}
         self.shapes: Dict[int, Shape] = {}
-        self.wireless_edges: Dict[str, CanvasWirelessEdge] = {}
+        self.shadow_core_nodes: Dict[int, ShadowNode] = {}
 
         # map wireless/EMANE node to the set of MDRs connected to that node
         self.wireless_network: Dict[int, Set[int]] = {}
@@ -81,10 +61,7 @@ class CanvasGraph(tk.Canvas):
         self.drawing_edge: Optional[CanvasEdge] = None
         self.rect: Optional[int] = None
         self.shape_drawing: bool = False
-        width = self.app.guiconfig.preferences.width
-        height = self.app.guiconfig.preferences.height
-        self.default_dimensions: Tuple[int, int] = (width, height)
-        self.current_dimensions: Tuple[int, int] = self.default_dimensions
+        self.current_dimensions: Tuple[int, int] = dimensions
         self.ratio: float = 1.0
         self.offset: Tuple[int, int] = (0, 0)
         self.cursor: Tuple[int, int] = (0, 0)
@@ -98,23 +75,6 @@ class CanvasGraph(tk.Canvas):
         self.scale_option: tk.IntVar = tk.IntVar(value=1)
         self.adjust_to_dim: tk.BooleanVar = tk.BooleanVar(value=False)
 
-        # throughput related
-        self.throughput_threshold: float = 250.0
-        self.throughput_width: int = 10
-        self.throughput_color: str = "#FF0000"
-
-        # drawing related
-        self.show_node_labels: ShowVar = ShowVar(self, tags.NODE_LABEL, value=True)
-        self.show_link_labels: ShowVar = ShowVar(self, tags.LINK_LABEL, value=True)
-        self.show_links: ShowVar = ShowVar(self, tags.EDGE, value=True)
-        self.show_wireless: ShowVar = ShowVar(self, tags.WIRELESS_EDGE, value=True)
-        self.show_grid: ShowVar = ShowVar(self, tags.GRIDLINE, value=True)
-        self.show_annotations: ShowVar = ShowVar(self, tags.ANNOTATION, value=True)
-        self.show_loss_links: ShowVar = ShowVar(self, tags.LOSS_EDGES, value=True)
-        self.show_iface_names: BooleanVar = BooleanVar(value=False)
-        self.show_ip4s: BooleanVar = BooleanVar(value=True)
-        self.show_ip6s: BooleanVar = BooleanVar(value=True)
-
         # bindings
         self.setup_bindings()
 
@@ -126,7 +86,7 @@ class CanvasGraph(tk.Canvas):
         if self.rect is not None:
             self.delete(self.rect)
         if not dimensions:
-            dimensions = self.default_dimensions
+            dimensions = self.manager.default_dimensions
         self.current_dimensions = dimensions
         self.rect = self.create_rectangle(
             0,
@@ -139,34 +99,6 @@ class CanvasGraph(tk.Canvas):
         )
         self.configure(scrollregion=self.bbox(tk.ALL))
 
-    def reset_and_redraw(self, session: Session) -> None:
-        # reset view options to default state
-        self.show_node_labels.set(True)
-        self.show_link_labels.set(True)
-        self.show_grid.set(True)
-        self.show_annotations.set(True)
-        self.show_iface_names.set(False)
-        self.show_ip4s.set(True)
-        self.show_ip6s.set(True)
-        self.show_loss_links.set(True)
-
-        # delete any existing drawn items
-        for tag in tags.RESET_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.wireless_network.clear()
-        self.drawing_edge = None
-        self.draw_session(session)
-
     def setup_bindings(self) -> None:
         """
         Bind any mouse events or hot keys to the matching action
@@ -183,6 +115,12 @@ class CanvasGraph(tk.Canvas):
         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))
 
+    def get_shadow(self, node: CanvasNode) -> ShadowNode:
+        shadow_node = self.shadow_core_nodes.get(node.core_node.id)
+        if not shadow_node:
+            shadow_node = ShadowNode(self.app, self, node)
+        return shadow_node
+
     def get_actual_coords(self, x: float, y: float) -> Tuple[float, float]:
         actual_x = (x - self.offset[0]) / self.ratio
         actual_y = (y - self.offset[1]) / self.ratio
@@ -204,16 +142,6 @@ class CanvasGraph(tk.Canvas):
         valid_bottomright = self.inside_canvas(x2, y2)
         return valid_topleft and valid_bottomright
 
-    def set_throughputs(self, throughputs_event: ThroughputsEvent) -> None:
-        for iface_throughput in throughputs_event.iface_throughputs:
-            node_id = iface_throughput.node_id
-            iface_id = iface_throughput.iface_id
-            throughput = iface_throughput.throughput
-            iface_to_edge_id = (node_id, iface_id)
-            edge = self.core.iface_to_edge.get(iface_to_edge_id)
-            if edge:
-                edge.set_throughput(throughput)
-
     def draw_grid(self) -> None:
         """
         Create grid.
@@ -228,123 +156,6 @@ class CanvasGraph(tk.Canvas):
         self.tag_lower(tags.GRIDLINE)
         self.tag_lower(self.rect)
 
-    def add_wired_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None:
-        token = create_edge_token(link)
-        if token in self.edges and link.options.unidirectional:
-            edge = self.edges[token]
-            edge.asymmetric_link = link
-        elif token not in self.edges:
-            node1 = src.core_node
-            node2 = dst.core_node
-            src_pos = (node1.position.x, node1.position.y)
-            dst_pos = (node2.position.x, node2.position.y)
-            edge = CanvasEdge(self, src.id, src_pos, dst_pos)
-            self.complete_edge(src, dst, edge, link)
-
-    def delete_wired_edge(self, link: Link) -> None:
-        token = create_edge_token(link)
-        edge = self.edges.get(token)
-        if edge:
-            self.delete_edge(edge)
-
-    def update_wired_edge(self, link: Link) -> None:
-        token = create_edge_token(link)
-        edge = self.edges.get(token)
-        if edge:
-            edge.link.options = deepcopy(link.options)
-            edge.draw_link_options()
-            edge.check_options()
-
-    def add_wireless_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None:
-        network_id = link.network_id if link.network_id else None
-        token = create_wireless_token(src.id, dst.id, network_id)
-        if token in self.wireless_edges:
-            logging.warning("ignoring link that already exists: %s", link)
-            return
-        src_pos = self.coords(src.id)
-        dst_pos = self.coords(dst.id)
-        edge = CanvasWirelessEdge(
-            self, src.id, dst.id, network_id, token, src_pos, dst_pos, link
-        )
-        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)
-        self.arc_common_edges(edge)
-
-    def delete_wireless_edge(
-        self, src: CanvasNode, dst: CanvasNode, link: Link
-    ) -> None:
-        network_id = link.network_id if link.network_id else None
-        token = create_wireless_token(src.id, dst.id, network_id)
-        if token not in self.wireless_edges:
-            return
-        edge = self.wireless_edges.pop(token)
-        edge.delete()
-        src.wireless_edges.remove(edge)
-        dst.wireless_edges.remove(edge)
-        self.arc_common_edges(edge)
-
-    def update_wireless_edge(
-        self, src: CanvasNode, dst: CanvasNode, link: Link
-    ) -> None:
-        if not link.label:
-            return
-        network_id = link.network_id if link.network_id else None
-        token = create_wireless_token(src.id, dst.id, network_id)
-        if token not in self.wireless_edges:
-            self.add_wireless_edge(src, dst, link)
-        else:
-            edge = self.wireless_edges[token]
-            edge.middle_label_text(link.label)
-
-    def add_core_node(self, core_node: Node) -> None:
-        logging.debug("adding node: %s", core_node)
-        # if the gui can't find node's image, default to the "edit-node" image
-        image = NodeUtils.node_image(core_node, self.app.guiconfig, self.app.app_scale)
-        if not image:
-            image = self.app.get_icon(ImageEnum.EDITNODE, ICON_SIZE)
-        x = core_node.position.x
-        y = core_node.position.y
-        node = CanvasNode(self.app, x, y, core_node, image)
-        self.nodes[node.id] = node
-        self.core.set_canvas_node(core_node, node)
-
-    def draw_session(self, session: Session) -> None:
-        """
-        Draw existing session.
-        """
-        # draw existing nodes
-        for core_node in session.nodes.values():
-            logging.debug("drawing node: %s", core_node)
-            # peer to peer node is not drawn on the GUI
-            if NodeUtils.is_ignore_node(core_node.type):
-                continue
-            self.add_core_node(core_node)
-        # draw existing links
-        for link in session.links:
-            logging.debug("drawing link: %s", link)
-            canvas_node1 = self.core.get_canvas_node(link.node1_id)
-            canvas_node2 = self.core.get_canvas_node(link.node2_id)
-            if link.type == LinkType.WIRELESS:
-                self.add_wireless_edge(canvas_node1, canvas_node2, link)
-            else:
-                self.add_wired_edge(canvas_node1, canvas_node2, link)
-
-    def stopped_session(self) -> None:
-        # 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 throughputs
-        self.clear_throughputs()
-
     def canvas_xy(self, event: tk.Event) -> Tuple[float, float]:
         """
         Convert window coordinate to canvas coordinate
@@ -363,14 +174,12 @@ class CanvasGraph(tk.Canvas):
         for _id in overlapping:
             if self.drawing_edge and self.drawing_edge.id == _id:
                 continue
-
-            if _id in self.nodes:
+            elif _id in self.nodes:
                 selected = _id
-                break
-
-            if _id in self.shapes:
+            elif _id in self.shapes:
+                selected = _id
+            elif _id in self.shadow_nodes:
                 selected = _id
-
         return selected
 
     def click_release(self, event: tk.Event) -> None:
@@ -381,13 +190,13 @@ class CanvasGraph(tk.Canvas):
         x, y = self.canvas_xy(event)
         if not self.inside_canvas(x, y):
             return
-        if self.mode == GraphMode.ANNOTATION:
+        if self.manager.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:
+        elif self.manager.mode == GraphMode.SELECT:
             self.focus_set()
             if self.select_box:
                 x0, y0, x1, y1 = self.coords(self.select_box.id)
@@ -403,61 +212,35 @@ class CanvasGraph(tk.Canvas):
         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:
+            logging.debug(
+                "click release selected(%s) mode(%s)", self.selected, self.manager.mode
+            )
+            if self.manager.mode == GraphMode.EDGE:
                 self.handle_edge_release(event)
-            elif self.mode == GraphMode.NODE:
+            elif self.manager.mode == GraphMode.NODE:
                 self.add_node(x, y)
-            elif self.mode == GraphMode.PICKNODE:
-                self.mode = GraphMode.NODE
+            elif self.manager.mode == GraphMode.PICKNODE:
+                self.manager.mode = GraphMode.NODE
         self.selected = None
 
     def handle_edge_release(self, _event: tk.Event) -> None:
+        # not drawing edge return
+        if not self.drawing_edge:
+            return
         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)
-        src_node = self.nodes.get(edge.src)
         dst_node = self.nodes.get(self.selected)
-        if not dst_node or not src_node:
+        if not dst_node:
             edge.delete()
             return
-
-        # edge dst is same as src, delete edge
-        if edge.src == self.selected:
+        # check if node can be linked
+        if not edge.src.is_linkable(dst_node):
             edge.delete()
             return
-
-        # rj45 nodes can only support one link
-        if NodeUtils.is_rj45_node(src_node.core_node.type) and src_node.edges:
-            edge.delete()
-            return
-        if NodeUtils.is_rj45_node(dst_node.core_node.type) and dst_node.edges:
-            edge.delete()
-            return
-
-        # only 1 link between bridge based nodes
-        is_src_bridge = NodeUtils.is_bridge_node(src_node.core_node)
-        is_dst_bridge = NodeUtils.is_bridge_node(dst_node.core_node)
-        common_links = src_node.edges & dst_node.edges
-        if all([is_src_bridge, is_dst_bridge, common_links]):
-            edge.delete()
-            return
-
         # finalize edge creation
-        self.complete_edge(src_node, dst_node, edge)
-
-    def arc_common_edges(self, edge: Edge) -> None:
-        src_node = self.nodes[edge.src]
-        dst_node = self.nodes[edge.dst]
-        common_edges = list(src_node.edges & dst_node.edges)
-        common_edges += list(src_node.wireless_edges & dst_node.wireless_edges)
-        arc_edges(common_edges)
+        self.manager.complete_edge(edge, dst_node)
 
     def select_object(self, object_id: int, choose_multiple: bool = False) -> None:
         """
@@ -504,28 +287,16 @@ class CanvasGraph(tk.Canvas):
             # 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:
+                while canvas_node.edges:
+                    edge = canvas_node.edges.pop()
                     if edge in edges:
                         continue
                     edges.add(edge)
-                    del self.edges[edge.token]
                     edge.delete()
-                    # update node connected to edge being deleted
-                    other_id = edge.src
-                    other_iface = edge.link.iface1
-                    if edge.src == object_id:
-                        other_id = edge.dst
-                        other_iface = edge.link.iface2
-                    other_node = self.nodes[other_id]
-                    other_node.edges.remove(edge)
-                    if other_iface:
-                        del other_node.ifaces[other_iface.id]
-                    if is_wireless:
-                        other_node.delete_antenna()
+                # delete node
+                canvas_node.delete()
+                nodes.append(canvas_node)
 
             # delete shape
             if object_id in self.shapes:
@@ -534,27 +305,21 @@ class CanvasGraph(tk.Canvas):
 
         self.selection.clear()
         self.core.deleted_canvas_nodes(nodes)
-        self.core.deleted_canvas_edges(edges)
 
-    def delete_edge(self, edge: CanvasEdge) -> None:
-        edge.delete()
-        del self.edges[edge.token]
-        src_node = self.nodes[edge.src]
-        src_node.edges.discard(edge)
-        if edge.link.iface1:
-            del src_node.ifaces[edge.link.iface1.id]
-        dst_node = self.nodes[edge.dst]
-        dst_node.edges.discard(edge)
-        if edge.link.iface2:
-            del dst_node.ifaces[edge.link.iface2.id]
-        src_wireless = NodeUtils.is_wireless_node(src_node.core_node.type)
-        if src_wireless:
-            dst_node.delete_antenna()
-        dst_wireless = NodeUtils.is_wireless_node(dst_node.core_node.type)
-        if dst_wireless:
-            src_node.delete_antenna()
-        self.core.deleted_canvas_edges([edge])
-        self.arc_common_edges(edge)
+    def hide_selected_objects(self) -> None:
+        for object_id in self.selection:
+            #  delete selection box
+            selection_id = self.selection[object_id]
+            self.delete(selection_id)
+            # hide node and related edges
+            if object_id in self.nodes:
+                canvas_node = self.nodes[object_id]
+                canvas_node.hide()
+
+    def show_hidden(self) -> None:
+        for node in self.nodes.values():
+            if node.hidden:
+                node.show()
 
     def zoom(self, event: tk.Event, factor: float = None) -> None:
         if not factor:
@@ -588,13 +353,13 @@ class CanvasGraph(tk.Canvas):
         y_check = self.cursor[1] - self.offset[1]
         logging.debug("click press offset(%s, %s)", x_check, y_check)
         is_node = selected in self.nodes
-        if self.mode == GraphMode.EDGE and is_node:
-            pos = self.coords(selected)
-            self.drawing_edge = CanvasEdge(self, selected, pos, pos)
+        if self.manager.mode == GraphMode.EDGE and is_node:
+            node = self.nodes[selected]
+            self.drawing_edge = CanvasEdge(self.app, node)
             self.organize()
 
-        if self.mode == GraphMode.ANNOTATION:
-            if is_marker(self.annotation_type):
+        if self.manager.mode == GraphMode.ANNOTATION:
+            if is_marker(self.manager.annotation_type):
                 r = self.app.toolbar.marker_frame.size.get()
                 self.create_oval(
                     x - r,
@@ -604,11 +369,11 @@ class CanvasGraph(tk.Canvas):
                     fill=self.app.toolbar.marker_frame.color,
                     outline="",
                     tags=(tags.MARKER, tags.ANNOTATION),
-                    state=self.show_annotations.state(),
+                    state=self.manager.show_annotations.state(),
                 )
                 return
             if selected is None:
-                shape = Shape(self.app, self, self.annotation_type, x, y)
+                shape = Shape(self.app, self, self.manager.annotation_type, x, y)
                 self.selected = shape.id
                 self.shape_drawing = True
                 self.shapes[shape.id] = shape
@@ -629,8 +394,18 @@ class CanvasGraph(tk.Canvas):
                         node.core_node.position.x,
                         node.core_node.position.y,
                     )
+                elif selected in self.shadow_nodes:
+                    shadow_node = self.shadow_nodes[selected]
+                    self.select_object(shadow_node.id)
+                    self.selected = selected
+                    logging.debug(
+                        "selected shadow node(%s), coords: (%s, %s)",
+                        shadow_node.node.core_node.name,
+                        shadow_node.node.core_node.position.x,
+                        shadow_node.node.core_node.position.y,
+                    )
         else:
-            if self.mode == GraphMode.SELECT:
+            if self.manager.mode == GraphMode.SELECT:
                 shape = Shape(self.app, self, ShapeType.RECTANGLE, x, y)
                 self.select_box = shape
             self.clear_selection()
@@ -659,7 +434,7 @@ class CanvasGraph(tk.Canvas):
             if self.select_box:
                 self.select_box.delete()
                 self.select_box = None
-            if is_draw_shape(self.annotation_type) and self.shape_drawing:
+            if is_draw_shape(self.manager.annotation_type) and self.shape_drawing:
                 shape = self.shapes.pop(self.selected)
                 shape.delete()
                 self.shape_drawing = False
@@ -669,14 +444,14 @@ class CanvasGraph(tk.Canvas):
         y_offset = y - self.cursor[1]
         self.cursor = x, y
 
-        if self.mode == GraphMode.EDGE and self.drawing_edge is not None:
-            self.drawing_edge.move_dst(self.cursor)
-        if self.mode == GraphMode.ANNOTATION:
-            if is_draw_shape(self.annotation_type) and self.shape_drawing:
+        if self.manager.mode == GraphMode.EDGE and self.drawing_edge is not None:
+            self.drawing_edge.drawing(self.cursor)
+        if self.manager.mode == GraphMode.ANNOTATION:
+            if is_draw_shape(self.manager.annotation_type) and self.shape_drawing:
                 shape = self.shapes[self.selected]
                 shape.shape_motion(x, y)
                 return
-            elif is_marker(self.annotation_type):
+            elif is_marker(self.manager.annotation_type):
                 r = self.app.toolbar.marker_frame.size.get()
                 self.create_oval(
                     x - r,
@@ -689,21 +464,26 @@ class CanvasGraph(tk.Canvas):
                 )
                 return
 
-        if self.mode == GraphMode.EDGE:
+        if self.manager.mode == GraphMode.EDGE:
             return
 
         # move selected objects
         if self.selection:
             for selected_id in self.selection:
-                if self.mode in MOVE_SHAPE_MODES and selected_id in self.shapes:
+                if self.manager.mode in MOVE_SHAPE_MODES and selected_id in self.shapes:
                     shape = self.shapes[selected_id]
                     shape.motion(x_offset, y_offset)
-
-                if self.mode in MOVE_NODE_MODES and selected_id in self.nodes:
+                elif self.manager.mode in MOVE_NODE_MODES and selected_id in self.nodes:
                     node = self.nodes[selected_id]
                     node.motion(x_offset, y_offset, update=self.core.is_runtime())
+                elif (
+                    self.manager.mode in MOVE_NODE_MODES
+                    and selected_id in self.shadow_nodes
+                ):
+                    shadow_node = self.shadow_nodes[selected_id]
+                    shadow_node.motion(x_offset, y_offset)
         else:
-            if self.select_box and self.mode == GraphMode.SELECT:
+            if self.select_box and self.manager.mode == GraphMode.SELECT:
                 self.select_box.shape_motion(x, y)
 
     def press_delete(self, _event: tk.Event) -> None:
@@ -729,17 +509,23 @@ class CanvasGraph(tk.Canvas):
             return
         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
+            actual_x,
+            actual_y,
+            self.manager.node_draw.node_type,
+            self.manager.node_draw.model,
         )
         if not core_node:
             return
+        core_node.canvas = self.id
         try:
-            image_enum = self.node_draw.image_enum
-            self.node_draw.image = self.app.get_icon(image_enum, ICON_SIZE)
+            image_enum = self.manager.node_draw.image_enum
+            self.manager.node_draw.image = self.app.get_icon(image_enum, ICON_SIZE)
         except AttributeError:
-            image_file = self.node_draw.image_file
-            self.node_draw.image = self.app.get_custom_icon(image_file, ICON_SIZE)
-        node = CanvasNode(self.app, x, y, core_node, self.node_draw.image)
+            image_file = self.manager.node_draw.image_file
+            self.manager.node_draw.image = self.app.get_custom_icon(
+                image_file, ICON_SIZE
+            )
+        node = CanvasNode(self.app, self, x, y, core_node, self.manager.node_draw.image)
         self.nodes[node.id] = node
         self.core.set_canvas_node(core_node, node)
 
@@ -847,7 +633,7 @@ class CanvasGraph(tk.Canvas):
         # redraw gridlines to new canvas size
         self.delete(tags.GRIDLINE)
         self.draw_grid()
-        self.app.canvas.show_grid.click_handler()
+        self.app.manager.show_grid.click_handler()
 
     def redraw_wallpaper(self) -> None:
         if self.adjust_to_dim.get():
@@ -871,7 +657,7 @@ class CanvasGraph(tk.Canvas):
             self.tag_raise(tag)
 
     def set_wallpaper(self, filename: Optional[str]) -> None:
-        logging.debug("setting wallpaper: %s", filename)
+        logging.info("setting canvas(%s) background: %s", self.id, filename)
         if filename:
             img = Image.open(filename)
             self.wallpaper = img
@@ -884,44 +670,16 @@ class CanvasGraph(tk.Canvas):
             self.wallpaper_file = None
 
     def is_selection_mode(self) -> bool:
-        return self.mode == GraphMode.SELECT
+        return self.manager.mode == GraphMode.SELECT
 
     def create_edge(self, src: CanvasNode, dst: CanvasNode) -> CanvasEdge:
         """
         create an edge between source node and destination node
         """
-        pos = (src.core_node.position.x, src.core_node.position.y)
-        edge = CanvasEdge(self, src.id, pos, pos)
-        self.complete_edge(src, dst, edge)
+        edge = CanvasEdge(self.app, src)
+        self.manager.complete_edge(edge, dst)
         return edge
 
-    def complete_edge(
-        self,
-        src: CanvasNode,
-        dst: CanvasNode,
-        edge: CanvasEdge,
-        link: Optional[Link] = None,
-    ) -> None:
-        linked_wireless = self.is_linked_wireless(src.id, dst.id)
-        edge.complete(dst.id, linked_wireless)
-        if link is None:
-            link = self.core.create_link(edge, src, dst)
-        edge.link = link
-        if link.iface1:
-            iface1 = link.iface1
-            src.ifaces[iface1.id] = iface1
-        if link.iface2:
-            iface2 = link.iface2
-            dst.ifaces[iface2.id] = iface2
-        src.edges.add(edge)
-        dst.edges.add(edge)
-        edge.token = create_edge_token(edge.link)
-        self.arc_common_edges(edge)
-        edge.draw_labels()
-        edge.check_options()
-        self.edges[edge.token] = edge
-        self.core.save_edge(edge, src, dst)
-
     def copy(self) -> None:
         if self.core.is_runtime():
             logging.debug("copy is disabled during runtime state")
@@ -952,7 +710,9 @@ class CanvasGraph(tk.Canvas):
             )
             if not copy:
                 continue
-            node = CanvasNode(self.app, scaled_x, scaled_y, copy, canvas_node.image)
+            node = CanvasNode(
+                self.app, self, scaled_x, scaled_y, copy, canvas_node.image
+            )
             # copy configurations and services
             node.core_node.services = core_node.services.copy()
             node.core_node.config_services = core_node.config_services.copy()
@@ -1039,30 +799,6 @@ class CanvasGraph(tk.Canvas):
             )
         self.tag_raise(tags.NODE)
 
-    def is_linked_wireless(self, src: int, dst: int) -> bool:
-        src_node = self.nodes[src]
-        dst_node = self.nodes[dst]
-        src_node_type = src_node.core_node.type
-        dst_node_type = dst_node.core_node.type
-        is_src_wireless = NodeUtils.is_wireless_node(src_node_type)
-        is_dst_wireless = NodeUtils.is_wireless_node(dst_node_type)
-
-        # update the wlan/EMANE network
-        wlan_network = self.wireless_network
-        if is_src_wireless and not is_dst_wireless:
-            if src not in wlan_network:
-                wlan_network[src] = set()
-            wlan_network[src].add(dst)
-        elif not is_src_wireless and is_dst_wireless:
-            if dst not in wlan_network:
-                wlan_network[dst] = set()
-            wlan_network[dst].add(src)
-        return is_src_wireless or is_dst_wireless
-
-    def clear_throughputs(self) -> None:
-        for edge in self.edges.values():
-            edge.clear_throughput()
-
     def scale_graph(self) -> None:
         for nid, canvas_node in self.nodes.items():
             img = None
@@ -1085,3 +821,36 @@ class CanvasGraph(tk.Canvas):
 
             for edge_id in self.find_withtag(tags.EDGE):
                 self.itemconfig(edge_id, width=int(EDGE_WIDTH * self.app.app_scale))
+
+    def get_metadata(self) -> Dict[str, Any]:
+        wallpaper_path = None
+        if self.wallpaper_file:
+            wallpaper = Path(self.wallpaper_file)
+            if appconfig.BACKGROUNDS_PATH == wallpaper.parent:
+                wallpaper_path = wallpaper.name
+            else:
+                wallpaper_path = str(wallpaper)
+        return dict(
+            id=self.id,
+            wallpaper=wallpaper_path,
+            wallpaper_style=self.scale_option.get(),
+            fit_image=self.adjust_to_dim.get(),
+        )
+
+    def parse_metadata(self, config: Dict[str, Any]) -> None:
+        fit_image = config.get("fit_image", False)
+        self.adjust_to_dim.set(fit_image)
+        wallpaper_style = config.get("wallpaper_style", 1)
+        self.scale_option.set(wallpaper_style)
+        wallpaper = config.get("wallpaper")
+        if wallpaper:
+            wallpaper = Path(wallpaper)
+            if not wallpaper.is_file():
+                wallpaper = appconfig.BACKGROUNDS_PATH.joinpath(wallpaper)
+            logging.info("canvas(%s), wallpaper: %s", self.id, wallpaper)
+            if wallpaper.is_file():
+                self.set_wallpaper(str(wallpaper))
+            else:
+                self.app.show_error(
+                    "Background Error", f"background file not found: {wallpaper}"
+                )
diff --git a/daemon/core/gui/graph/manager.py b/daemon/core/gui/graph/manager.py
new file mode 100644
index 00000000..00681848
--- /dev/null
+++ b/daemon/core/gui/graph/manager.py
@@ -0,0 +1,392 @@
+import logging
+import tkinter as tk
+from copy import deepcopy
+from tkinter import BooleanVar, messagebox, ttk
+from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, ValuesView
+
+from core.api.grpc.wrappers import Link, LinkType, Node, Session, ThroughputsEvent
+from core.gui.graph import tags
+from core.gui.graph.edges import (
+    CanvasEdge,
+    CanvasWirelessEdge,
+    create_edge_token,
+    create_wireless_token,
+)
+from core.gui.graph.enums import GraphMode
+from core.gui.graph.graph import CanvasGraph
+from core.gui.graph.node import CanvasNode
+from core.gui.graph.shapeutils import ShapeType
+from core.gui.images import ImageEnum
+from core.gui.nodeutils import ICON_SIZE, NodeDraw, NodeUtils
+
+if TYPE_CHECKING:
+    from core.gui.app import Application
+    from core.gui.coreclient import CoreClient
+
+
+class ShowVar(BooleanVar):
+    def __init__(self, manager: "CanvasManager", tag: str, value: bool) -> None:
+        super().__init__(value=value)
+        self.manager: "CanvasManager" = manager
+        self.tag: str = tag
+
+    def state(self) -> str:
+        return tk.NORMAL if self.get() else tk.HIDDEN
+
+    def click_handler(self) -> None:
+        for canvas in self.manager.all():
+            canvas.itemconfigure(self.tag, state=self.state())
+
+
+class CanvasManager:
+    def __init__(
+        self, master: tk.BaseWidget, app: "Application", core: "CoreClient"
+    ) -> None:
+        self.master: tk.BaseWidget = master
+        self.app: "Application" = app
+        self.core: "CoreClient" = core
+
+        # canvas interactions
+        self.mode: GraphMode = GraphMode.SELECT
+        self.annotation_type: Optional[ShapeType] = None
+        self.node_draw: Optional[NodeDraw] = None
+        self.canvases: Dict[int, CanvasGraph] = {}
+
+        # global edge management
+        self.edges: Dict[str, CanvasEdge] = {}
+        self.wireless_edges: Dict[str, CanvasWirelessEdge] = {}
+
+        # global canvas settings
+        self.default_dimensions: Tuple[int, int] = (
+            self.app.guiconfig.preferences.width,
+            self.app.guiconfig.preferences.height,
+        )
+        self.current_dimensions: Tuple[int, int] = self.default_dimensions
+        self.show_node_labels: ShowVar = ShowVar(self, tags.NODE_LABEL, value=True)
+        self.show_link_labels: ShowVar = ShowVar(self, tags.LINK_LABEL, value=True)
+        self.show_links: ShowVar = ShowVar(self, tags.EDGE, value=True)
+        self.show_wireless: ShowVar = ShowVar(self, tags.WIRELESS_EDGE, value=True)
+        self.show_grid: ShowVar = ShowVar(self, tags.GRIDLINE, value=True)
+        self.show_annotations: ShowVar = ShowVar(self, tags.ANNOTATION, value=True)
+        self.show_loss_links: ShowVar = ShowVar(self, tags.LOSS_EDGES, value=True)
+        self.show_iface_names: BooleanVar = BooleanVar(value=False)
+        self.show_ip4s: BooleanVar = BooleanVar(value=True)
+        self.show_ip6s: BooleanVar = BooleanVar(value=True)
+
+        # throughput settings
+        self.throughput_threshold: float = 250.0
+        self.throughput_width: int = 10
+        self.throughput_color: str = "#FF0000"
+
+        # widget
+        self.notebook: Optional[ttk.Notebook] = None
+        self.canvas_ids: Dict[str, int] = {}
+        self.unique_ids: Dict[int, str] = {}
+        self.draw()
+
+        self.setup_bindings()
+        # start with a single tab by default
+        self.add_canvas()
+
+    def setup_bindings(self) -> None:
+        self.notebook.bind("<<NotebookTabChanged>>", self.tab_change)
+
+    def tab_change(self, _event: tk.Event) -> None:
+        # ignore tab change events before tab data has been setup
+        unique_id = self.notebook.select()
+        if not unique_id or unique_id not in self.canvas_ids:
+            return
+        canvas = self.current()
+        self.app.statusbar.set_zoom(canvas.ratio)
+
+    def select(self, tab_id: int):
+        unique_id = self.unique_ids.get(tab_id)
+        self.notebook.select(unique_id)
+
+    def draw(self) -> None:
+        self.notebook = ttk.Notebook(self.master)
+        self.notebook.grid(sticky=tk.NSEW, pady=1)
+
+    def _next_id(self) -> int:
+        _id = 1
+        canvas_ids = set(self.canvas_ids.values())
+        while _id in canvas_ids:
+            _id += 1
+        return _id
+
+    def current(self) -> CanvasGraph:
+        unique_id = self.notebook.select()
+        canvas_id = self.canvas_ids[unique_id]
+        return self.get(canvas_id)
+
+    def all(self) -> ValuesView[CanvasGraph]:
+        return self.canvases.values()
+
+    def get(self, canvas_id: int) -> CanvasGraph:
+        canvas = self.canvases.get(canvas_id)
+        if not canvas:
+            canvas = self.add_canvas(canvas_id)
+        return canvas
+
+    def add_canvas(self, canvas_id: int = None) -> CanvasGraph:
+        # create tab frame
+        tab = ttk.Frame(self.notebook, padding=0)
+        tab.grid(sticky=tk.NSEW)
+        tab.columnconfigure(0, weight=1)
+        tab.rowconfigure(0, weight=1)
+        if canvas_id is None:
+            canvas_id = self._next_id()
+        self.notebook.add(tab, text=f"Canvas {canvas_id}")
+        unique_id = self.notebook.tabs()[-1]
+        logging.info("creating canvas(%s) unique(%s)", canvas_id, unique_id)
+        self.canvas_ids[unique_id] = canvas_id
+        self.unique_ids[canvas_id] = unique_id
+
+        # create canvas
+        canvas = CanvasGraph(
+            tab, self.app, self, self.core, canvas_id, self.default_dimensions
+        )
+        canvas.grid(sticky=tk.NSEW)
+        self.canvases[canvas_id] = canvas
+
+        # add scrollbars
+        scroll_y = ttk.Scrollbar(tab, command=canvas.yview)
+        scroll_y.grid(row=0, column=1, sticky=tk.NS)
+        scroll_x = ttk.Scrollbar(tab, orient=tk.HORIZONTAL, command=canvas.xview)
+        scroll_x.grid(row=1, column=0, sticky=tk.EW)
+        canvas.configure(xscrollcommand=scroll_x.set)
+        canvas.configure(yscrollcommand=scroll_y.set)
+        return canvas
+
+    def delete_canvas(self) -> None:
+        if len(self.notebook.tabs()) == 1:
+            messagebox.showinfo("Canvas", "Cannot delete last canvas", parent=self.app)
+            return
+        unique_id = self.notebook.select()
+        self.notebook.forget(unique_id)
+        canvas_id = self.canvas_ids.pop(unique_id)
+        canvas = self.canvases.pop(canvas_id)
+        edges = set()
+        for node in canvas.nodes.values():
+            node.delete()
+            while node.edges:
+                edge = node.edges.pop()
+                if edge in edges:
+                    continue
+                edges.add(edge)
+                edge.delete()
+
+    def join(self, session: Session) -> None:
+        # clear out all canvas
+        for canvas_id in self.notebook.tabs():
+            self.notebook.forget(canvas_id)
+        self.canvases.clear()
+        self.canvas_ids.clear()
+        self.unique_ids.clear()
+        self.edges.clear()
+        self.wireless_edges.clear()
+        logging.info("cleared canvases")
+
+        # reset settings
+        self.show_node_labels.set(True)
+        self.show_link_labels.set(True)
+        self.show_grid.set(True)
+        self.show_annotations.set(True)
+        self.show_iface_names.set(False)
+        self.show_ip4s.set(True)
+        self.show_ip6s.set(True)
+        self.show_loss_links.set(True)
+        self.mode = GraphMode.SELECT
+        self.annotation_type = None
+        self.node_draw = None
+
+        # draw session
+        self.draw_session(session)
+
+    def draw_session(self, session: Session) -> None:
+        # create session nodes
+        for core_node in session.nodes.values():
+            # add node, avoiding ignored nodes
+            if NodeUtils.is_ignore_node(core_node.type):
+                continue
+            self.add_core_node(core_node)
+
+        # organize canvas tabs
+        canvas_ids = sorted(self.canvases)
+        for index, canvas_id in enumerate(canvas_ids):
+            canvas = self.canvases[canvas_id]
+            logging.info("sorting canvas index(%s) canvas(%s)", index, canvas_id)
+            self.notebook.insert(index, canvas.master)
+
+        # draw existing links
+        for link in session.links:
+            node1 = self.core.get_canvas_node(link.node1_id)
+            node2 = self.core.get_canvas_node(link.node2_id)
+            if link.type == LinkType.WIRELESS:
+                self.add_wireless_edge(node1, node2, link)
+            else:
+                self.add_wired_edge(node1, node2, link)
+
+        # parse metadata and organize canvases
+        self.core.parse_metadata()
+        for canvas in self.canvases.values():
+            canvas.organize()
+
+        # create a default canvas if none were created prior
+        if not self.canvases:
+            self.add_canvas()
+
+    def redraw_canvases(self, dimensions: Tuple[int, int]) -> None:
+        for canvas in self.canvases.values():
+            canvas.redraw_canvas(dimensions)
+            if canvas.wallpaper:
+                canvas.redraw_wallpaper()
+
+    def get_metadata(self) -> Dict[str, Any]:
+        canvases = [x.get_metadata() for x in self.all()]
+        return dict(
+            gridlines=self.app.manager.show_grid.get(),
+            dimensions=self.app.manager.current_dimensions,
+            canvases=canvases,
+        )
+
+    def parse_metadata(self, config: Dict[str, Any]) -> None:
+        # get configured dimensions and gridlines option
+        dimensions = self.default_dimensions
+        dimensions = config.get("dimensions", dimensions)
+        gridlines = config.get("gridlines", True)
+        self.show_grid.set(gridlines)
+        self.redraw_canvases(dimensions)
+
+        # get background configurations
+        for canvas_config in config.get("canvases", []):
+            canvas_id = canvas_config.get("id")
+            if canvas_id is None:
+                logging.error("canvas config id not provided")
+                continue
+            canvas = self.get(canvas_id)
+            canvas.parse_metadata(canvas_config)
+
+    def add_core_node(self, core_node: Node) -> None:
+        # get canvas tab for node
+        canvas_id = core_node.canvas if core_node.canvas > 0 else 1
+        logging.info("adding core node canvas(%s): %s", core_node.name, canvas_id)
+        canvas = self.get(canvas_id)
+        # if the gui can't find node's image, default to the "edit-node" image
+        image = NodeUtils.node_image(core_node, self.app.guiconfig, self.app.app_scale)
+        if not image:
+            image = self.app.get_icon(ImageEnum.EDITNODE, ICON_SIZE)
+        x = core_node.position.x
+        y = core_node.position.y
+        node = CanvasNode(self.app, canvas, x, y, core_node, image)
+        canvas.nodes[node.id] = node
+        self.core.set_canvas_node(core_node, node)
+
+    def set_throughputs(self, throughputs_event: ThroughputsEvent):
+        for iface_throughput in throughputs_event.iface_throughputs:
+            node_id = iface_throughput.node_id
+            iface_id = iface_throughput.iface_id
+            throughput = iface_throughput.throughput
+            iface_to_edge_id = (node_id, iface_id)
+            edge = self.core.iface_to_edge.get(iface_to_edge_id)
+            if edge:
+                edge.set_throughput(throughput)
+
+    def clear_throughputs(self) -> None:
+        for edge in self.edges.values():
+            edge.clear_throughput()
+
+    def stopped_session(self) -> None:
+        # clear wireless edges
+        for edge in self.wireless_edges.values():
+            edge.delete()
+        self.wireless_edges.clear()
+        self.clear_throughputs()
+
+    def update_wired_edge(self, link: Link) -> None:
+        token = create_edge_token(link)
+        edge = self.edges.get(token)
+        if edge:
+            edge.link.options = deepcopy(link.options)
+            edge.draw_link_options()
+            edge.check_options()
+
+    def delete_wired_edge(self, link: Link) -> None:
+        token = create_edge_token(link)
+        edge = self.edges.get(token)
+        if edge:
+            edge.delete()
+
+    def add_wired_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None:
+        token = create_edge_token(link)
+        if token in self.edges and link.options.unidirectional:
+            edge = self.edges[token]
+            edge.asymmetric_link = link
+        elif token not in self.edges:
+            edge = CanvasEdge(self.app, src, dst)
+            self.complete_edge(edge, dst, link)
+
+    def add_wireless_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None:
+        network_id = link.network_id if link.network_id else None
+        token = create_wireless_token(src.id, dst.id, network_id)
+        if token in self.wireless_edges:
+            logging.warning("ignoring link that already exists: %s", link)
+            return
+        edge = CanvasWirelessEdge(self.app, src, dst, network_id, token, link)
+        self.wireless_edges[token] = edge
+        src.wireless_edges.add(edge)
+        dst.wireless_edges.add(edge)
+        src.canvas.tag_raise(src.id)
+        dst.canvas.tag_raise(dst.id)
+
+    def delete_wireless_edge(
+        self, src: CanvasNode, dst: CanvasNode, link: Link
+    ) -> None:
+        network_id = link.network_id if link.network_id else None
+        token = create_wireless_token(src.id, dst.id, network_id)
+        if token not in self.wireless_edges:
+            return
+        edge = self.wireless_edges.pop(token)
+        edge.delete()
+
+    def update_wireless_edge(
+        self, src: CanvasNode, dst: CanvasNode, link: Link
+    ) -> None:
+        if not link.label:
+            return
+        network_id = link.network_id if link.network_id else None
+        token = create_wireless_token(src.id, dst.id, network_id)
+        if token not in self.wireless_edges:
+            self.add_wireless_edge(src, dst, link)
+        else:
+            edge = self.wireless_edges[token]
+            edge.middle_label_text(link.label)
+
+    # TODO: look into properly moving this into the edge itself and complete when
+    #       the destination is already provided
+    def complete_edge(
+        self, edge: CanvasEdge, dst: CanvasNode, link: Optional[Link] = None
+    ) -> None:
+        src = edge.src
+        edge.complete(dst)
+        if link is None:
+            link = self.core.create_link(edge, src, dst)
+        edge.link = link
+        if link.iface1:
+            iface1 = link.iface1
+            src.ifaces[iface1.id] = iface1
+        if link.iface2:
+            iface2 = link.iface2
+            dst.ifaces[iface2.id] = iface2
+        src.edges.add(edge)
+        dst.edges.add(edge)
+        edge.token = create_edge_token(edge.link)
+        if not edge.linked_wireless:
+            edge.arc_common_edges()
+        edge.draw_labels()
+        edge.check_options()
+        self.edges[edge.token] = edge
+        self.core.save_edge(edge, src, dst)
+        edge.src.canvas.organize()
+        if edge.has_shadows():
+            edge.dst.canvas.organize()
diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py
index 2ac4219e..9017db4c 100644
--- a/daemon/core/gui/graph/node.py
+++ b/daemon/core/gui/graph/node.py
@@ -2,7 +2,7 @@ import functools
 import logging
 import tkinter as tk
 from pathlib import Path
-from typing import TYPE_CHECKING, Dict, List, Set
+from typing import TYPE_CHECKING, Dict, List, Set, Tuple
 
 import grpc
 from PIL.ImageTk import PhotoImage
@@ -31,10 +31,16 @@ NODE_TEXT_OFFSET: int = 5
 
 class CanvasNode:
     def __init__(
-        self, app: "Application", x: float, y: float, core_node: Node, image: PhotoImage
+        self,
+        app: "Application",
+        canvas: "CanvasGraph",
+        x: float,
+        y: float,
+        core_node: Node,
+        image: PhotoImage,
     ):
         self.app: "Application" = app
-        self.canvas: "CanvasGraph" = app.canvas
+        self.canvas: "CanvasGraph" = canvas
         self.image: PhotoImage = image
         self.core_node: Node = core_node
         self.id: int = self.canvas.create_image(
@@ -49,7 +55,7 @@ class CanvasNode:
             tags=tags.NODE_LABEL,
             font=self.app.icon_text_font,
             fill="#0000CD",
-            state=self.canvas.show_node_labels.state(),
+            state=self.app.manager.show_node_labels.state(),
         )
         self.tooltip: CanvasTooltip = CanvasTooltip(self.canvas)
         self.edges: Set[CanvasEdge] = set()
@@ -57,10 +63,14 @@ class CanvasNode:
         self.wireless_edges: Set[CanvasWirelessEdge] = set()
         self.antennas: List[int] = []
         self.antenna_images: Dict[int, PhotoImage] = {}
+        self.hidden: bool = False
         self.setup_bindings()
         self.context: tk.Menu = tk.Menu(self.canvas)
         themes.style_menu(self.context)
 
+    def position(self) -> Tuple[int, int]:
+        return self.canvas.coords(self.id)
+
     def next_iface_id(self) -> int:
         i = 0
         while i in self.ifaces:
@@ -81,7 +91,7 @@ class CanvasNode:
         self.delete_antennas()
 
     def add_antenna(self) -> None:
-        x, y = self.canvas.coords(self.id)
+        x, y = self.position()
         offset = len(self.antennas) * 8 * self.app.app_scale
         img = self.app.get_icon(ImageEnum.ANTENNA, ANTENNA_SIZE)
         antenna_id = self.canvas.create_image(
@@ -139,15 +149,14 @@ class CanvasNode:
 
     def move(self, x: float, y: float) -> None:
         x, y = self.canvas.get_scaled_coords(x, y)
-        current_x, current_y = self.canvas.coords(self.id)
+        current_x, current_y = self.position()
         x_offset = x - current_x
         y_offset = y - current_y
         self.motion(x_offset, y_offset, update=False)
 
     def motion(self, x_offset: float, y_offset: float, update: bool = True) -> None:
-        original_position = self.canvas.coords(self.id)
+        original_position = self.position()
         self.canvas.move(self.id, x_offset, y_offset)
-        pos = self.canvas.coords(self.id)
 
         # check new position
         bbox = self.canvas.bbox(self.id)
@@ -165,11 +174,12 @@ class CanvasNode:
 
         # move edges
         for edge in self.edges:
-            edge.move_node(self.id, pos)
+            edge.move_node(self)
         for edge in self.wireless_edges:
-            edge.move_node(self.id, pos)
+            edge.move_node(self)
 
         # set actual coords for node and update core is running
+        pos = self.position()
         real_x, real_y = self.canvas.get_actual_coords(*pos)
         self.core_node.position.x = real_x
         self.core_node.position.y = real_y
@@ -245,27 +255,44 @@ class CanvasNode:
                 self.context.add_command(
                     label="Link To Selected", command=self.wireless_link_selected
                 )
+
+            link_menu = tk.Menu(self.context)
+            for canvas in self.app.manager.all():
+                canvas_menu = tk.Menu(link_menu)
+                themes.style_menu(canvas_menu)
+                for node in canvas.nodes.values():
+                    if not self.is_linkable(node):
+                        continue
+                    func_link = functools.partial(self.click_link, node)
+                    canvas_menu.add_command(
+                        label=node.core_node.name, command=func_link
+                    )
+                link_menu.add_cascade(label=f"Canvas {canvas.id}", menu=canvas_menu)
+            themes.style_menu(link_menu)
+            self.context.add_cascade(label="Link", menu=link_menu)
+
             unlink_menu = tk.Menu(self.context)
             for edge in self.edges:
                 link = edge.link
-                if self.id == edge.src:
-                    other_id = edge.dst
+                if self.id == edge.src.id:
+                    other_node = edge.dst
                     other_iface = link.iface2.name if link.iface2 else None
                 else:
-                    other_id = edge.src
+                    other_node = edge.src
                     other_iface = link.iface1.name if link.iface1 else None
-                other_node = self.canvas.nodes[other_id]
                 other_name = other_node.core_node.name
                 label = f"{other_name}:{other_iface}" if other_iface else other_name
                 func_unlink = functools.partial(self.click_unlink, edge)
                 unlink_menu.add_command(label=label, command=func_unlink)
             themes.style_menu(unlink_menu)
             self.context.add_cascade(label="Unlink", menu=unlink_menu)
+
             edit_menu = tk.Menu(self.context)
             themes.style_menu(edit_menu)
             edit_menu.add_command(label="Cut", command=self.click_cut)
             edit_menu.add_command(label="Copy", command=self.canvas_copy)
             edit_menu.add_command(label="Delete", command=self.canvas_delete)
+            edit_menu.add_command(label="Hide", command=self.hide)
             self.context.add_cascade(label="Edit", menu=edit_menu)
         self.context.tk_popup(event.x_root, event.y_root)
 
@@ -274,9 +301,13 @@ class CanvasNode:
         self.canvas_delete()
 
     def click_unlink(self, edge: CanvasEdge) -> None:
-        self.canvas.delete_edge(edge)
+        edge.delete()
         self.app.default_info()
 
+    def click_link(self, node: "CanvasNode") -> None:
+        edge = CanvasEdge(self.app, self, node)
+        self.app.manager.complete_edge(edge, node)
+
     def canvas_delete(self) -> None:
         self.canvas.clear_selection()
         self.canvas.select_object(self.id)
@@ -320,15 +351,14 @@ class CanvasNode:
     def has_emane_link(self, iface_id: int) -> Node:
         result = None
         for edge in self.edges:
-            if self.id == edge.src:
-                other_id = edge.dst
+            if self.id == edge.src.id:
+                other_node = edge.dst
                 edge_iface_id = edge.link.iface1.id
             else:
-                other_id = edge.src
+                other_node = edge.src
                 edge_iface_id = edge.link.iface2.id
             if edge_iface_id != iface_id:
                 continue
-            other_node = self.canvas.nodes[other_id]
             if other_node.core_node.type == NodeType.EMANE:
                 result = other_node.core_node
                 break
@@ -360,3 +390,43 @@ class CanvasNode:
         self.core_node.icon = icon_path
         self.image = Images.create(icon_path, nodeutils.ICON_SIZE)
         self.canvas.itemconfig(self.id, image=self.image)
+
+    def is_linkable(self, node: "CanvasNode") -> bool:
+        # cannot link to self
+        if self == node:
+            return False
+        # rj45 nodes can only support one link
+        if NodeUtils.is_rj45_node(self.core_node.type) and self.edges:
+            return False
+        if NodeUtils.is_rj45_node(node.core_node.type) and node.edges:
+            return False
+        # only 1 link between bridge based nodes
+        is_src_bridge = NodeUtils.is_bridge_node(self.core_node)
+        is_dst_bridge = NodeUtils.is_bridge_node(node.core_node)
+        common_links = self.edges & node.edges
+        if all([is_src_bridge, is_dst_bridge, common_links]):
+            return False
+        # valid link
+        return True
+
+    def is_wireless(self) -> bool:
+        return NodeUtils.is_wireless_node(self.core_node.type)
+
+    def hide(self) -> None:
+        self.hidden = True
+        self.canvas.itemconfig(self.id, state=tk.HIDDEN)
+        self.canvas.itemconfig(self.text_id, state=tk.HIDDEN)
+        for edge in self.edges:
+            if not edge.hidden:
+                edge.hide()
+
+    def show(self) -> None:
+        self.hidden = False
+        self.canvas.itemconfig(self.id, state=tk.NORMAL)
+        self.canvas.itemconfig(self.text_id, state=tk.NORMAL)
+        for edge in self.edges:
+            other_node = edge.src
+            if edge.src == self:
+                other_node = edge.dst
+            if edge.hidden and not other_node.hidden:
+                edge.show()
diff --git a/daemon/core/gui/graph/shape.py b/daemon/core/gui/graph/shape.py
index 36298655..24786b04 100644
--- a/daemon/core/gui/graph/shape.py
+++ b/daemon/core/gui/graph/shape.py
@@ -1,5 +1,5 @@
 import logging
-from typing import TYPE_CHECKING, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
 
 from core.gui.dialogs.shapemod import ShapeDialog
 from core.gui.graph import tags
@@ -69,6 +69,31 @@ class Shape:
             self.shape_data = data
         self.draw()
 
+    @classmethod
+    def from_metadata(cls, app: "Application", config: Dict[str, Any]) -> None:
+        shape_type = config["type"]
+        try:
+            shape_type = ShapeType(shape_type)
+            coords = config["iconcoords"]
+            data = AnnotationData(
+                config["label"],
+                config["fontfamily"],
+                config["fontsize"],
+                config["labelcolor"],
+                config["color"],
+                config["border"],
+                config["width"],
+                config["bold"],
+                config["italic"],
+                config["underline"],
+            )
+            canvas_id = config.get("canvas", 1)
+            canvas = app.manager.get(canvas_id)
+            shape = Shape(app, canvas, shape_type, *coords, data=data)
+            canvas.shapes[shape.id] = shape
+        except ValueError:
+            logging.exception("unknown shape: %s", shape_type)
+
     def draw(self) -> None:
         if self.created:
             dash = None
@@ -85,7 +110,7 @@ class Shape:
                 fill=self.shape_data.fill_color,
                 outline=self.shape_data.border_color,
                 width=self.shape_data.border_width,
-                state=self.canvas.show_annotations.state(),
+                state=self.app.manager.show_annotations.state(),
             )
             self.draw_shape_text()
         elif self.shape_type == ShapeType.RECTANGLE:
@@ -99,7 +124,7 @@ class Shape:
                 fill=self.shape_data.fill_color,
                 outline=self.shape_data.border_color,
                 width=self.shape_data.border_width,
-                state=self.canvas.show_annotations.state(),
+                state=self.app.manager.show_annotations.state(),
             )
             self.draw_shape_text()
         elif self.shape_type == ShapeType.TEXT:
@@ -111,7 +136,7 @@ class Shape:
                 text=self.shape_data.text,
                 fill=self.shape_data.text_color,
                 font=font,
-                state=self.canvas.show_annotations.state(),
+                state=self.app.manager.show_annotations.state(),
             )
         else:
             logging.error("unknown shape type: %s", self.shape_type)
@@ -139,7 +164,7 @@ class Shape:
                 text=self.shape_data.text,
                 fill=self.shape_data.text_color,
                 font=font,
-                state=self.canvas.show_annotations.state(),
+                state=self.app.manager.show_annotations.state(),
             )
 
     def shape_motion(self, x1: float, y1: float) -> None:
@@ -184,6 +209,7 @@ class Shape:
             x1, y1 = self.canvas.get_actual_coords(x1, y1)
             coords = (x1, y1)
         return {
+            "canvas": self.canvas.id,
             "type": self.shape_type.value,
             "iconcoords": coords,
             "label": self.shape_data.text,
diff --git a/daemon/core/gui/graph/tags.py b/daemon/core/gui/graph/tags.py
index 3d3c3611..803b969e 100644
--- a/daemon/core/gui/graph/tags.py
+++ b/daemon/core/gui/graph/tags.py
@@ -14,6 +14,7 @@ NODE: str = "node"
 WALLPAPER: str = "wallpaper"
 SELECTION: str = "selectednodes"
 MARKER: str = "marker"
+HIDDEN: str = "hidden"
 ORGANIZE_TAGS: List[str] = [
     WALLPAPER,
     GRIDLINE,
diff --git a/daemon/core/gui/images.py b/daemon/core/gui/images.py
index 66d92d30..188bc824 100644
--- a/daemon/core/gui/images.py
+++ b/daemon/core/gui/images.py
@@ -90,6 +90,7 @@ class ImageEnum(Enum):
     SHUTDOWN = "shutdown"
     CANCEL = "cancel"
     ERROR = "error"
+    SHADOW = "shadow"
 
 
 class TypeToImage:
diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py
index 4c5f5978..a1b03b51 100644
--- a/daemon/core/gui/interface.py
+++ b/daemon/core/gui/interface.py
@@ -188,19 +188,16 @@ class InterfaceManager:
         self, canvas_node: CanvasNode, visited: Set[int] = None
     ) -> Optional[IPNetwork]:
         logging.info("finding subnet for node: %s", canvas_node.core_node.name)
-        canvas = self.app.canvas
         subnets = None
         if not visited:
             visited = set()
         visited.add(canvas_node.core_node.id)
         for edge in canvas_node.edges:
-            src_node = canvas.nodes[edge.src]
-            dst_node = canvas.nodes[edge.dst]
             iface = edge.link.iface1
-            check_node = src_node
-            if src_node == canvas_node:
+            check_node = edge.src
+            if edge.src == canvas_node:
                 iface = edge.link.iface2
-                check_node = dst_node
+                check_node = edge.dst
             if check_node.core_node.id in visited:
                 continue
             visited.add(check_node.core_node.id)
diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py
index fa7853ad..85a8ce01 100644
--- a/daemon/core/gui/menubar.py
+++ b/daemon/core/gui/menubar.py
@@ -22,7 +22,7 @@ from core.gui.dialogs.servers import ServersDialog
 from core.gui.dialogs.sessionoptions import SessionOptionsDialog
 from core.gui.dialogs.sessions import SessionsDialog
 from core.gui.dialogs.throughput import ThroughputDialog
-from core.gui.graph.graph import CanvasGraph
+from core.gui.graph.manager import CanvasManager
 from core.gui.nodeutils import ICON_SIZE
 from core.gui.observers import ObserversMenu
 from core.gui.task import ProgressTask
@@ -45,9 +45,10 @@ class Menubar(tk.Menu):
         super().__init__(app)
         self.app: "Application" = app
         self.core: CoreClient = app.core
-        self.canvas: CanvasGraph = app.canvas
+        self.manager: CanvasManager = app.manager
         self.recent_menu: Optional[tk.Menu] = None
         self.edit_menu: Optional[tk.Menu] = None
+        self.canvas_menu: Optional[tk.Menu] = None
         self.observers_menu: Optional[ObserversMenu] = None
         self.draw()
 
@@ -106,6 +107,7 @@ class Menubar(tk.Menu):
         menu = tk.Menu(self)
         menu.add_command(label="Preferences", command=self.click_preferences)
         menu.add_command(label="Custom Nodes", command=self.click_custom_nodes)
+        menu.add_command(label="Show Hidden Nodes", command=self.click_show_hidden)
         menu.add_separator()
         menu.add_command(label="Undo", accelerator="Ctrl+Z", state=tk.DISABLED)
         menu.add_command(label="Redo", accelerator="Ctrl+Y", state=tk.DISABLED)
@@ -116,11 +118,13 @@ class Menubar(tk.Menu):
         menu.add_command(
             label="Delete", accelerator="Ctrl+D", command=self.click_delete
         )
+        menu.add_command(label="Hide", accelerator="Ctrl+H", command=self.click_hide)
         self.add_cascade(label="Edit", menu=menu)
         self.app.master.bind_all("<Control-x>", self.click_cut)
         self.app.master.bind_all("<Control-c>", self.click_copy)
         self.app.master.bind_all("<Control-v>", self.click_paste)
         self.app.master.bind_all("<Control-d>", self.click_delete)
+        self.app.master.bind_all("<Control-h>", self.click_hide)
         self.edit_menu = menu
 
     def draw_canvas_menu(self) -> None:
@@ -128,9 +132,13 @@ class Menubar(tk.Menu):
         Create canvas menu
         """
         menu = tk.Menu(self)
+        menu.add_command(label="New", command=self.click_canvas_add)
         menu.add_command(label="Size / Scale", command=self.click_canvas_size_and_scale)
+        menu.add_separator()
+        menu.add_command(label="Delete", command=self.click_canvas_delete)
         menu.add_command(label="Wallpaper", command=self.click_canvas_wallpaper)
         self.add_cascade(label="Canvas", menu=menu)
+        self.canvas_menu = menu
 
     def draw_view_menu(self) -> None:
         """
@@ -145,52 +153,52 @@ class Menubar(tk.Menu):
         menu.add_checkbutton(
             label="Interface Names",
             command=self.click_edge_label_change,
-            variable=self.canvas.show_iface_names,
+            variable=self.manager.show_iface_names,
         )
         menu.add_checkbutton(
             label="IPv4 Addresses",
             command=self.click_edge_label_change,
-            variable=self.canvas.show_ip4s,
+            variable=self.manager.show_ip4s,
         )
         menu.add_checkbutton(
             label="IPv6 Addresses",
             command=self.click_edge_label_change,
-            variable=self.canvas.show_ip6s,
+            variable=self.manager.show_ip6s,
         )
         menu.add_checkbutton(
             label="Node Labels",
-            command=self.canvas.show_node_labels.click_handler,
-            variable=self.canvas.show_node_labels,
+            command=self.manager.show_node_labels.click_handler,
+            variable=self.manager.show_node_labels,
         )
         menu.add_checkbutton(
             label="Link Labels",
-            command=self.canvas.show_link_labels.click_handler,
-            variable=self.canvas.show_link_labels,
+            command=self.manager.show_link_labels.click_handler,
+            variable=self.manager.show_link_labels,
         )
         menu.add_checkbutton(
             label="Links",
-            command=self.canvas.show_links.click_handler,
-            variable=self.canvas.show_links,
+            command=self.manager.show_links.click_handler,
+            variable=self.manager.show_links,
         )
         menu.add_checkbutton(
             label="Loss Links",
-            command=self.canvas.show_loss_links.click_handler,
-            variable=self.canvas.show_loss_links,
+            command=self.manager.show_loss_links.click_handler,
+            variable=self.manager.show_loss_links,
         )
         menu.add_checkbutton(
             label="Wireless Links",
-            command=self.canvas.show_wireless.click_handler,
-            variable=self.canvas.show_wireless,
+            command=self.manager.show_wireless.click_handler,
+            variable=self.manager.show_wireless,
         )
         menu.add_checkbutton(
             label="Annotations",
-            command=self.canvas.show_annotations.click_handler,
-            variable=self.canvas.show_annotations,
+            command=self.manager.show_annotations.click_handler,
+            variable=self.manager.show_annotations,
         )
         menu.add_checkbutton(
             label="Canvas Grid",
-            command=self.canvas.show_grid.click_handler,
-            variable=self.canvas.show_grid,
+            command=self.manager.show_grid.click_handler,
+            variable=self.manager.show_grid,
         )
         self.add_cascade(label="View", menu=menu)
 
@@ -334,17 +342,12 @@ class Menubar(tk.Menu):
         self.app.save_config()
         self.app.menubar.update_recent_files()
 
-    def change_menubar_item_state(self, is_runtime: bool) -> None:
-        labels = {"Copy", "Paste", "Delete", "Cut"}
-        for i in range(self.edit_menu.index(tk.END) + 1):
-            try:
-                label = self.edit_menu.entrycget(i, "label")
-                if label not in labels:
-                    continue
-                state = tk.DISABLED if is_runtime else tk.NORMAL
-                self.edit_menu.entryconfig(i, state=state)
-            except tk.TclError:
-                pass
+    def set_state(self, is_runtime: bool) -> None:
+        state = tk.DISABLED if is_runtime else tk.NORMAL
+        for entry in {"Copy", "Paste", "Delete", "Cut"}:
+            self.edit_menu.entryconfigure(entry, state=state)
+        for entry in {"Delete"}:
+            self.canvas_menu.entryconfigure(entry, state=state)
 
     def prompt_save_running_session(self, quit_app: bool = False) -> None:
         """
@@ -372,6 +375,12 @@ class Menubar(tk.Menu):
         dialog = PreferencesDialog(self.app)
         dialog.show()
 
+    def click_canvas_add(self) -> None:
+        self.manager.add_canvas()
+
+    def click_canvas_delete(self) -> None:
+        self.manager.delete_canvas()
+
     def click_canvas_size_and_scale(self) -> None:
         dialog = SizeAndScaleDialog(self.app)
         dialog.show()
@@ -401,17 +410,29 @@ class Menubar(tk.Menu):
         dialog.show()
 
     def click_copy(self, _event: tk.Event = None) -> None:
-        self.canvas.copy()
+        canvas = self.manager.current()
+        canvas.copy()
 
     def click_paste(self, _event: tk.Event = None) -> None:
-        self.canvas.paste()
+        canvas = self.manager.current()
+        canvas.paste()
 
     def click_delete(self, _event: tk.Event = None) -> None:
-        self.canvas.delete_selected_objects()
+        canvas = self.manager.current()
+        canvas.delete_selected_objects()
+
+    def click_hide(self, _event: tk.Event = None) -> None:
+        canvas = self.manager.current()
+        canvas.hide_selected_objects()
 
     def click_cut(self, _event: tk.Event = None) -> None:
-        self.canvas.copy()
-        self.canvas.delete_selected_objects()
+        canvas = self.manager.current()
+        canvas.copy()
+        canvas.delete_selected_objects()
+
+    def click_show_hidden(self, _event: tk.Event = None) -> None:
+        for canvas in self.manager.all():
+            canvas.show_hidden()
 
     def click_session_options(self) -> None:
         logging.debug("Click options")
@@ -439,14 +460,15 @@ class Menubar(tk.Menu):
         dialog.show()
 
     def click_autogrid(self) -> None:
-        width, height = self.canvas.current_dimensions
+        width, height = self.manager.current_dimensions
         padding = (ICON_SIZE / 2) + 10
         layout_size = padding + ICON_SIZE
         col_count = width // layout_size
         logging.info(
             "auto grid layout: dimension(%s, %s) col(%s)", width, height, col_count
         )
-        for i, node in enumerate(self.canvas.nodes.values()):
+        canvas = self.manager.current()
+        for i, node in enumerate(canvas.nodes.values()):
             col = i % col_count
             row = i // col_count
             x = (col * layout_size) + padding
@@ -460,7 +482,7 @@ class Menubar(tk.Menu):
             self.app.hide_info()
 
     def click_edge_label_change(self) -> None:
-        for edge in self.canvas.edges.values():
+        for edge in self.manager.edges.values():
             edge.draw_labels()
 
     def click_mac_config(self) -> None:
diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py
index 25f5f972..441213f2 100644
--- a/daemon/core/gui/statusbar.py
+++ b/daemon/core/gui/statusbar.py
@@ -48,7 +48,6 @@ class StatusBar(ttk.Frame):
 
         self.zoom = ttk.Label(self, anchor=tk.CENTER, borderwidth=1, relief=tk.RIDGE)
         self.zoom.grid(row=0, column=1, sticky=tk.EW)
-        self.set_zoom(self.app.canvas.ratio)
 
         self.cpu_label = ttk.Label(
             self, anchor=tk.CENTER, borderwidth=1, relief=tk.RIDGE
diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py
index 1f5589ba..79a0a2f0 100644
--- a/daemon/core/gui/toolbar.py
+++ b/daemon/core/gui/toolbar.py
@@ -144,7 +144,8 @@ class MarkerFrame(ttk.Frame):
         Tooltip(self.color_frame, "Marker Color")
 
     def click_clear(self) -> None:
-        self.app.canvas.delete(tags.MARKER)
+        canvas = self.app.manager.current()
+        canvas.delete(tags.MARKER)
 
     def click_color(self, _event: tk.Event) -> None:
         dialog = ColorPickerDialog(self.app, self.app, self.color)
@@ -257,8 +258,8 @@ class Toolbar(ttk.Frame):
 
     def draw_node_picker(self) -> None:
         self.hide_marker()
-        self.app.canvas.mode = GraphMode.NODE
-        self.app.canvas.node_draw = self.current_node
+        self.app.manager.mode = GraphMode.NODE
+        self.app.manager.node_draw = self.current_node
         self.design_frame.select_radio(self.node_button)
         self.picker = PickerFrame(self.app, self.node_button)
         # draw default nodes
@@ -278,12 +279,12 @@ class Toolbar(ttk.Frame):
 
     def click_selection(self) -> None:
         self.design_frame.select_radio(self.select_button)
-        self.app.canvas.mode = GraphMode.SELECT
+        self.app.manager.mode = GraphMode.SELECT
         self.hide_marker()
 
     def click_runtime_selection(self) -> None:
         self.runtime_frame.select_radio(self.runtime_select_button)
-        self.app.canvas.mode = GraphMode.SELECT
+        self.app.manager.mode = GraphMode.SELECT
         self.hide_marker()
 
     def click_start(self) -> None:
@@ -291,8 +292,8 @@ class Toolbar(ttk.Frame):
         Start session handler redraw buttons, send node and link messages to grpc
         server.
         """
-        self.app.menubar.change_menubar_item_state(is_runtime=True)
-        self.app.canvas.mode = GraphMode.SELECT
+        self.app.menubar.set_state(is_runtime=True)
+        self.app.manager.mode = GraphMode.SELECT
         enable_buttons(self.design_frame, enabled=False)
         task = ProgressTask(
             self.app, "Start", self.app.core.start_session, self.start_callback
@@ -324,7 +325,7 @@ class Toolbar(ttk.Frame):
 
     def click_link(self) -> None:
         self.design_frame.select_radio(self.link_button)
-        self.app.canvas.mode = GraphMode.EDGE
+        self.app.manager.mode = GraphMode.EDGE
         self.hide_marker()
 
     def update_button(
@@ -337,7 +338,7 @@ class Toolbar(ttk.Frame):
         logging.debug("update button(%s): %s", button, node_draw)
         button.configure(image=image)
         button.image = image
-        self.app.canvas.node_draw = node_draw
+        self.app.manager.node_draw = node_draw
         if type_enum == NodeTypeEnum.NODE:
             self.current_node = node_draw
         elif type_enum == NodeTypeEnum.NETWORK:
@@ -348,8 +349,8 @@ class Toolbar(ttk.Frame):
         Draw the options for link-layer button.
         """
         self.hide_marker()
-        self.app.canvas.mode = GraphMode.NODE
-        self.app.canvas.node_draw = self.current_network
+        self.app.manager.mode = GraphMode.NODE
+        self.app.manager.node_draw = self.current_network
         self.design_frame.select_radio(self.network_button)
         self.picker = PickerFrame(self.app, self.network_button)
         for node_draw in NodeUtils.NETWORK_NODES:
@@ -364,8 +365,8 @@ class Toolbar(ttk.Frame):
         Draw the options for marker button.
         """
         self.design_frame.select_radio(self.annotation_button)
-        self.app.canvas.mode = GraphMode.ANNOTATION
-        self.app.canvas.annotation_type = self.current_annotation
+        self.app.manager.mode = GraphMode.ANNOTATION
+        self.app.manager.annotation_type = self.current_annotation
         if is_marker(self.current_annotation):
             self.show_marker()
         self.picker = PickerFrame(self.app, self.annotation_button)
@@ -396,7 +397,7 @@ class Toolbar(ttk.Frame):
         redraw buttons on the toolbar, send node and link messages to grpc server
         """
         logging.info("clicked stop button")
-        self.app.menubar.change_menubar_item_state(is_runtime=False)
+        self.app.menubar.set_state(is_runtime=False)
         self.app.core.close_mobility_players()
         enable_buttons(self.runtime_frame, enabled=False)
         task = ProgressTask(
@@ -406,7 +407,7 @@ class Toolbar(ttk.Frame):
 
     def stop_callback(self, result: bool) -> None:
         self.set_design()
-        self.app.canvas.stopped_session()
+        self.app.manager.stopped_session()
 
     def update_annotation(
         self, shape_type: ShapeType, image_enum: ImageEnum, image: PhotoImage
@@ -414,7 +415,7 @@ class Toolbar(ttk.Frame):
         logging.debug("clicked annotation")
         self.annotation_button.configure(image=image)
         self.annotation_button.image = image
-        self.app.canvas.annotation_type = shape_type
+        self.app.manager.annotation_type = shape_type
         self.current_annotation = shape_type
         self.annotation_enum = image_enum
         if is_marker(shape_type):
@@ -435,8 +436,8 @@ class Toolbar(ttk.Frame):
 
     def click_marker_button(self) -> None:
         self.runtime_frame.select_radio(self.runtime_marker_button)
-        self.app.canvas.mode = GraphMode.ANNOTATION
-        self.app.canvas.annotation_type = ShapeType.MARKER
+        self.app.manager.mode = GraphMode.ANNOTATION
+        self.app.manager.annotation_type = ShapeType.MARKER
         self.show_marker()
 
     def scale_button(
diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto
index d168afe0..cdc89202 100644
--- a/daemon/proto/core/api/grpc/core.proto
+++ b/daemon/proto/core/api/grpc/core.proto
@@ -752,6 +752,7 @@ message Node {
     Geo geo = 12;
     string dir = 13;
     string channel = 14;
+    int32 canvas = 15;
 }
 
 message Link {