####################################################################### # WikidMap - v0.2 # # Plugin that uses FloatCanvas to display a simple mindmap of wikipages # directly related to the currently opened page. # # This is still a test release and as such some things will not # work / may change in the future. # ####################################################################### import wx # FloatCanvas is used for the graphics #from wx.lib.floatcanvas import NavCanvas, FloatCanvas #from wx.lib.floatcanvas.Utilities import BBox from floatcanvas import NavCanvas, FloatCanvas from floatcanvas.Utilities import BBox from threading import Thread import numpy as N import math import random import time WIKIDPAD_PLUGIN = (("MenuFunctions",1),) def describeMenuItems(wiki): return ((mindMap, "Display mind map (test)'\tCtrl-Shift-M", "Display a mindmap (test))"),) # Does this matter / should it be bigger? CANVAS_SIZE = (500, 500) # Variables that affect mindmap display, try playing with these # if you don't like the default layout DISTANCE = 120 MAX_NODES = 5 STAGGER_NODE_NO = 5 MAX_DEPTH = 2 EXISTING_PAGES_ONLY = False # This is only necessary if you use a hack that removes case # sensitive wikiwords CASE_INSENSITIVE_WIKIWORDS = False # ---------------- Transparant overlay --------------- # FloatCanvas does not natively support alpha transparency class OpacityMixin: def SetBrush(self, FillColor, FillStyle): "Like standard SetBrush except that FillStyle is an integer giving\ opacity , where 0<=opacity<=255" opacity = FillStyle c = wx.Color() if FillColor is None: FillColor = u"black" c.SetFromName(FillColor) r,g,b = c.Get() c = wx.Color(r,g,b,opacity) self.Brush = wx.Brush(c) class AlphaCircle(OpacityMixin, FloatCanvas.Circle): def __init__(self, XY, Diameter, **kwargs): FloatCanvas.Circle.__init__(self, XY, Diameter, **kwargs) self.XY = XY def _Draw(self, dc, WorldToPixel, ScaleWorldToPixel, HTdc=None): gc = wx.GraphicsContext.Create(dc) (XY, WH) = self.SetUpDraw(gc, WorldToPixel, ScaleWorldToPixel, HTdc) path = gc.CreatePath() center = XY radius = WH[0] * 0.5 path.AddCircle(center[0], center[1], radius) gc.PushState() gc.SetPen(wx.Pen(self.LineColor, self.LineWidth)) gc.SetBrush(self.Brush) gc.DrawPath(path) gc.PopState() class MovingObjectMixin: """ Methods required for a Moving object """ def GetOutlinePoints(self): """ Returns a set of points with which to draw the outline when moving the object. Points are a NX2 array of (x,y) points in World coordinates. """ BB = self.BoundingBox OutlinePoints = N.array( ( (BB[0,0], BB[0,1]), (BB[0,0], BB[1,1]), (BB[1,0], BB[1,1]), (BB[1,0], BB[0,1]), ) ) return OutlinePoints class ConnectorObjectMixin: """ Mixin class for DrawObjects that can be connected with lines Note that this version only works for Objects that have an "XY" attribute: that is, one that is derived from XHObjectMixin. """ def GetConnectPoint(self): return self.XY class MovingGroup(FloatCanvas.Group, MovingObjectMixin, ConnectorObjectMixin): def GetConnectPoint(self): return self.BoundingBox.Center class Node: """ Each wikiword(page) is represented by a node Is it bad practice to pass the frame to each Node? """ def __init__(self, name, depth, pos, size=(20, 10), BackgroundColor="Dark blue", TextColor="Black"): self.Name = name self.Depth = depth self.Children = [] self.Parents = [] self.Relations_Created = False self.Position = None self.Creator_Angle = None self.Connectors = set([]) self.Node_Number = None self.Drawn_Child_Nodes = set([]) self.Start_Pos = pos self.Page_Exists = False self.DrawObject = None self.BackgroundColor = BackgroundColor self.TextColor = TextColor #self.Page_Exists = self.frame.getWikiDocument().isDefinedWikiLinkTerm(self.Name) self.Drawn = False def CreateDrawObject(self): if self.Page_Exists: self.DrawObject = NodeObject(self.Name, self.Start_Pos, (20, 10), self.BackgroundColor, u"Gray") else: self.DrawObject = NodeObject(self.Name, self.Start_Pos, (20, 10), u"BLACK", u"Gray") return self.DrawObject def Move(self, pos): # For some reason when moving an object horizontally we get # a small drift that must be corrected (would be better to # correct at the source) if pos[1] == 0: pos = (pos[0], 5) self.Start_Pos = self.Start_Pos + pos def GetName(self): #name = self.Name #if CASE_INSENSITIVE_WIKIWORDS: # name = name.lower() return self.Name def SetNodeNumber(self, max_nodes): self.Node_Number = max_nodes # Modify draw object if not all nodes shown # NOTE: if we do this here the draw object changes and all # connectors must be updated as well #if max_nodes is not None and \ # max_nodes < len(self.Children) + len(self.Parents): # self.DrawObject = NodeObject(self.Name, # self.GetPosition(), (20, 10), self.BackgroundColor, # self.TextColor, NodesHidden=True) def HiddenNodes(self): if self.Node_Number is not None and \ self.Node_Number < len(self.Children) + len(self.Parents): return True else: False def AddChild(self, page): self.Children.append(page) def AddParent(self, page): self.Parents.append(page) def AddChildren(self, pages): self.Children.extend(pages) def AddParents(self, pages): self.Parents.extend(pages) def GetDrawObject(self): return self.DrawObject def GetRelations(self): l = self.Children[:] l.extend(self.Parents) return l def GetChildren(self): return self.Children def GetParents(self): return self.Parents def GetPosition(self): """ Helper to return the nodes position """ if self.DrawObject is not None: return self.DrawObject.Ellipse.XY else: return self.Start_Pos #return self.DrawObject.GetConnectPoint() # TODO: cleanup class NodeObject(FloatCanvas.Group, MovingObjectMixin, ConnectorObjectMixin): """ A version of the moving group for nodes -- an ellipse with text on it. """ def __init__(self, Label, XY, WH, BackgroundColor = u"Yellow", TextColor = u"Black", InForeground = False, IsVisible = True, TruncateText = True, NodesHidden = False, TextSize = 15): self.Name = Label if len(Label) > 12 and TruncateText: Label = u"{0}..".format(Label[:12]) XY = N.asarray(XY, N.float).reshape(2,) WH = N.asarray(WH, N.float).reshape(2,) self.Label = FloatCanvas.ScaledText(Label, XY, Size = TextSize, #Size = 10, Color = TextColor, Position = 'cc', ) if NodesHidden: LineStyle = u"ShortDash" else: LineStyle = None self.Ellipse = FloatCanvas.Ellipse( (XY - WH/2.0), WH, FillColor = BackgroundColor, LineStyle = LineStyle, LineColor = u"Red", LineWidth = 2, ) FloatCanvas.Group.__init__(self, [self.Ellipse, self.Label], InForeground, IsVisible) def GetConnectPoint(self): return self.BoundingBox.Center def GetName(self): #name = self.Name #if CASE_INSENSITIVE_WIKIWORDS: # name = name.lower() return self.Name class ConnectorLine(FloatCanvas.LineOnlyMixin, FloatCanvas.DrawObject,): """ A Line that connects two objects -- it uses the objects to get its coordinates The objects must have a GetConnectPoint() method. """ ##fixme: this should be added to the Main FloatCanvas Objects some day. def __init__(self, Object1, Object2, LineColor = u"Black", LineStyle = u"Solid", LineWidth = 1, InForeground = False): FloatCanvas.DrawObject.__init__(self, InForeground) self.Object1 = Object1 self.Object2 = Object2 self.LineColor = LineColor self.LineStyle = LineStyle self.LineWidth = LineWidth self.CalcBoundingBox() self.SetPen(LineColor,LineStyle,LineWidth) self.HitLineWidth = max(LineWidth,self.MinHitLineWidth) def CalcBoundingBox(self): self.BoundingBox = BBox.fromPoints((self.Object1.GetConnectPoint(), self.Object2.GetConnectPoint()) ) if self._Canvas: self._Canvas.BoundingBoxDirty = True def _Draw(self, dc , WorldToPixel, ScaleWorldToPixel, HTdc=None): Points = N.array( (self.Object1.GetConnectPoint(), self.Object2.GetConnectPoint()) ) Points = WorldToPixel(Points) dc.SetPen(self.Pen) dc.DrawLines(Points) if HTdc and self.HitAble: HTdc.SetPen(self.HitPen) HTdc.DrawLines(Points) class DrawFrame(wx.Frame): """ A frame used for the mindmap """ def __init__(self, *args, **kwargs): wx.Frame.__init__(self, *args, **kwargs) self.CreateStatusBar() # Add the Canvas NC = NavCanvas.NavCanvas(self,-1, CANVAS_SIZE, ProjectionFun = None, Debug = 0, BackgroundColor = u"DARK SLATE BLUE", ) self.Canvas = NC.Canvas self.Canvas.Bind(FloatCanvas.EVT_MOTION, self.OnMove) self.Canvas.Bind(FloatCanvas.EVT_LEFT_UP, self.OnLeftUp) self.Canvas.Bind(FloatCanvas.EVT_RIGHT_UP, self.OnRightUp) tb = NC.ToolBar tb.AddSeparator() StopButton = wx.Button(tb, wx.ID_ANY, u"Pause") tb.AddControl(StopButton) StopButton.Bind(wx.EVT_BUTTON, self.PauseLoading) PlayButton = wx.Button(tb, wx.ID_ANY, u"Resume") tb.AddControl(PlayButton) PlayButton.Bind(wx.EVT_BUTTON, self.ResumeLoading) tb.Realize() self.Show(True) self.MoveObject = None self.Moving = False self.GroupMove = False self.overlay = AlphaCircle((250, 250), 50000, FillStyle = 200) self.Overlay_Shown = False self.PauseLoading = False self.LoadingComplete = False self.ResetVariables() self.DisplayLoadingProgress() self.Bind(wx.EVT_IDLE, self.OnIdle) return None def PauseLoading(self, evt): self.PauseLoading = True self.DisplayLoadingProgress() def ResumeLoading(self, evt): self.PauseLoading = False self.DisplayLoadingProgress() def DisplayLoadingProgress(self): if self.LoadingComplete: text = u"Map of {0} loaded.".format(self.start_page) elif self.PauseLoading: text = u"Loading Paused..." else: text = u"Loading..." self.SetStatusText(text) def SetPWiki(self, pWiki): self.pWiki = pWiki def SetCurrentPage(self, page): self.start_page = page def ResetVariables(self): self.ConnectorsAll = set([]) self.Nodes = {} self.Drawn_Nodes = set([]) self.draw_node_queue = set([]) self.expand_node_queue = set([]) self.terminal_nodes = set([]) # TODO: if we move or zoom during loading the map should no longer # automatically zoom to bb self.Zoom_to_fit = True def DrawMindmap(self, start_page=None): print u"Building mindmap" self.ResetVariables() if start_page is None: self.SetCurrentPage(self.pWiki.getCurrentWikiWord()) else: self.start_page = start_page Canvas = self.Canvas D = 1.0 self.Canvas.Draw(True) # First create the center node (current page) root_node = Node(self.start_page, 0, (250, 250), BackgroundColor=u"Blue") root_node.Creator_Angle = None self.AddNode(root_node) self.draw_node_queue.add(root_node) self.DrawNode(root_node) self.expand_node_queue.add(root_node) self.UpdateMap() def OnIdle(self, evt): """ OnIdle is used so that map is gradually built and can be interacted with at the same time. """ if not self.PauseLoading: if self.expand_node_queue: self.LoadNode(self.expand_node_queue.pop()) # Once all nodes have been shown, try and create connections # that were missed earlier elif self.terminal_nodes: n = self.terminal_nodes.pop() self.CreateRelations(n) self.CreateConnectors(n) self.UpdateMap() else: self.LoadingComplete = True self.DisplayLoadingProgress() def LoadNode(self, current_node): """ The main function that handles the creation and positioning of nodes. """ #self.NodeThread = NodeThread(self, nodes, self.Nodes) #Thread(target=self.DrawWaitingNodes).start() depth = current_node.Depth + 1 if depth > MAX_DEPTH: self.terminal_nodes.add(current_node) return self.CreateRelations(current_node) self.DrawNode(current_node) # We only show a subset of nodes if depth > 1 if depth > 1: max_node_no = MAX_NODES rotate = True else: max_node_no = None rotate = False relations = current_node.GetRelations() current_node.SetNodeNumber(max_node_no) if not relations: # No related nodes return new_nodes = set([]) nodes_to_draw = set([]) # Create relation nodes if they exist pos = current_node.GetPosition() for page in relations: if self.GetNodeByName(page) is None: n = Node(page, depth, pos) self.AddNode(n) new_nodes.add(n) # Check if any nodes have already been drawn, in which case we # just have to add links nodes_to_draw = set([n for n in new_nodes if not self.IsInitialized(n)]) d = DISTANCE if len(nodes_to_draw) > 10: d = d*2 stagger = False if len(nodes_to_draw) > STAGGER_NODE_NO and depth == 1: stagger = True points = self.CalculateNodePositionsAndAngles(len(nodes_to_draw), current_node.Creator_Angle, 1/float(depth) * 1.5 * DISTANCE, max_node_no, stagger, rotate) connectors = [] n = 0 current_node.Drawn_Child_Nodes = nodes_to_draw for n in nodes_to_draw: if len(points) < 1: break pos, angle = points.pop() n.Creator_Angle = angle # TODO: set angle n.Move(pos) self.DrawNode(n) recursive = True if depth < MAX_DEPTH else False if recursive: self.expand_node_queue.add(n) else: self.terminal_nodes.add(n) #self.DrawNode(n, recursive) #self.draw_node_queue.add(n) self.CreateConnectors(current_node) self.UpdateMap() def ClearConnectors(self, draw_object): node = self.GetNodeByName(draw_object.GetName()) connectors = node.Connectors self.ConnectorsAll.difference(connectors) self.Canvas.RemoveObjects(connectors) self.CreateConnectors(node) def CreateConnectors(self, current_node): self.CreateRelations(current_node) connectors = set([]) for page in current_node.GetChildren(): n = self.GetNodeByName(page) if n is not None and n.Drawn: c = ConnectorLine(current_node.GetDrawObject(), n.GetDrawObject(), LineWidth=3, LineColor=u"Black") connectors.add(c) n.Connectors.add(c) for page in current_node.GetParents(): n = self.GetNodeByName(page) if n is not None and n.Drawn: c = ConnectorLine(n.GetDrawObject(), current_node.GetDrawObject(), LineWidth=3, LineColor=u"Black") connectors.add(c) n.Connectors.add(c) self.ConnectorsAll.update(connectors) current_node.Connectors = connectors self.Canvas.AddObjects(connectors) def CalculateNodePositionsAndAngles(self, n, start_angle, length, max_nodes, stagger=True, rotate=False): if n < 1: return [] if max_nodes is not None: n = min(n, max_nodes) points = set([]) if start_angle is not None: n += 1 offset = 0 angle = (2*math.pi)/n #if rotate: # offset = start_angle - angle for i in range(1, n+1): if stagger and i % 2 == 0: l = length * 2 else: l = length ang = angle * i if start_angle is not None: ang = math.pi + start_angle - angle + ang #if rotate: # #ang = ang + angle / 2 + math.pi - start_angle # if ang + start_angle == 2*math.pi: # ang = ang + math.pi if start_angle is not None and i == 1: continue points.add(((round(math.cos(ang) * l, 1), round(math.sin(ang) * l, 1)), ang)) return points def UpdateMap(self, evt=None): #[self.DrawNode(i) for i in self.draw_node_queue] #self.draw_node_queue = set([]) self.Canvas.RemoveObjects(self.Drawn_Nodes) self.Canvas.AddObjects(self.Drawn_Nodes) if not self.Overlay_Shown: self.Canvas.ZoomToBB() #self.DrawWaitingNodes() def IsInitialized(self, node): """ Check if a node has been drawn or is queued to be drawn """ if node.Drawn or node in self.draw_node_queue: return True else: return False def DrawNode(self, node, recursive=True): if node.Drawn: return self.CheckIsWikiWord(node) draw_object = node.CreateDrawObject() # This will probably be inefficient with lots of nodes. # It will also fail on later branches, however it appears to # be fine for small maps # It should be possible to group nodes (by their drawn parent) # and move the entire group if necessary ang = node.Creator_Angle for n in self.Drawn_Nodes: while draw_object.BoundingBox.Overlaps(n.BoundingBox): draw_object.Move((round(math.cos(ang) * 10, 1), round(math.sin(ang) * 10, 1))) # Add the node to the canvas self.Canvas.AddObject(draw_object) node.Drawn = True self.Drawn_Nodes.add(draw_object) # Bind events self.BindNode(node.GetDrawObject()) #if recursive: # self.DrawRelatedNodes(node) def AddNode(self, node): name = node.GetName() if CASE_INSENSITIVE_WIKIWORDS: name = name.lower() self.Nodes[name] = node def GetNodeByName(self, name): if CASE_INSENSITIVE_WIKIWORDS: name = name.lower() if name in self.Nodes: return self.Nodes[name] else: return None def NodeHitRight(self, node): self.GroupMove = True self.NodeHit(node) def NodeHit(self, node): """ Clicking and holding on a node allows it to be dragged """ print u"Node %s was hit, obj ID: %i"%(node.GetName(), id(node)) if not self.Moving: self.Moving = True self.StartPoint = node.HitCoordsPixel self.StartObject = self.Canvas.WorldToPixel(node.GetOutlinePoints()) self.MoveObject = None self.MovingObject = node def NodeDoubleHit(self, node): """ Called when node is doubleclicked on. Will open page node represents in current page and reload map with the new page centred """ page = node.GetName() self.pWiki.openWikiPage(page) self.Canvas.ClearAll() self.Nodes = {} self.draw_node_queue = set([]) self.ResetVariables() self.DrawMindmap() def NodeEndHover(self, draw_node): """ Called when mouse leaves node Remove node highlighting """ # There must be a better way to do this try: self.Canvas.RemoveObjects(self.temp_connectors) except: pass try: self.Canvas.RemoveObjects(self.temp_nodes) except: pass try: self.Canvas.RemoveObject(self.overlay) except: pass self.Overlay_Shown = False self.Canvas.Draw(True) def NodeHover(self, draw_node): """ When a node is hovered over, said node and its direct relations are highlighted """ print u"Node %s was hovered over, obj ID: %i"%(draw_node.GetName(), id(draw_node)) self.Overlay_Shown = True self.Canvas.AddObject(self.overlay) self.temp_connectors = [] self.temp_nodes = [] node = self.GetNodeByName(draw_node.GetName()) if not node.Relations_Created: self.CreateRelations(node) self.CreateConnectors(node) if node.Connectors: connectors = node.Connectors for i in connectors: node1 = self.GetNodeByName(i.Object1.GetName()) node2 = self.GetNodeByName(i.Object2.GetName()) if node2.GetName() in node.Parents: self.temp_connectors.append(ConnectorLine(node1.DrawObject, node2.DrawObject, LineWidth=3, LineColor="Red")) else: self.temp_connectors.append(ConnectorLine(node1.DrawObject, node2.DrawObject, LineWidth=3, LineColor="Blue")) scale = 2 # TODO: nodes which are both parent and child # cleanup for page in node.GetChildren(): n = self.GetNodeByName(page) if n is not None and self.IsInitialized(n): if n.GetPosition() is not None: # TODO: move into node function BB = n.GetDrawObject().BoundingBox WH = (2 * abs(BB[1][0] - BB[0][0]), 2 * abs(BB[1][1] - BB[0][1])) self.temp_nodes.append(NodeObject(n.Name, n.GetPosition(), WH, BackgroundColor = u"Yellow", TextColor = u"Black", TextSize = 20, NodesHidden=n.HiddenNodes())) for page in node.GetParents(): n = self.GetNodeByName(page) if n is not None and self.IsInitialized(n): if n.GetPosition() is not None: BB = n.GetDrawObject().BoundingBox WH = (2 * abs(BB[1][0] - BB[0][0]), 2 * abs(BB[1][1] - BB[0][1])) self.temp_nodes.append(NodeObject(n.Name, n.GetPosition(), WH, BackgroundColor = u"Orange", TextColor = u"Black", TextSize = 20, NodesHidden=n.HiddenNodes())) BB = node.GetDrawObject().BoundingBox WH = (2 * abs(BB[1][0] - BB[0][0]), 2 * abs(BB[1][1] - BB[0][1])) self.temp_nodes.append(NodeObject(node.Name, node.GetPosition(), WH, BackgroundColor = u"Green", TextColor = u"White", TruncateText = False, TextSize = 20, NodesHidden=node.HiddenNodes())) self.Canvas.AddObjects(self.temp_connectors) self.Canvas.AddObjects(self.temp_nodes) self.Canvas.Draw(True) def OnMove(self, event): """ Nodes can be dragged about TODO: Drag groups of nodes """ #self.SetStatusText("%.4f, %.4f"%tuple(event.Coords)) if self.Moving: dxy = event.GetPosition() - self.StartPoint # Draw the Moving Object: dc = wx.ClientDC(self.Canvas) if self.GroupMove: colour = u'RED' else: colour = u'WHITE' dc.SetPen(wx.Pen(colour, 2, wx.SHORT_DASH)) dc.SetBrush(wx.TRANSPARENT_BRUSH) dc.SetLogicalFunction(wx.XOR) if self.MoveObject is not None: dc.DrawPolygon(self.MoveObject) self.MoveObject = self.StartObject + dxy dc.DrawPolygon(self.MoveObject) def OnLeftUp(self, event): self.EndMove(event) def OnRightUp(self, event): self.EndMove(event) def EndMove(self, event): if self.Moving: self.Moving = False if self.MoveObject is not None: dxy = event.GetPosition() - self.StartPoint dxy = self.Canvas.ScalePixelToWorld(dxy) self.MovingObject.Move(dxy) # Move the entire if self.GroupMove: for i in self.Nodes[self.MovingObject.GetName()].Drawn_Child_Nodes: i.DrawObject.Move(dxy) self.GroupMove = False self.UpdateMap() #self.Canvas.Draw(True) def BindNode(self, DrawObject): DrawObject.Bind(FloatCanvas.EVT_FC_LEFT_DOWN, self.NodeHit) DrawObject.Bind(FloatCanvas.EVT_FC_RIGHT_DOWN, self.NodeHitRight) DrawObject.Bind(FloatCanvas.EVT_FC_LEFT_DCLICK, self.NodeDoubleHit) DrawObject.Bind(FloatCanvas.EVT_FC_ENTER_OBJECT, self.NodeHover) DrawObject.Bind(FloatCanvas.EVT_FC_LEAVE_OBJECT, self.NodeEndHover) def CreateRelations(self, node): """ Relation are not setup at the same time as node creation for speed reasons """ if node.Relations_Created: return node.AddParents(self.pWiki.getWikiData().getParentRelationships( node.Name)) node.AddChildren(self.pWiki.getWikiData().getChildRelationships( node.Name, EXISTING_PAGES_ONLY)) node.Relations_Created = True def CheckIsWikiWord(self, node): node.Page_Exists = self.pWiki.getWikiDocument().isDefinedWikiLinkTerm(node.Name) def mindMap(pwiki, evt): #mapApp = wx.App(False) m = DrawFrame(None, -1, u"WikidMap", wx.DefaultPosition, (700,700) ) m.Show() m.SetPWiki(pwiki) m.DrawMindmap()