PageRenderTime 63ms CodeModel.GetById 36ms RepoModel.GetById 0ms app.codeStats 0ms

/dotviewer/drawgraph.py

https://bitbucket.org/kcr/pypy
Python | 680 lines | 618 code | 49 blank | 13 comment | 59 complexity | fb7bebe53a93720a6f6aa47a774914b5 MD5 | raw file
Possible License(s): Apache-2.0
  1. """
  2. A custom graphic renderer for the '.plain' files produced by dot.
  3. """
  4. from __future__ import generators
  5. import re, os, math
  6. import pygame
  7. from pygame.locals import *
  8. from strunicode import forceunicode
  9. this_dir = os.path.dirname(os.path.abspath(__file__))
  10. FONT = os.path.join(this_dir, 'font', 'DroidSans.ttf')
  11. FIXEDFONT = os.path.join(this_dir, 'font', 'DroidSansMono.ttf')
  12. COLOR = {
  13. 'black': (0,0,0),
  14. 'white': (255,255,255),
  15. 'red': (255,0,0),
  16. 'green': (0,255,0),
  17. 'blue': (0,0,255),
  18. 'yellow': (255,255,0),
  19. }
  20. re_nonword=re.compile(r'([^0-9a-zA-Z_.]+)')
  21. def combine(color1, color2, alpha):
  22. r1, g1, b1 = color1
  23. r2, g2, b2 = color2
  24. beta = 1.0 - alpha
  25. return (int(r1 * alpha + r2 * beta),
  26. int(g1 * alpha + g2 * beta),
  27. int(b1 * alpha + b2 * beta))
  28. def highlight_color(color):
  29. if color == (0, 0, 0): # black becomes magenta
  30. return (255, 0, 255)
  31. elif color == (255, 255, 255): # white becomes yellow
  32. return (255, 255, 0)
  33. intensity = sum(color)
  34. if intensity > 191 * 3:
  35. return combine(color, (128, 192, 0), 0.2)
  36. else:
  37. return combine(color, (255, 255, 0), 0.2)
  38. def getcolor(name, default):
  39. if name in COLOR:
  40. return COLOR[name]
  41. elif name.startswith('#') and len(name) == 7:
  42. rval = COLOR[name] = (int(name[1:3],16), int(name[3:5],16), int(name[5:7],16))
  43. return rval
  44. else:
  45. return default
  46. class GraphLayout:
  47. fixedfont = False
  48. def __init__(self, scale, width, height):
  49. self.scale = scale
  50. self.boundingbox = width, height
  51. self.nodes = {}
  52. self.edges = []
  53. self.links = {}
  54. def add_node(self, *args):
  55. n = Node(*args)
  56. self.nodes[n.name] = n
  57. def add_edge(self, *args):
  58. self.edges.append(Edge(self.nodes, *args))
  59. def get_display(self):
  60. from graphdisplay import GraphDisplay
  61. return GraphDisplay(self)
  62. def display(self):
  63. self.get_display().run()
  64. def reload(self):
  65. return self
  66. # async interaction helpers
  67. def display_async_quit():
  68. pygame.event.post(pygame.event.Event(QUIT))
  69. def display_async_cmd(**kwds):
  70. pygame.event.post(pygame.event.Event(USEREVENT, **kwds))
  71. EventQueue = []
  72. def wait_for_events():
  73. if not EventQueue:
  74. EventQueue.append(pygame.event.wait())
  75. EventQueue.extend(pygame.event.get())
  76. def wait_for_async_cmd():
  77. # wait until another thread pushes a USEREVENT in the queue
  78. while True:
  79. wait_for_events()
  80. e = EventQueue.pop(0)
  81. if e.type in (USEREVENT, QUIT): # discard all other events
  82. break
  83. EventQueue.insert(0, e) # re-insert the event for further processing
  84. class Node:
  85. def __init__(self, name, x, y, w, h, label, style, shape, color, fillcolor):
  86. self.name = forceunicode(name)
  87. self.x = float(x)
  88. self.y = float(y)
  89. self.w = float(w)
  90. self.h = float(h)
  91. self.label = forceunicode(label)
  92. self.style = style
  93. self.shape = shape
  94. self.color = color
  95. self.fillcolor = fillcolor
  96. self.highlight = False
  97. def sethighlight(self, which):
  98. self.highlight = bool(which)
  99. class Edge:
  100. label = None
  101. def __init__(self, nodes, tail, head, cnt, *rest):
  102. self.tail = nodes[forceunicode(tail)]
  103. self.head = nodes[forceunicode(head)]
  104. cnt = int(cnt)
  105. self.points = [(float(rest[i]), float(rest[i+1]))
  106. for i in range(0, cnt*2, 2)]
  107. rest = rest[cnt*2:]
  108. if len(rest) > 2:
  109. self.label, xl, yl = rest[:3]
  110. self.xl = float(xl)
  111. self.yl = float(yl)
  112. rest = rest[3:]
  113. self.style, self.color = rest
  114. self.highlight = False
  115. self.cachedbezierpoints = None
  116. self.cachedarrowhead = None
  117. self.cachedlimits = None
  118. def sethighlight(self, which):
  119. self.highlight = bool(which)
  120. def limits(self):
  121. result = self.cachedlimits
  122. if result is None:
  123. points = self.bezierpoints()
  124. xs = [point[0] for point in points]
  125. ys = [point[1] for point in points]
  126. self.cachedlimits = result = (min(xs), max(ys), max(xs), min(ys))
  127. return result
  128. def bezierpoints(self):
  129. result = self.cachedbezierpoints
  130. if result is None:
  131. result = []
  132. pts = self.points
  133. for i in range(0, len(pts)-3, 3):
  134. result += beziercurve(pts[i], pts[i+1], pts[i+2], pts[i+3])
  135. self.cachedbezierpoints = result
  136. return result
  137. def arrowhead(self):
  138. result = self.cachedarrowhead
  139. if result is None:
  140. # we don't know if the list of points is in the right order
  141. # or not :-( try to guess...
  142. def dist(node, pt):
  143. return abs(node.x - pt[0]) + abs(node.y - pt[1])
  144. error_if_direct = (dist(self.head, self.points[-1]) +
  145. dist(self.tail, self.points[0]))
  146. error_if_reversed = (dist(self.tail, self.points[-1]) +
  147. dist(self.head, self.points[0]))
  148. if error_if_direct > error_if_reversed: # reversed edge
  149. head = 0
  150. dir = 1
  151. else:
  152. head = -1
  153. dir = -1
  154. n = 1
  155. while True:
  156. try:
  157. x0, y0 = self.points[head]
  158. x1, y1 = self.points[head+n*dir]
  159. except IndexError:
  160. result = []
  161. break
  162. vx = x0-x1
  163. vy = y0-y1
  164. try:
  165. f = 0.12 / math.sqrt(vx*vx + vy*vy)
  166. vx *= f
  167. vy *= f
  168. result = [(x0 + 0.9*vx, y0 + 0.9*vy),
  169. (x0 + 0.4*vy, y0 - 0.4*vx),
  170. (x0 - 0.4*vy, y0 + 0.4*vx)]
  171. break
  172. except (ZeroDivisionError, ValueError):
  173. n += 1
  174. self.cachedarrowhead = result
  175. return result
  176. def beziercurve((x0,y0), (x1,y1), (x2,y2), (x3,y3), resolution=8):
  177. result = []
  178. f = 1.0/(resolution-1)
  179. append = result.append
  180. for i in range(resolution):
  181. t = f*i
  182. t0 = (1-t)*(1-t)*(1-t)
  183. t1 = t *(1-t)*(1-t) * 3.0
  184. t2 = t * t *(1-t) * 3.0
  185. t3 = t * t * t
  186. append((x0*t0 + x1*t1 + x2*t2 + x3*t3,
  187. y0*t0 + y1*t1 + y2*t2 + y3*t3))
  188. return result
  189. def segmentdistance((x0,y0), (x1,y1), (x,y)):
  190. "Distance between the point (x,y) and the segment (x0,y0)-(x1,y1)."
  191. vx = x1-x0
  192. vy = y1-y0
  193. try:
  194. l = math.hypot(vx, vy)
  195. vx /= l
  196. vy /= l
  197. dlong = vx*(x-x0) + vy*(y-y0)
  198. except (ZeroDivisionError, ValueError):
  199. dlong = -1
  200. if dlong < 0.0:
  201. return math.hypot(x-x0, y-y0)
  202. elif dlong > l:
  203. return math.hypot(x-x1, y-y1)
  204. else:
  205. return abs(vy*(x-x0) - vx*(y-y0))
  206. class GraphRenderer:
  207. MARGIN = 0.6
  208. SCALEMIN = 3
  209. SCALEMAX = 100
  210. FONTCACHE = {}
  211. def __init__(self, screen, graphlayout, scale=75):
  212. self.graphlayout = graphlayout
  213. self.setscale(scale)
  214. self.setoffset(0, 0)
  215. self.screen = screen
  216. self.textzones = []
  217. self.highlightwords = graphlayout.links
  218. self.highlight_word = None
  219. self.visiblenodes = []
  220. self.visibleedges = []
  221. def wordcolor(self, word):
  222. info = self.highlightwords[word]
  223. if isinstance(info, tuple) and len(info) >= 2:
  224. color = info[1]
  225. else:
  226. color = None
  227. if color is None:
  228. color = (128,0,0)
  229. if word == self.highlight_word:
  230. return ((255,255,80), color)
  231. else:
  232. return (color, None)
  233. def setscale(self, scale):
  234. scale = max(min(scale, self.SCALEMAX), self.SCALEMIN)
  235. self.scale = float(scale)
  236. w, h = self.graphlayout.boundingbox
  237. self.margin = int(self.MARGIN * scale)
  238. self.width = int(w * scale) + (2 * self.margin)
  239. self.height = int(h * scale) + (2 * self.margin)
  240. self.bboxh = h
  241. size = int(15 * (scale-10) / 75)
  242. self.font = self.getfont(size)
  243. def getfont(self, size):
  244. if size in self.FONTCACHE:
  245. return self.FONTCACHE[size]
  246. elif size < 5:
  247. self.FONTCACHE[size] = None
  248. return None
  249. else:
  250. if self.graphlayout.fixedfont:
  251. filename = FIXEDFONT
  252. else:
  253. filename = FONT
  254. font = self.FONTCACHE[size] = pygame.font.Font(filename, size)
  255. return font
  256. def setoffset(self, offsetx, offsety):
  257. "Set the (x,y) origin of the rectangle where the graph will be rendered."
  258. self.ofsx = offsetx - self.margin
  259. self.ofsy = offsety - self.margin
  260. def shiftoffset(self, dx, dy):
  261. self.ofsx += dx
  262. self.ofsy += dy
  263. def getcenter(self):
  264. w, h = self.screen.get_size()
  265. return self.revmap(w//2, h//2)
  266. def setcenter(self, x, y):
  267. w, h = self.screen.get_size()
  268. x, y = self.map(x, y)
  269. self.shiftoffset(x-w//2, y-h//2)
  270. def shiftscale(self, factor, fix=None):
  271. if fix is None:
  272. fixx, fixy = self.screen.get_size()
  273. fixx //= 2
  274. fixy //= 2
  275. else:
  276. fixx, fixy = fix
  277. x, y = self.revmap(fixx, fixy)
  278. self.setscale(self.scale * factor)
  279. newx, newy = self.map(x, y)
  280. self.shiftoffset(newx - fixx, newy - fixy)
  281. def reoffset(self, swidth, sheight):
  282. offsetx = noffsetx = self.ofsx
  283. offsety = noffsety = self.ofsy
  284. width = self.width
  285. height = self.height
  286. # if it fits, center it, otherwise clamp
  287. if width <= swidth:
  288. noffsetx = (width - swidth) // 2
  289. else:
  290. noffsetx = min(max(0, offsetx), width - swidth)
  291. if height <= sheight:
  292. noffsety = (height - sheight) // 2
  293. else:
  294. noffsety = min(max(0, offsety), height - sheight)
  295. self.ofsx = noffsetx
  296. self.ofsy = noffsety
  297. def getboundingbox(self):
  298. "Get the rectangle where the graph will be rendered."
  299. return (-self.ofsx, -self.ofsy, self.width, self.height)
  300. def visible(self, x1, y1, x2, y2):
  301. """Is any part of the box visible (i.e. within the bounding box)?
  302. We have to perform clipping ourselves because with big graphs the
  303. coordinates may sometimes become longs and cause OverflowErrors
  304. within pygame.
  305. """
  306. w, h = self.screen.get_size()
  307. return x1 < w and x2 > 0 and y1 < h and y2 > 0
  308. def computevisible(self):
  309. del self.visiblenodes[:]
  310. del self.visibleedges[:]
  311. w, h = self.screen.get_size()
  312. for node in self.graphlayout.nodes.values():
  313. x, y = self.map(node.x, node.y)
  314. nw2 = int(node.w * self.scale)//2
  315. nh2 = int(node.h * self.scale)//2
  316. if x-nw2 < w and x+nw2 > 0 and y-nh2 < h and y+nh2 > 0:
  317. self.visiblenodes.append(node)
  318. for edge in self.graphlayout.edges:
  319. x1, y1, x2, y2 = edge.limits()
  320. x1, y1 = self.map(x1, y1)
  321. if x1 < w and y1 < h:
  322. x2, y2 = self.map(x2, y2)
  323. if x2 > 0 and y2 > 0:
  324. self.visibleedges.append(edge)
  325. def map(self, x, y):
  326. return (int(x*self.scale) - (self.ofsx - self.margin),
  327. int((self.bboxh-y)*self.scale) - (self.ofsy - self.margin))
  328. def revmap(self, px, py):
  329. return ((px + (self.ofsx - self.margin)) / self.scale,
  330. self.bboxh - (py + (self.ofsy - self.margin)) / self.scale)
  331. def draw_node_commands(self, node):
  332. xcenter, ycenter = self.map(node.x, node.y)
  333. boxwidth = int(node.w * self.scale)
  334. boxheight = int(node.h * self.scale)
  335. fgcolor = getcolor(node.color, (0,0,0))
  336. bgcolor = getcolor(node.fillcolor, (255,255,255))
  337. if node.highlight:
  338. fgcolor = highlight_color(fgcolor)
  339. bgcolor = highlight_color(bgcolor)
  340. text = node.label
  341. lines = text.replace('\\l','\\l\n').replace('\r','\r\n').split('\n')
  342. # ignore a final newline
  343. if not lines[-1]:
  344. del lines[-1]
  345. wmax = 0
  346. hmax = 0
  347. commands = []
  348. bkgndcommands = []
  349. if self.font is None:
  350. if lines:
  351. raw_line = lines[0].replace('\\l','').replace('\r','')
  352. if raw_line:
  353. for size in (12, 10, 8, 6, 4):
  354. font = self.getfont(size)
  355. img = TextSnippet(self, raw_line, (0, 0, 0), bgcolor, font=font)
  356. w, h = img.get_size()
  357. if (w >= boxwidth or h >= boxheight):
  358. continue
  359. else:
  360. if w>wmax: wmax = w
  361. def cmd(img=img, y=hmax, w=w):
  362. img.draw(xcenter-w//2, ytop+y)
  363. commands.append(cmd)
  364. hmax += h
  365. break
  366. else:
  367. for line in lines:
  368. raw_line = line.replace('\\l','').replace('\r','') or ' '
  369. if '\f' in raw_line: # grayed out parts of the line
  370. imgs = []
  371. graytext = True
  372. h = 16
  373. w_total = 0
  374. for linepart in raw_line.split('\f'):
  375. graytext = not graytext
  376. if not linepart.strip():
  377. continue
  378. if graytext:
  379. fgcolor = (128, 160, 160)
  380. else:
  381. fgcolor = (0, 0, 0)
  382. img = TextSnippet(self, linepart, fgcolor, bgcolor)
  383. imgs.append((w_total, img))
  384. w, h = img.get_size()
  385. w_total += w
  386. if w_total > wmax: wmax = w_total
  387. def cmd(imgs=imgs, y=hmax):
  388. for x, img in imgs:
  389. img.draw(xleft+x, ytop+y)
  390. commands.append(cmd)
  391. else:
  392. img = TextSnippet(self, raw_line, (0, 0, 0), bgcolor)
  393. w, h = img.get_size()
  394. if w>wmax: wmax = w
  395. if raw_line.strip():
  396. if line.endswith('\\l'):
  397. def cmd(img=img, y=hmax):
  398. img.draw(xleft, ytop+y)
  399. elif line.endswith('\r'):
  400. def cmd(img=img, y=hmax, w=w):
  401. img.draw(xright-w, ytop+y)
  402. else:
  403. def cmd(img=img, y=hmax, w=w):
  404. img.draw(xcenter-w//2, ytop+y)
  405. commands.append(cmd)
  406. hmax += h
  407. #hmax += 8
  408. # we know the bounding box only now; setting these variables will
  409. # have an effect on the values seen inside the cmd() functions above
  410. xleft = xcenter - wmax//2
  411. xright = xcenter + wmax//2
  412. ytop = ycenter - hmax//2
  413. x = xcenter-boxwidth//2
  414. y = ycenter-boxheight//2
  415. if node.shape == 'box':
  416. rect = (x-1, y-1, boxwidth+2, boxheight+2)
  417. def cmd():
  418. self.screen.fill(bgcolor, rect)
  419. bkgndcommands.append(cmd)
  420. def cmd():
  421. pygame.draw.rect(self.screen, fgcolor, rect, 1)
  422. commands.append(cmd)
  423. elif node.shape == 'ellipse':
  424. rect = (x-1, y-1, boxwidth+2, boxheight+2)
  425. def cmd():
  426. pygame.draw.ellipse(self.screen, bgcolor, rect, 0)
  427. bkgndcommands.append(cmd)
  428. def cmd():
  429. pygame.draw.ellipse(self.screen, fgcolor, rect, 1)
  430. commands.append(cmd)
  431. elif node.shape == 'octagon':
  432. step = 1-math.sqrt(2)/2
  433. points = [(int(x+boxwidth*fx), int(y+boxheight*fy))
  434. for fx, fy in [(step,0), (1-step,0),
  435. (1,step), (1,1-step),
  436. (1-step,1), (step,1),
  437. (0,1-step), (0,step)]]
  438. def cmd():
  439. pygame.draw.polygon(self.screen, bgcolor, points, 0)
  440. bkgndcommands.append(cmd)
  441. def cmd():
  442. pygame.draw.polygon(self.screen, fgcolor, points, 1)
  443. commands.append(cmd)
  444. return bkgndcommands, commands
  445. def draw_commands(self):
  446. nodebkgndcmd = []
  447. nodecmd = []
  448. for node in self.visiblenodes:
  449. cmd1, cmd2 = self.draw_node_commands(node)
  450. nodebkgndcmd += cmd1
  451. nodecmd += cmd2
  452. edgebodycmd = []
  453. edgeheadcmd = []
  454. for edge in self.visibleedges:
  455. fgcolor = getcolor(edge.color, (0,0,0))
  456. if edge.highlight:
  457. fgcolor = highlight_color(fgcolor)
  458. points = [self.map(*xy) for xy in edge.bezierpoints()]
  459. def drawedgebody(points=points, fgcolor=fgcolor):
  460. pygame.draw.lines(self.screen, fgcolor, False, points)
  461. edgebodycmd.append(drawedgebody)
  462. points = [self.map(*xy) for xy in edge.arrowhead()]
  463. if points:
  464. def drawedgehead(points=points, fgcolor=fgcolor):
  465. pygame.draw.polygon(self.screen, fgcolor, points, 0)
  466. edgeheadcmd.append(drawedgehead)
  467. if edge.label:
  468. x, y = self.map(edge.xl, edge.yl)
  469. img = TextSnippet(self, edge.label, (0, 0, 0))
  470. w, h = img.get_size()
  471. if self.visible(x-w//2, y-h//2, x+w//2, y+h//2):
  472. def drawedgelabel(img=img, x1=x-w//2, y1=y-h//2):
  473. img.draw(x1, y1)
  474. edgeheadcmd.append(drawedgelabel)
  475. return edgebodycmd + nodebkgndcmd + edgeheadcmd + nodecmd
  476. def render(self):
  477. self.computevisible()
  478. bbox = self.getboundingbox()
  479. ox, oy, width, height = bbox
  480. dpy_width, dpy_height = self.screen.get_size()
  481. # some versions of the SDL misinterpret widely out-of-range values,
  482. # so clamp them
  483. if ox < 0:
  484. width += ox
  485. ox = 0
  486. if oy < 0:
  487. height += oy
  488. oy = 0
  489. if width > dpy_width:
  490. width = dpy_width
  491. if height > dpy_height:
  492. height = dpy_height
  493. self.screen.fill((224, 255, 224), (ox, oy, width, height))
  494. # gray off-bkgnd areas
  495. gray = (128, 128, 128)
  496. if ox > 0:
  497. self.screen.fill(gray, (0, 0, ox, dpy_height))
  498. if oy > 0:
  499. self.screen.fill(gray, (0, 0, dpy_width, oy))
  500. w = dpy_width - (ox + width)
  501. if w > 0:
  502. self.screen.fill(gray, (dpy_width-w, 0, w, dpy_height))
  503. h = dpy_height - (oy + height)
  504. if h > 0:
  505. self.screen.fill(gray, (0, dpy_height-h, dpy_width, h))
  506. # draw the graph and record the position of texts
  507. del self.textzones[:]
  508. for cmd in self.draw_commands():
  509. cmd()
  510. def findall(self, searchstr):
  511. """Return an iterator for all nodes and edges that contain a searchstr.
  512. """
  513. for item in self.graphlayout.nodes.itervalues():
  514. if item.label and searchstr in item.label:
  515. yield item
  516. for item in self.graphlayout.edges:
  517. if item.label and searchstr in item.label:
  518. yield item
  519. def at_position(self, (x, y)):
  520. """Figure out the word under the cursor."""
  521. for rx, ry, rw, rh, word in self.textzones:
  522. if rx <= x < rx+rw and ry <= y < ry+rh:
  523. return word
  524. return None
  525. def node_at_position(self, (x, y)):
  526. """Return the Node under the cursor."""
  527. x, y = self.revmap(x, y)
  528. for node in self.visiblenodes:
  529. if 2.0*abs(x-node.x) <= node.w and 2.0*abs(y-node.y) <= node.h:
  530. return node
  531. return None
  532. def edge_at_position(self, (x, y), distmax=14):
  533. """Return the Edge near the cursor."""
  534. # XXX this function is very CPU-intensive and makes the display kinda sluggish
  535. distmax /= self.scale
  536. xy = self.revmap(x, y)
  537. closest_edge = None
  538. for edge in self.visibleedges:
  539. pts = edge.bezierpoints()
  540. for i in range(1, len(pts)):
  541. d = segmentdistance(pts[i-1], pts[i], xy)
  542. if d < distmax:
  543. distmax = d
  544. closest_edge = edge
  545. return closest_edge
  546. class TextSnippet:
  547. def __init__(self, renderer, text, fgcolor, bgcolor=None, font=None):
  548. self.renderer = renderer
  549. self.imgs = []
  550. self.parts = []
  551. if font is None:
  552. font = renderer.font
  553. if font is None:
  554. return
  555. parts = self.parts
  556. for word in re_nonword.split(text):
  557. if not word:
  558. continue
  559. if word in renderer.highlightwords:
  560. fg, bg = renderer.wordcolor(word)
  561. bg = bg or bgcolor
  562. else:
  563. fg, bg = fgcolor, bgcolor
  564. parts.append((word, fg, bg))
  565. # consolidate sequences of words with the same color
  566. for i in range(len(parts)-2, -1, -1):
  567. if parts[i][1:] == parts[i+1][1:]:
  568. word, fg, bg = parts[i]
  569. parts[i] = word + parts[i+1][0], fg, bg
  570. del parts[i+1]
  571. # delete None backgrounds
  572. for i in range(len(parts)):
  573. if parts[i][2] is None:
  574. parts[i] = parts[i][:2]
  575. # render parts
  576. i = 0
  577. while i < len(parts):
  578. part = parts[i]
  579. word = part[0]
  580. try:
  581. img = font.render(word, True, *part[1:])
  582. except pygame.error:
  583. del parts[i] # Text has zero width
  584. else:
  585. self.imgs.append(img)
  586. i += 1
  587. def get_size(self):
  588. if self.imgs:
  589. sizes = [img.get_size() for img in self.imgs]
  590. return sum([w for w,h in sizes]), max([h for w,h in sizes])
  591. else:
  592. return 0, 0
  593. def draw(self, x, y):
  594. for part, img in zip(self.parts, self.imgs):
  595. word = part[0]
  596. self.renderer.screen.blit(img, (x, y))
  597. w, h = img.get_size()
  598. self.renderer.textzones.append((x, y, w, h, word))
  599. x += w