* Always pan to the first result
* Show the number of results
* Provide a button to pan to the next result
... | ... |
@@ -92,6 +92,9 @@ class Shape: |
92 | 92 |
ya, yb = min(ya, y0), max(yb, y1) |
93 | 93 |
return xa, ya, xb, yb |
94 | 94 |
|
95 |
+ def get_text(self): |
|
96 |
+ return None |
|
97 |
+ |
|
95 | 98 |
|
96 | 99 |
class TextShape(Shape): |
97 | 100 |
|
... | ... |
@@ -211,6 +214,9 @@ class TextShape(Shape): |
211 | 214 |
x, w, j = self.x, self.w, self.j |
212 | 215 |
return x - 0.5 * (1 + j) * w, -_inf, x + 0.5 * (1 - j) * w, _inf |
213 | 216 |
|
217 |
+ def get_text(self): |
|
218 |
+ return self.t |
|
219 |
+ |
|
214 | 220 |
|
215 | 221 |
class ImageShape(Shape): |
216 | 222 |
|
... | ... |
@@ -494,6 +500,13 @@ class CompoundShape(Shape): |
494 | 500 |
return True |
495 | 501 |
return False |
496 | 502 |
|
503 |
+ def get_text(self): |
|
504 |
+ for shape in self.shapes: |
|
505 |
+ text = shape.get_text() |
|
506 |
+ if text is not None: |
|
507 |
+ return text |
|
508 |
+ return None |
|
509 |
+ |
|
497 | 510 |
|
498 | 511 |
class Url(object): |
499 | 512 |
|
- changed Edge to yield Jump everywhere along the curve
- changed Edge and Graph such that edges are highlighted only when directly connected to element under cursor
- fixed issue related to non-Bezier shapes in Edge
- replaced precomputation of points by distance to curve computation
- added requirement numpy to travis configuration
- added method get_smallest_distance to Shape
- moved Edge._get_distance to BezierShape.get_smallest_distance
- changed BezierShape.get_smallest_distance to include endpoints
... | ... |
@@ -26,6 +26,7 @@ from gi.repository import GdkPixbuf |
26 | 26 |
from gi.repository import Pango |
27 | 27 |
from gi.repository import PangoCairo |
28 | 28 |
import cairo |
29 |
+import numpy |
|
29 | 30 |
|
30 | 31 |
_inf = float('inf') |
31 | 32 |
_get_bounding = operator.attrgetter('bounding') |
... | ... |
@@ -68,6 +69,9 @@ class Shape: |
68 | 69 |
def search_text(self, regexp): |
69 | 70 |
return False |
70 | 71 |
|
72 |
+ def get_smallest_distance(self, x, y): |
|
73 |
+ return None |
|
74 |
+ |
|
71 | 75 |
@staticmethod |
72 | 76 |
def _bounds_from_points(points): |
73 | 77 |
x0, y0 = points[0] |
... | ... |
@@ -408,6 +412,67 @@ class BezierShape(Shape): |
408 | 412 |
cr.set_source_rgba(*pen.color) |
409 | 413 |
cr.stroke() |
410 | 414 |
|
415 |
+ def get_smallest_distance(self, x, y): |
|
416 |
+ min_squared_distance = float('inf') |
|
417 |
+ |
|
418 |
+ points_iter = iter(self.points) |
|
419 |
+ x0, y0 = next(points_iter) |
|
420 |
+ |
|
421 |
+ while True: |
|
422 |
+ try: |
|
423 |
+ x1, y1 = next(points_iter) |
|
424 |
+ |
|
425 |
+ except StopIteration: |
|
426 |
+ break |
|
427 |
+ |
|
428 |
+ x2, y2 = next(points_iter) |
|
429 |
+ x3, y3 = next(points_iter) |
|
430 |
+ |
|
431 |
+ _e1 = -5 |
|
432 |
+ _e2 = (x0 - 3 * x1 + 3 * x2 - x3) |
|
433 |
+ _e3 = (y0 - 3 * y1 + 3 * y2 - y3) |
|
434 |
+ _e4 = 2 * x1 |
|
435 |
+ _e5 = 2 * y1 |
|
436 |
+ _e6 = x0**2 |
|
437 |
+ _e7 = y0**2 |
|
438 |
+ _e8 = -2 |
|
439 |
+ _e9 = (x0 - _e4 + x2) |
|
440 |
+ _e10 = (y0 - _e5 + y2) |
|
441 |
+ _e11 = 2 * x0 |
|
442 |
+ _e12 = 2 * y0 |
|
443 |
+ _e13 = 5 * _e6 |
|
444 |
+ _e14 = 5 * _e7 |
|
445 |
+ _e15 = x1**2 |
|
446 |
+ _e16 = y1**2 |
|
447 |
+ coefficients = [ |
|
448 |
+ (x - x0) * (x0 - x1) + (y - y0) * (y0 - y1), |
|
449 |
+ _e13 + 3 * _e15 + _e11 * (_e1 * x1 + x2) - 2 * x * _e9 + _e14 + 3 * _e16 + _e12 * (_e1 * y1 + y2) - 2 * y * _e10, |
|
450 |
+ -10 * _e6 + 9 * x1 * (_e8 * x1 + x2) + x * _e2 + x0 * (30 * x1 - 12 * x2 + x3) - 10 * _e7 + 9 * y1 * (_e8 * y1 + y2) + y * _e3 + y0 * (30 * y1 - 12 * y2 + y3), |
|
451 |
+ 2 * (_e13 + 18 * _e15 + 3 * x2**2 + _e4 * (-9 * x2 + x3) - _e11 * (10 * x1 - 6 * x2 + x3) + _e14 + 3 * (6 * _e16 - 6 * y1 * y2 + y2**2) + _e5 * y3 - _e12 * (10 * y1 - 6 * y2 + y3)), |
|
452 |
+ _e1 * _e9 * _e2 - 5 * _e10 * _e3, |
|
453 |
+ _e2**2 + _e3**2 |
|
454 |
+ ] |
|
455 |
+ coefficients.reverse() |
|
456 |
+ |
|
457 |
+ for t in numpy.roots(coefficients): |
|
458 |
+ if 1e-6 < abs(t.imag): |
|
459 |
+ continue |
|
460 |
+ |
|
461 |
+ t = t.real |
|
462 |
+ if t < 0: |
|
463 |
+ t = 0 |
|
464 |
+ elif 1 < t: |
|
465 |
+ t = 1 |
|
466 |
+ |
|
467 |
+ squared_distance = ((1 - t)**3 * x0 + 3 * (1 - t)**2 * t * x1 + 3 * (1 - t) * t**2 * x2 + t**3 * x3 - x)**2 + ((1 - t)**3 * y0 + 3 * (1 - t)**2 * t * y1 + 3 * (1 - t) * t**2 * y2 + t**3 * y3 - y)**2 |
|
468 |
+ if squared_distance < min_squared_distance: |
|
469 |
+ min_squared_distance = squared_distance |
|
470 |
+ |
|
471 |
+ x0 = x3 |
|
472 |
+ y0 = y3 |
|
473 |
+ |
|
474 |
+ return math.sqrt(min_squared_distance) |
|
475 |
+ |
|
411 | 476 |
|
412 | 477 |
class CompoundShape(Shape): |
413 | 478 |
|
... | ... |
@@ -534,10 +599,19 @@ class Edge(Element): |
534 | 599 |
return False |
535 | 600 |
|
536 | 601 |
def get_jump(self, x, y): |
537 |
- if self.is_inside_begin(x, y): |
|
538 |
- return Jump(self, self.dst.x, self.dst.y, highlight=set([self, self.dst])) |
|
539 |
- if self.is_inside_end(x, y): |
|
540 |
- return Jump(self, self.src.x, self.src.y, highlight=set([self, self.src])) |
|
602 |
+ for shape in self.shapes: |
|
603 |
+ x1, y1, x2, y2 = shape.bounding |
|
604 |
+ if (x1 - self.RADIUS) <= x and x <= (x2 + self.RADIUS) and (y1 - self.RADIUS) <= y and y <= (y2 + self.RADIUS): |
|
605 |
+ break |
|
606 |
+ |
|
607 |
+ else: |
|
608 |
+ return None |
|
609 |
+ |
|
610 |
+ for shape in self.shapes: |
|
611 |
+ distance = shape.get_smallest_distance(x, y) |
|
612 |
+ if distance is not None and distance <= self.RADIUS: |
|
613 |
+ return Jump(self, self.src.x, self.src.y) |
|
614 |
+ |
|
541 | 615 |
return None |
542 | 616 |
|
543 | 617 |
def __repr__(self): |
... | ... |
@@ -570,9 +644,17 @@ class Graph(Shape): |
570 | 644 |
shape._draw(cr, highlight=False, bounding=bounding) |
571 | 645 |
|
572 | 646 |
def _draw_nodes(self, cr, bounding, highlight_items): |
647 |
+ highlight_nodes = [] |
|
648 |
+ for element in highlight_items: |
|
649 |
+ if isinstance(element, Edge): |
|
650 |
+ highlight_nodes.append(element.src) |
|
651 |
+ highlight_nodes.append(element.dst) |
|
652 |
+ else: |
|
653 |
+ highlight_nodes.append(element) |
|
654 |
+ |
|
573 | 655 |
for node in self.nodes: |
574 | 656 |
if bounding is None or node._intersects(bounding): |
575 |
- node._draw(cr, highlight=(node in highlight_items), bounding=bounding) |
|
657 |
+ node._draw(cr, highlight=(node in highlight_nodes), bounding=bounding) |
|
576 | 658 |
|
577 | 659 |
def _draw_edges(self, cr, bounding, highlight_items): |
578 | 660 |
for edge in self.edges: |
- added support for tooltips
- fixed issue related to residual tooltip windows
- added tooltip window resize to label size
See https://github.com/jrfonseca/xdot.py/pull/81
Fixes https://github.com/jrfonseca/xdot.py/issues/71
... | ... |
@@ -469,7 +469,7 @@ class Element(CompoundShape): |
469 | 469 |
|
470 | 470 |
class Node(Element): |
471 | 471 |
|
472 |
- def __init__(self, id, x, y, w, h, shapes, url): |
|
472 |
+ def __init__(self, id, x, y, w, h, shapes, url, tooltip): |
|
473 | 473 |
Element.__init__(self, shapes) |
474 | 474 |
|
475 | 475 |
self.id = id |
... | ... |
@@ -482,6 +482,7 @@ class Node(Element): |
482 | 482 |
self.y2 = y + 0.5*h |
483 | 483 |
|
484 | 484 |
self.url = url |
485 |
+ self.tooltip = tooltip |
|
485 | 486 |
|
486 | 487 |
def is_inside(self, x, y): |
487 | 488 |
return self.x1 <= x and x <= self.x2 and self.y1 <= y and y <= self.y2 |
... | ... |
@@ -510,11 +511,12 @@ def square_distance(x1, y1, x2, y2): |
510 | 511 |
|
511 | 512 |
class Edge(Element): |
512 | 513 |
|
513 |
- def __init__(self, src, dst, points, shapes): |
|
514 |
+ def __init__(self, src, dst, points, shapes, tooltip): |
|
514 | 515 |
Element.__init__(self, shapes) |
515 | 516 |
self.src = src |
516 | 517 |
self.dst = dst |
517 | 518 |
self.points = points |
519 |
+ self.tooltip = tooltip |
|
518 | 520 |
|
519 | 521 |
RADIUS = 10 |
520 | 522 |
|
https://github.com/jrfonseca/xdot.py/pull/68
... | ... |
@@ -544,7 +544,7 @@ class Edge(Element): |
544 | 544 |
|
545 | 545 |
class Graph(Shape): |
546 | 546 |
|
547 |
- def __init__(self, width=1, height=1, shapes=(), nodes=(), edges=()): |
|
547 |
+ def __init__(self, width=1, height=1, shapes=(), nodes=(), edges=(), outputorder='breadthfirst'): |
|
548 | 548 |
Shape.__init__(self) |
549 | 549 |
|
550 | 550 |
self.width = width |
... | ... |
@@ -552,6 +552,7 @@ class Graph(Shape): |
552 | 552 |
self.shapes = shapes |
553 | 553 |
self.nodes = nodes |
554 | 554 |
self.edges = edges |
555 |
+ self.outputorder = outputorder |
|
555 | 556 |
|
556 | 557 |
self.bounding = Shape._envelope_bounds( |
557 | 558 |
map(_get_bounding, self.shapes), |
... | ... |
@@ -561,6 +562,23 @@ class Graph(Shape): |
561 | 562 |
def get_size(self): |
562 | 563 |
return self.width, self.height |
563 | 564 |
|
565 |
+ def _draw_shapes(self, cr, bounding): |
|
566 |
+ for shape in self.shapes: |
|
567 |
+ if bounding is None or shape._intersects(bounding): |
|
568 |
+ shape._draw(cr, highlight=False, bounding=bounding) |
|
569 |
+ |
|
570 |
+ def _draw_nodes(self, cr, bounding, highlight_items): |
|
571 |
+ for node in self.nodes: |
|
572 |
+ if bounding is None or node._intersects(bounding): |
|
573 |
+ node._draw(cr, highlight=(node in highlight_items), bounding=bounding) |
|
574 |
+ |
|
575 |
+ def _draw_edges(self, cr, bounding, highlight_items): |
|
576 |
+ for edge in self.edges: |
|
577 |
+ if bounding is None or edge._intersects(bounding): |
|
578 |
+ should_highlight = any(e in highlight_items |
|
579 |
+ for e in (edge, edge.src, edge.dst)) |
|
580 |
+ edge._draw(cr, highlight=should_highlight, bounding=bounding) |
|
581 |
+ |
|
564 | 582 |
def draw(self, cr, highlight_items=None, bounding=None): |
565 | 583 |
if bounding is not None: |
566 | 584 |
if not self._intersects(bounding): |
... | ... |
@@ -575,17 +593,13 @@ class Graph(Shape): |
575 | 593 |
cr.set_line_cap(cairo.LINE_CAP_BUTT) |
576 | 594 |
cr.set_line_join(cairo.LINE_JOIN_MITER) |
577 | 595 |
|
578 |
- for shape in self.shapes: |
|
579 |
- if bounding is None or shape._intersects(bounding): |
|
580 |
- shape._draw(cr, highlight=False, bounding=bounding) |
|
581 |
- for edge in self.edges: |
|
582 |
- if bounding is None or edge._intersects(bounding): |
|
583 |
- should_highlight = any(e in highlight_items |
|
584 |
- for e in (edge, edge.src, edge.dst)) |
|
585 |
- edge._draw(cr, highlight=should_highlight, bounding=bounding) |
|
586 |
- for node in self.nodes: |
|
587 |
- if bounding is None or node._intersects(bounding): |
|
588 |
- node._draw(cr, highlight=(node in highlight_items), bounding=bounding) |
|
596 |
+ self._draw_shapes(cr, bounding) |
|
597 |
+ if self.outputorder == 'edgesfirst': |
|
598 |
+ self._draw_edges(cr, bounding, highlight_items) |
|
599 |
+ self._draw_nodes(cr, bounding, highlight_items) |
|
600 |
+ else: |
|
601 |
+ self._draw_nodes(cr, bounding, highlight_items) |
|
602 |
+ self._draw_edges(cr, bounding, highlight_items) |
|
589 | 603 |
|
590 | 604 |
def get_element(self, x, y): |
591 | 605 |
for node in self.nodes: |
... | ... |
@@ -14,6 +14,7 @@ |
14 | 14 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
15 | 15 |
# |
16 | 16 |
import math |
17 |
+import operator |
|
17 | 18 |
|
18 | 19 |
import gi |
19 | 20 |
gi.require_version('Gtk', '3.0') |
... | ... |
@@ -26,17 +27,36 @@ from gi.repository import Pango |
26 | 27 |
from gi.repository import PangoCairo |
27 | 28 |
import cairo |
28 | 29 |
|
30 |
+_inf = float('inf') |
|
31 |
+_get_bounding = operator.attrgetter('bounding') |
|
32 |
+ |
|
29 | 33 |
|
30 | 34 |
class Shape: |
31 | 35 |
"""Abstract base class for all the drawing shapes.""" |
36 |
+ bounding = (-_inf, -_inf, _inf, _inf) |
|
32 | 37 |
|
33 | 38 |
def __init__(self): |
34 | 39 |
pass |
35 | 40 |
|
36 |
- def draw(self, cr, highlight=False): |
|
37 |
- """Draw this shape with the given cairo context""" |
|
41 |
+ def _intersects(self, bounding): |
|
42 |
+ x0, y0, x1, y1 = bounding |
|
43 |
+ x2, y2, x3, y3 = self.bounding |
|
44 |
+ return x2 <= x1 and x0 <= x3 and y2 <= y1 and y0 <= y3 |
|
45 |
+ |
|
46 |
+ def _fully_in(self, bounding): |
|
47 |
+ x0, y0, x1, y1 = bounding |
|
48 |
+ x2, y2, x3, y3 = self.bounding |
|
49 |
+ return x0 <= x2 and x3 <= x1 and y0 <= y2 and y3 <= y1 |
|
50 |
+ |
|
51 |
+ def _draw(self, cr, highlight, bounding): |
|
52 |
+ """Actual draw implementation""" |
|
38 | 53 |
raise NotImplementedError |
39 | 54 |
|
55 |
+ def draw(self, cr, highlight=False, bounding=None): |
|
56 |
+ """Draw this shape with the given cairo context""" |
|
57 |
+ if bounding is None or self._intersects(bounding): |
|
58 |
+ self._draw(cr, highlight, bounding) |
|
59 |
+ |
|
40 | 60 |
def select_pen(self, highlight): |
41 | 61 |
if highlight: |
42 | 62 |
if not hasattr(self, 'highlight_pen'): |
... | ... |
@@ -48,6 +68,26 @@ class Shape: |
48 | 68 |
def search_text(self, regexp): |
49 | 69 |
return False |
50 | 70 |
|
71 |
+ @staticmethod |
|
72 |
+ def _bounds_from_points(points): |
|
73 |
+ x0, y0 = points[0] |
|
74 |
+ x1, y1 = x0, y0 |
|
75 |
+ for i in range(1, len(points)): |
|
76 |
+ x, y = points[i] |
|
77 |
+ x0, x1 = min(x0, x), max(x1, x) |
|
78 |
+ y0, y1 = min(y0, y), max(y1, y) |
|
79 |
+ return x0, y0, x1, y1 |
|
80 |
+ |
|
81 |
+ @staticmethod |
|
82 |
+ def _envelope_bounds(*args): |
|
83 |
+ xa = ya = _inf |
|
84 |
+ xb = yb = -_inf |
|
85 |
+ for bounds in args: |
|
86 |
+ for x0, y0, x1, y1 in bounds: |
|
87 |
+ xa, xb = min(xa, x0), max(xb, x1) |
|
88 |
+ ya, yb = min(ya, y0), max(yb, y1) |
|
89 |
+ return xa, ya, xb, yb |
|
90 |
+ |
|
51 | 91 |
|
52 | 92 |
class TextShape(Shape): |
53 | 93 |
|
... | ... |
@@ -62,7 +102,7 @@ class TextShape(Shape): |
62 | 102 |
self.w = w # width |
63 | 103 |
self.t = t # text |
64 | 104 |
|
65 |
- def draw(self, cr, highlight=False): |
|
105 |
+ def _draw(self, cr, highlight, bounding): |
|
66 | 106 |
|
67 | 107 |
try: |
68 | 108 |
layout = self.layout |
... | ... |
@@ -139,34 +179,22 @@ class TextShape(Shape): |
139 | 179 |
else: |
140 | 180 |
f = 1.0 |
141 | 181 |
|
142 |
- if self.j == self.LEFT: |
|
143 |
- x = self.x |
|
144 |
- elif self.j == self.CENTER: |
|
145 |
- x = self.x - 0.5*width |
|
146 |
- elif self.j == self.RIGHT: |
|
147 |
- x = self.x - width |
|
148 |
- else: |
|
149 |
- assert 0 |
|
150 |
- |
|
151 | 182 |
y = self.y - height + descent |
152 | 183 |
|
153 |
- cr.move_to(x, y) |
|
184 |
+ if bounding is None or (y <= bounding[3] and bounding[1] <= y + height): |
|
185 |
+ x = self.x - 0.5 * (1 + self.j) * width |
|
186 |
+ cr.move_to(x, y) |
|
154 | 187 |
|
155 |
- cr.save() |
|
156 |
- cr.scale(f, f) |
|
157 |
- cr.set_source_rgba(*self.select_pen(highlight).color) |
|
158 |
- PangoCairo.show_layout(cr, layout) |
|
159 |
- cr.restore() |
|
188 |
+ cr.save() |
|
189 |
+ cr.scale(f, f) |
|
190 |
+ cr.set_source_rgba(*self.select_pen(highlight).color) |
|
191 |
+ PangoCairo.show_layout(cr, layout) |
|
192 |
+ cr.restore() |
|
160 | 193 |
|
161 | 194 |
if 0: # DEBUG |
162 | 195 |
# show where dot thinks the text should appear |
163 | 196 |
cr.set_source_rgba(1, 0, 0, .9) |
164 |
- if self.j == self.LEFT: |
|
165 |
- x = self.x |
|
166 |
- elif self.j == self.CENTER: |
|
167 |
- x = self.x - 0.5*self.w |
|
168 |
- elif self.j == self.RIGHT: |
|
169 |
- x = self.x - self.w |
|
197 |
+ x = self.x - 0.5 * (1 + self.j) * width |
|
170 | 198 |
cr.move_to(x, self.y) |
171 | 199 |
cr.line_to(x+self.w, self.y) |
172 | 200 |
cr.stroke() |
... | ... |
@@ -174,6 +202,11 @@ class TextShape(Shape): |
174 | 202 |
def search_text(self, regexp): |
175 | 203 |
return regexp.search(self.t) is not None |
176 | 204 |
|
205 |
+ @property |
|
206 |
+ def bounding(self): |
|
207 |
+ x, w, j = self.x, self.w, self.j |
|
208 |
+ return x - 0.5 * (1 + j) * w, -_inf, x + 0.5 * (1 - j) * w, _inf |
|
209 |
+ |
|
177 | 210 |
|
178 | 211 |
class ImageShape(Shape): |
179 | 212 |
|
... | ... |
@@ -186,7 +219,7 @@ class ImageShape(Shape): |
186 | 219 |
self.h = h |
187 | 220 |
self.path = path |
188 | 221 |
|
189 |
- def draw(self, cr, highlight=False): |
|
222 |
+ def _draw(self, cr, highlight, bounding): |
|
190 | 223 |
pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.path) |
191 | 224 |
sx = float(self.w)/float(pixbuf.get_width()) |
192 | 225 |
sy = float(self.h)/float(pixbuf.get_height()) |
... | ... |
@@ -197,6 +230,11 @@ class ImageShape(Shape): |
197 | 230 |
cr.paint() |
198 | 231 |
cr.restore() |
199 | 232 |
|
233 |
+ @property |
|
234 |
+ def bounding(self): |
|
235 |
+ x0, y0 = self.x0, self.y0 |
|
236 |
+ return x0, y0 - self.h, x0 + self.w, y0 |
|
237 |
+ |
|
200 | 238 |
|
201 | 239 |
class EllipseShape(Shape): |
202 | 240 |
|
... | ... |
@@ -209,7 +247,7 @@ class EllipseShape(Shape): |
209 | 247 |
self.h = h |
210 | 248 |
self.filled = filled |
211 | 249 |
|
212 |
- def draw(self, cr, highlight=False): |
|
250 |
+ def _draw(self, cr, highlight, bounding): |
|
213 | 251 |
cr.save() |
214 | 252 |
cr.translate(self.x0, self.y0) |
215 | 253 |
cr.scale(self.w, self.h) |
... | ... |
@@ -226,6 +264,13 @@ class EllipseShape(Shape): |
226 | 264 |
cr.set_source_rgba(*pen.color) |
227 | 265 |
cr.stroke() |
228 | 266 |
|
267 |
+ @property |
|
268 |
+ def bounding(self): |
|
269 |
+ x0, y0, w, h = self.x0, self.y0, self.w, self.h |
|
270 |
+ bt = 0 if self.filled else self.pen.linewidth / 2. |
|
271 |
+ w, h = w + bt, h + bt |
|
272 |
+ return x0 - w, y0 - h, x0 + w, y0 + h |
|
273 |
+ |
|
229 | 274 |
|
230 | 275 |
class PolygonShape(Shape): |
231 | 276 |
|
... | ... |
@@ -235,7 +280,11 @@ class PolygonShape(Shape): |
235 | 280 |
self.points = points |
236 | 281 |
self.filled = filled |
237 | 282 |
|
238 |
- def draw(self, cr, highlight=False): |
|
283 |
+ x0, y0, x1, y1 = Shape._bounds_from_points(self.points) |
|
284 |
+ bt = 0 if self.filled else self.pen.linewidth / 2. |
|
285 |
+ self.bounding = x0 - bt, y0 - bt, x1 + bt, y1 + bt |
|
286 |
+ |
|
287 |
+ def _draw(self, cr, highlight, bounding): |
|
239 | 288 |
x0, y0 = self.points[-1] |
240 | 289 |
cr.move_to(x0, y0) |
241 | 290 |
for x, y in self.points: |
... | ... |
@@ -260,7 +309,11 @@ class LineShape(Shape): |
260 | 309 |
self.pen = pen.copy() |
261 | 310 |
self.points = points |
262 | 311 |
|
263 |
- def draw(self, cr, highlight=False): |
|
312 |
+ x0, y0, x1, y1 = Shape._bounds_from_points(self.points) |
|
313 |
+ bt = self.pen.linewidth / 2. |
|
314 |
+ self.bounding = x0 - bt, y0 - bt, x1 + bt, y1 + bt |
|
315 |
+ |
|
316 |
+ def _draw(self, cr, highlight, bounding): |
|
264 | 317 |
x0, y0 = self.points[0] |
265 | 318 |
cr.move_to(x0, y0) |
266 | 319 |
for x1, y1 in self.points[1:]: |
... | ... |
@@ -280,13 +333,69 @@ class BezierShape(Shape): |
280 | 333 |
self.points = points |
281 | 334 |
self.filled = filled |
282 | 335 |
|
283 |
- def draw(self, cr, highlight=False): |
|
336 |
+ x0, y0 = self.points[0] |
|
337 |
+ xa = xb = x0 |
|
338 |
+ ya = yb = y0 |
|
339 |
+ for i in range(1, len(self.points), 3): |
|
340 |
+ (x1, y1), (x2, y2), (x3, y3) = self.points[i:i+3] |
|
341 |
+ for t in self._cubic_bernstein_extrema(x0, x1, x2, x3): |
|
342 |
+ if 0 < t < 1: # We're dealing only with Bezier curves |
|
343 |
+ v = self._cubic_bernstein(x0, x1, x2, x3, t) |
|
344 |
+ xa, xb = min(xa, v), max(xb, v) |
|
345 |
+ xa, xb = min(xa, x3), max(xb, x3) # t=0 / t=1 |
|
346 |
+ for t in self._cubic_bernstein_extrema(y0, y1, y2, y3): |
|
347 |
+ if 0 < t < 1: # We're dealing only with Bezier curves |
|
348 |
+ v = self._cubic_bernstein(y0, y1, y2, y3, t) |
|
349 |
+ ya, yb = min(ya, v), max(yb, v) |
|
350 |
+ ya, yb = min(ya, y3), max(yb, y3) # t=0 / t=1 |
|
351 |
+ x0, y0 = x3, y3 |
|
352 |
+ |
|
353 |
+ bt = 0 if self.filled else self.pen.linewidth / 2. |
|
354 |
+ self.bounding = xa - bt, ya - bt, xb + bt, yb + bt |
|
355 |
+ |
|
356 |
+ @staticmethod |
|
357 |
+ def _cubic_bernstein_extrema(p0, p1, p2, p3): |
|
358 |
+ """ |
|
359 |
+ Find extremas of a function of real domain defined by evaluating |
|
360 |
+ a cubic bernstein polynomial of given bernstein coefficients. |
|
361 |
+ """ |
|
362 |
+ # compute coefficients of derivative |
|
363 |
+ a = 3.*(p3-p0+3.*(p1-p2)) |
|
364 |
+ b = 6.*(p0+p2-2.*p1) |
|
365 |
+ c = 3.*(p1-p0) |
|
366 |
+ |
|
367 |
+ if a == 0: |
|
368 |
+ if b == 0: |
|
369 |
+ return () # constant |
|
370 |
+ return (-c / b,) # linear |
|
371 |
+ |
|
372 |
+ # quadratic |
|
373 |
+ # compute discriminant |
|
374 |
+ d = b*b - 4.*a*c |
|
375 |
+ if d < 0: |
|
376 |
+ return () |
|
377 |
+ |
|
378 |
+ k = -2. * a |
|
379 |
+ if d == 0: |
|
380 |
+ return (b / k,) |
|
381 |
+ |
|
382 |
+ r = math.sqrt(d) |
|
383 |
+ return ((b + r) / k, (b - r) / k) |
|
384 |
+ |
|
385 |
+ @staticmethod |
|
386 |
+ def _cubic_bernstein(p0, p1, p2, p3, t): |
|
387 |
+ """ |
|
388 |
+ Evaluate polynomial of given bernstein coefficients |
|
389 |
+ using de Casteljau's algorithm. |
|
390 |
+ """ |
|
391 |
+ u = 1 - t |
|
392 |
+ return p0*(u**3) + 3*t*u*(p1*u + p2*t) + p3*(t**3) |
|
393 |
+ |
|
394 |
+ def _draw(self, cr, highlight, bounding): |
|
284 | 395 |
x0, y0 = self.points[0] |
285 | 396 |
cr.move_to(x0, y0) |
286 | 397 |
for i in range(1, len(self.points), 3): |
287 |
- x1, y1 = self.points[i] |
|
288 |
- x2, y2 = self.points[i + 1] |
|
289 |
- x3, y3 = self.points[i + 2] |
|
398 |
+ (x1, y1), (x2, y2), (x3, y3) = self.points[i:i+3] |
|
290 | 399 |
cr.curve_to(x1, y1, x2, y2, x3, y3) |
291 | 400 |
pen = self.select_pen(highlight) |
292 | 401 |
if self.filled: |
... | ... |
@@ -305,10 +414,14 @@ class CompoundShape(Shape): |
305 | 414 |
def __init__(self, shapes): |
306 | 415 |
Shape.__init__(self) |
307 | 416 |
self.shapes = shapes |
417 |
+ self.bounding = Shape._envelope_bounds(map(_get_bounding, self.shapes)) |
|
308 | 418 |
|
309 |
- def draw(self, cr, highlight=False): |
|
419 |
+ def _draw(self, cr, highlight, bounding): |
|
420 |
+ if bounding is not None and self._fully_in(bounding): |
|
421 |
+ bounding = None |
|
310 | 422 |
for shape in self.shapes: |
311 |
- shape.draw(cr, highlight=highlight) |
|
423 |
+ if bounding is None or shape._intersects(bounding): |
|
424 |
+ shape._draw(cr, highlight, bounding) |
|
312 | 425 |
|
313 | 426 |
def search_text(self, regexp): |
314 | 427 |
for shape in self.shapes: |
... | ... |
@@ -440,10 +553,21 @@ class Graph(Shape): |
440 | 553 |
self.nodes = nodes |
441 | 554 |
self.edges = edges |
442 | 555 |
|
556 |
+ self.bounding = Shape._envelope_bounds( |
|
557 |
+ map(_get_bounding, self.shapes), |
|
558 |
+ map(_get_bounding, self.nodes), |
|
559 |
+ map(_get_bounding, self.edges)) |
|
560 |
+ |
|
443 | 561 |
def get_size(self): |
444 | 562 |
return self.width, self.height |
445 | 563 |
|
446 |
- def draw(self, cr, highlight_items=None): |
|
564 |
+ def draw(self, cr, highlight_items=None, bounding=None): |
|
565 |
+ if bounding is not None: |
|
566 |
+ if not self._intersects(bounding): |
|
567 |
+ return |
|
568 |
+ if self._fully_in(bounding): |
|
569 |
+ bounding = None |
|
570 |
+ |
|
447 | 571 |
if highlight_items is None: |
448 | 572 |
highlight_items = () |
449 | 573 |
cr.set_source_rgba(0.0, 0.0, 0.0, 1.0) |
... | ... |
@@ -452,13 +576,16 @@ class Graph(Shape): |
452 | 576 |
cr.set_line_join(cairo.LINE_JOIN_MITER) |
453 | 577 |
|
454 | 578 |
for shape in self.shapes: |
455 |
- shape.draw(cr) |
|
579 |
+ if bounding is None or shape._intersects(bounding): |
|
580 |
+ shape._draw(cr, highlight=False, bounding=bounding) |
|
456 | 581 |
for edge in self.edges: |
457 |
- should_highlight = any(e in highlight_items |
|
458 |
- for e in (edge, edge.src, edge.dst)) |
|
459 |
- edge.draw(cr, highlight=should_highlight) |
|
582 |
+ if bounding is None or edge._intersects(bounding): |
|
583 |
+ should_highlight = any(e in highlight_items |
|
584 |
+ for e in (edge, edge.src, edge.dst)) |
|
585 |
+ edge._draw(cr, highlight=should_highlight, bounding=bounding) |
|
460 | 586 |
for node in self.nodes: |
461 |
- node.draw(cr, highlight=(node in highlight_items)) |
|
587 |
+ if bounding is None or node._intersects(bounding): |
|
588 |
+ node._draw(cr, highlight=(node in highlight_items), bounding=bounding) |
|
462 | 589 |
|
463 | 590 |
def get_element(self, x, y): |
464 | 591 |
for node in self.nodes: |
... | ... |
@@ -83,7 +83,8 @@ class TextShape(Shape): |
83 | 83 |
# 'TypeError: font_options must be a cairo.FontOptions or None' |
84 | 84 |
pass |
85 | 85 |
except KeyError: |
86 |
- # cairo.FontOptions is not registered as a foreign struct in older PyGObject versions. |
|
86 |
+ # cairo.FontOptions is not registered as a foreign |
|
87 |
+ # struct in older PyGObject versions. |
|
87 | 88 |
# https://git.gnome.org/browse/pygobject/commit/?id=b21f66d2a399b8c9a36a1758107b7bdff0ec8eaa |
88 | 89 |
pass |
89 | 90 |
|
... | ... |
@@ -121,7 +122,7 @@ class TextShape(Shape): |
121 | 122 |
else: |
122 | 123 |
PangoCairo.update_layout(cr, layout) |
123 | 124 |
|
124 |
- descent = 2 # XXX get descender from font metrics |
|
125 |
+ descent = 2 # XXX get descender from font metrics |
|
125 | 126 |
|
126 | 127 |
width, height = layout.get_size() |
127 | 128 |
width = float(width)/Pango.SCALE |
... | ... |
@@ -132,7 +133,7 @@ class TextShape(Shape): |
132 | 133 |
# scale it so that the text fits inside its box |
133 | 134 |
if width > self.w: |
134 | 135 |
f = self.w / width |
135 |
- width = self.w # equivalent to width *= f |
|
136 |
+ width = self.w # equivalent to width *= f |
|
136 | 137 |
height *= f |
137 | 138 |
descent *= f |
138 | 139 |
else: |
... | ... |
@@ -157,7 +158,7 @@ class TextShape(Shape): |
157 | 158 |
PangoCairo.show_layout(cr, layout) |
158 | 159 |
cr.restore() |
159 | 160 |
|
160 |
- if 0: # DEBUG |
|
161 |
+ if 0: # DEBUG |
|
161 | 162 |
# show where dot thinks the text should appear |
162 | 163 |
cr.set_source_rgba(1, 0, 0, .9) |
163 | 164 |
if self.j == self.LEFT: |
1 | 1 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,486 @@ |
1 |
+# Copyright 2008-2015 Jose Fonseca |
|
2 |
+# |
|
3 |
+# This program is free software: you can redistribute it and/or modify it |
|
4 |
+# under the terms of the GNU Lesser General Public License as published |
|
5 |
+# by the Free Software Foundation, either version 3 of the License, or |
|
6 |
+# (at your option) any later version. |
|
7 |
+# |
|
8 |
+# This program is distributed in the hope that it will be useful, |
|
9 |
+# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
10 |
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
11 |
+# GNU Lesser General Public License for more details. |
|
12 |
+# |
|
13 |
+# You should have received a copy of the GNU Lesser General Public License |
|
14 |
+# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
15 |
+# |
|
16 |
+import math |
|
17 |
+ |
|
18 |
+import gi |
|
19 |
+gi.require_version('Gtk', '3.0') |
|
20 |
+gi.require_version('PangoCairo', '1.0') |
|
21 |
+ |
|
22 |
+from gi.repository import GObject |
|
23 |
+from gi.repository import Gdk |
|
24 |
+from gi.repository import GdkPixbuf |
|
25 |
+from gi.repository import Pango |
|
26 |
+from gi.repository import PangoCairo |
|
27 |
+import cairo |
|
28 |
+ |
|
29 |
+ |
|
30 |
+class Shape: |
|
31 |
+ """Abstract base class for all the drawing shapes.""" |
|
32 |
+ |
|
33 |
+ def __init__(self): |
|
34 |
+ pass |
|
35 |
+ |
|
36 |
+ def draw(self, cr, highlight=False): |
|
37 |
+ """Draw this shape with the given cairo context""" |
|
38 |
+ raise NotImplementedError |
|
39 |
+ |
|
40 |
+ def select_pen(self, highlight): |
|
41 |
+ if highlight: |
|
42 |
+ if not hasattr(self, 'highlight_pen'): |
|
43 |
+ self.highlight_pen = self.pen.highlighted() |
|
44 |
+ return self.highlight_pen |
|
45 |
+ else: |
|
46 |
+ return self.pen |
|
47 |
+ |
|
48 |
+ def search_text(self, regexp): |
|
49 |
+ return False |
|
50 |
+ |
|
51 |
+ |
|
52 |
+class TextShape(Shape): |
|
53 |
+ |
|
54 |
+ LEFT, CENTER, RIGHT = -1, 0, 1 |
|
55 |
+ |
|
56 |
+ def __init__(self, pen, x, y, j, w, t): |
|
57 |
+ Shape.__init__(self) |
|
58 |
+ self.pen = pen.copy() |
|
59 |
+ self.x = x |
|
60 |
+ self.y = y |
|
61 |
+ self.j = j # Centering |
|
62 |
+ self.w = w # width |
|
63 |
+ self.t = t # text |
|
64 |
+ |
|
65 |
+ def draw(self, cr, highlight=False): |
|
66 |
+ |
|
67 |
+ try: |
|
68 |
+ layout = self.layout |
|
69 |
+ except AttributeError: |
|
70 |
+ layout = PangoCairo.create_layout(cr) |
|
71 |
+ |
|
72 |
+ # set font options |
|
73 |
+ # see http://lists.freedesktop.org/archives/cairo/2007-February/009688.html |
|
74 |
+ context = layout.get_context() |
|
75 |
+ fo = cairo.FontOptions() |
|
76 |
+ fo.set_antialias(cairo.ANTIALIAS_DEFAULT) |
|
77 |
+ fo.set_hint_style(cairo.HINT_STYLE_NONE) |
|
78 |
+ fo.set_hint_metrics(cairo.HINT_METRICS_OFF) |
|
79 |
+ try: |
|
80 |
+ PangoCairo.context_set_font_options(context, fo) |
|
81 |
+ except TypeError: |
|
82 |
+ # XXX: Some broken pangocairo bindings show the error |
|
83 |
+ # 'TypeError: font_options must be a cairo.FontOptions or None' |
|
84 |
+ pass |
|
85 |
+ except KeyError: |
|
86 |
+ # cairo.FontOptions is not registered as a foreign struct in older PyGObject versions. |
|
87 |
+ # https://git.gnome.org/browse/pygobject/commit/?id=b21f66d2a399b8c9a36a1758107b7bdff0ec8eaa |
|
88 |
+ pass |
|
89 |
+ |
|
90 |
+ # set font |
|
91 |
+ font = Pango.FontDescription() |
|
92 |
+ |
|
93 |
+ # https://developer.gnome.org/pango/stable/PangoMarkupFormat.html |
|
94 |
+ markup = GObject.markup_escape_text(self.t) |
|
95 |
+ if self.pen.bold: |
|
96 |
+ markup = '<b>' + markup + '</b>' |
|
97 |
+ if self.pen.italic: |
|
98 |
+ markup = '<i>' + markup + '</i>' |
|
99 |
+ if self.pen.underline: |
|
100 |
+ markup = '<span underline="single">' + markup + '</span>' |
|
101 |
+ if self.pen.strikethrough: |
|
102 |
+ markup = '<s>' + markup + '</s>' |
|
103 |
+ if self.pen.superscript: |
|
104 |
+ markup = '<sup><small>' + markup + '</small></sup>' |
|
105 |
+ if self.pen.subscript: |
|
106 |
+ markup = '<sub><small>' + markup + '</small></sub>' |
|
107 |
+ |
|
108 |
+ success, attrs, text, accel_char = Pango.parse_markup(markup, -1, '\x00') |
|
109 |
+ assert success |
|
110 |
+ layout.set_attributes(attrs) |
|
111 |
+ |
|
112 |
+ font.set_family(self.pen.fontname) |
|
113 |
+ font.set_absolute_size(self.pen.fontsize*Pango.SCALE) |
|
114 |
+ layout.set_font_description(font) |
|
115 |
+ |
|
116 |
+ # set text |
|
117 |
+ layout.set_text(text, -1) |
|
118 |
+ |
|
119 |
+ # cache it |
|
120 |
+ self.layout = layout |
|
121 |
+ else: |
|
122 |
+ PangoCairo.update_layout(cr, layout) |
|
123 |
+ |
|
124 |
+ descent = 2 # XXX get descender from font metrics |
|
125 |
+ |
|
126 |
+ width, height = layout.get_size() |
|
127 |
+ width = float(width)/Pango.SCALE |
|
128 |
+ height = float(height)/Pango.SCALE |
|
129 |
+ |
|
130 |
+ # we know the width that dot thinks this text should have |
|
131 |
+ # we do not necessarily have a font with the same metrics |
|
132 |
+ # scale it so that the text fits inside its box |
|
133 |
+ if width > self.w: |
|
134 |
+ f = self.w / width |
|
135 |
+ width = self.w # equivalent to width *= f |
|
136 |
+ height *= f |
|
137 |
+ descent *= f |
|
138 |
+ else: |
|
139 |
+ f = 1.0 |
|
140 |
+ |
|
141 |
+ if self.j == self.LEFT: |
|
142 |
+ x = self.x |
|
143 |
+ elif self.j == self.CENTER: |
|
144 |
+ x = self.x - 0.5*width |
|
145 |
+ elif self.j == self.RIGHT: |
|
146 |
+ x = self.x - width |
|
147 |
+ else: |
|
148 |
+ assert 0 |
|
149 |
+ |
|
150 |
+ y = self.y - height + descent |
|
151 |
+ |
|
152 |
+ cr.move_to(x, y) |
|
153 |
+ |
|
154 |
+ cr.save() |
|
155 |
+ cr.scale(f, f) |
|
156 |
+ cr.set_source_rgba(*self.select_pen(highlight).color) |
|
157 |
+ PangoCairo.show_layout(cr, layout) |
|
158 |
+ cr.restore() |
|
159 |
+ |
|
160 |
+ if 0: # DEBUG |
|
161 |
+ # show where dot thinks the text should appear |
|
162 |
+ cr.set_source_rgba(1, 0, 0, .9) |
|
163 |
+ if self.j == self.LEFT: |
|
164 |
+ x = self.x |
|
165 |
+ elif self.j == self.CENTER: |
|
166 |
+ x = self.x - 0.5*self.w |
|
167 |
+ elif self.j == self.RIGHT: |
|
168 |
+ x = self.x - self.w |
|
169 |
+ cr.move_to(x, self.y) |
|
170 |
+ cr.line_to(x+self.w, self.y) |
|
171 |
+ cr.stroke() |
|
172 |
+ |
|
173 |
+ def search_text(self, regexp): |
|
174 |
+ return regexp.search(self.t) is not None |
|
175 |
+ |
|
176 |
+ |
|
177 |
+class ImageShape(Shape): |
|
178 |
+ |
|
179 |
+ def __init__(self, pen, x0, y0, w, h, path): |
|
180 |
+ Shape.__init__(self) |
|
181 |
+ self.pen = pen.copy() |
|
182 |
+ self.x0 = x0 |
|
183 |
+ self.y0 = y0 |
|
184 |
+ self.w = w |
|
185 |
+ self.h = h |
|
186 |
+ self.path = path |
|
187 |
+ |
|
188 |
+ def draw(self, cr, highlight=False): |
|
189 |
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.path) |
|
190 |
+ sx = float(self.w)/float(pixbuf.get_width()) |
|
191 |
+ sy = float(self.h)/float(pixbuf.get_height()) |
|
192 |
+ cr.save() |
|
193 |
+ cr.translate(self.x0, self.y0 - self.h) |
|
194 |
+ cr.scale(sx, sy) |
|
195 |
+ Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0) |
|
196 |
+ cr.paint() |
|
197 |
+ cr.restore() |
|
198 |
+ |
|
199 |
+ |
|
200 |
+class EllipseShape(Shape): |
|
201 |
+ |
|
202 |
+ def __init__(self, pen, x0, y0, w, h, filled=False): |
|
203 |
+ Shape.__init__(self) |
|
204 |
+ self.pen = pen.copy() |
|
205 |
+ self.x0 = x0 |
|
206 |
+ self.y0 = y0 |
|
207 |
+ self.w = w |
|
208 |
+ self.h = h |
|
209 |
+ self.filled = filled |
|
210 |
+ |
|
211 |
+ def draw(self, cr, highlight=False): |
|
212 |
+ cr.save() |
|
213 |
+ cr.translate(self.x0, self.y0) |
|
214 |
+ cr.scale(self.w, self.h) |
|
215 |
+ cr.move_to(1.0, 0.0) |
|
216 |
+ cr.arc(0.0, 0.0, 1.0, 0, 2.0*math.pi) |
|
217 |
+ cr.restore() |
|
218 |
+ pen = self.select_pen(highlight) |
|
219 |
+ if self.filled: |
|
220 |
+ cr.set_source_rgba(*pen.fillcolor) |
|
221 |
+ cr.fill() |
|
222 |
+ else: |
|
223 |
+ cr.set_dash(pen.dash) |
|
224 |
+ cr.set_line_width(pen.linewidth) |
|
225 |
+ cr.set_source_rgba(*pen.color) |
|
226 |
+ cr.stroke() |
|
227 |
+ |
|
228 |
+ |
|
229 |
+class PolygonShape(Shape): |
|
230 |
+ |
|
231 |
+ def __init__(self, pen, points, filled=False): |
|
232 |
+ Shape.__init__(self) |
|
233 |
+ self.pen = pen.copy() |
|
234 |
+ self.points = points |
|
235 |
+ self.filled = filled |
|
236 |
+ |
|
237 |
+ def draw(self, cr, highlight=False): |
|
238 |
+ x0, y0 = self.points[-1] |
|
239 |
+ cr.move_to(x0, y0) |
|
240 |
+ for x, y in self.points: |
|
241 |
+ cr.line_to(x, y) |
|
242 |
+ cr.close_path() |
|
243 |
+ pen = self.select_pen(highlight) |
|
244 |
+ if self.filled: |
|
245 |
+ cr.set_source_rgba(*pen.fillcolor) |
|
246 |
+ cr.fill_preserve() |
|
247 |
+ cr.fill() |
|
248 |
+ else: |
|
249 |
+ cr.set_dash(pen.dash) |
|
250 |
+ cr.set_line_width(pen.linewidth) |
|
251 |
+ cr.set_source_rgba(*pen.color) |
|
252 |
+ cr.stroke() |
|
253 |
+ |
|
254 |
+ |
|
255 |
+class LineShape(Shape): |
|
256 |
+ |
|
257 |
+ def __init__(self, pen, points): |
|
258 |
+ Shape.__init__(self) |
|
259 |
+ self.pen = pen.copy() |
|
260 |
+ self.points = points |
|
261 |
+ |
|
262 |
+ def draw(self, cr, highlight=False): |
|
263 |
+ x0, y0 = self.points[0] |
|
264 |
+ cr.move_to(x0, y0) |
|
265 |
+ for x1, y1 in self.points[1:]: |
|
266 |
+ cr.line_to(x1, y1) |
|
267 |
+ pen = self.select_pen(highlight) |
|
268 |
+ cr.set_dash(pen.dash) |
|
269 |
+ cr.set_line_width(pen.linewidth) |
|
270 |
+ cr.set_source_rgba(*pen.color) |
|
271 |
+ cr.stroke() |
|
272 |
+ |
|
273 |
+ |
|
274 |
+class BezierShape(Shape): |
|
275 |
+ |
|
276 |
+ def __init__(self, pen, points, filled=False): |
|
277 |
+ Shape.__init__(self) |
|
278 |
+ self.pen = pen.copy() |
|
279 |
+ self.points = points |
|
280 |
+ self.filled = filled |
|
281 |
+ |
|
282 |
+ def draw(self, cr, highlight=False): |
|
283 |
+ x0, y0 = self.points[0] |
|
284 |
+ cr.move_to(x0, y0) |
|
285 |
+ for i in range(1, len(self.points), 3): |
|
286 |
+ x1, y1 = self.points[i] |
|
287 |
+ x2, y2 = self.points[i + 1] |
|
288 |
+ x3, y3 = self.points[i + 2] |
|
289 |
+ cr.curve_to(x1, y1, x2, y2, x3, y3) |
|
290 |
+ pen = self.select_pen(highlight) |
|
291 |
+ if self.filled: |
|
292 |
+ cr.set_source_rgba(*pen.fillcolor) |
|
293 |
+ cr.fill_preserve() |
|
294 |
+ cr.fill() |
|
295 |
+ else: |
|
296 |
+ cr.set_dash(pen.dash) |
|
297 |
+ cr.set_line_width(pen.linewidth) |
|
298 |
+ cr.set_source_rgba(*pen.color) |
|
299 |
+ cr.stroke() |
|
300 |
+ |
|
301 |
+ |
|
302 |
+class CompoundShape(Shape): |
|
303 |
+ |
|
304 |
+ def __init__(self, shapes): |
|
305 |
+ Shape.__init__(self) |
|
306 |
+ self.shapes = shapes |
|
307 |
+ |
|
308 |
+ def draw(self, cr, highlight=False): |
|
309 |
+ for shape in self.shapes: |
|
310 |
+ shape.draw(cr, highlight=highlight) |
|
311 |
+ |
|
312 |
+ def search_text(self, regexp): |
|
313 |
+ for shape in self.shapes: |
|
314 |
+ if shape.search_text(regexp): |
|
315 |
+ return True |
|
316 |
+ return False |
|
317 |
+ |
|
318 |
+ |
|
319 |
+class Url(object): |
|
320 |
+ |
|
321 |
+ def __init__(self, item, url, highlight=None): |
|
322 |
+ self.item = item |
|
323 |
+ self.url = url |
|
324 |
+ if highlight is None: |
|
325 |
+ highlight = set([item]) |
|
326 |
+ self.highlight = highlight |
|
327 |
+ |
|
328 |
+ |
|
329 |
+class Jump(object): |
|
330 |
+ |
|
331 |
+ def __init__(self, item, x, y, highlight=None): |
|
332 |
+ self.item = item |
|
333 |
+ self.x = x |
|
334 |
+ self.y = y |
|
335 |
+ if highlight is None: |
|
336 |
+ highlight = set([item]) |
|
337 |
+ self.highlight = highlight |
|
338 |
+ |
|
339 |
+ |
|
340 |
+class Element(CompoundShape): |
|
341 |
+ """Base class for graph nodes and edges.""" |
|
342 |
+ |
|
343 |
+ def __init__(self, shapes): |
|
344 |
+ CompoundShape.__init__(self, shapes) |
|
345 |
+ |
|
346 |
+ def is_inside(self, x, y): |
|
347 |
+ return False |
|
348 |
+ |
|
349 |
+ def get_url(self, x, y): |
|
350 |
+ return None |
|
351 |
+ |
|
352 |
+ def get_jump(self, x, y): |
|
353 |
+ return None |
|
354 |
+ |
|
355 |
+ |
|
356 |
+class Node(Element): |
|
357 |
+ |
|
358 |
+ def __init__(self, id, x, y, w, h, shapes, url): |
|
359 |
+ Element.__init__(self, shapes) |
|
360 |
+ |
|
361 |
+ self.id = id |
|
362 |
+ self.x = x |
|
363 |
+ self.y = y |
|
364 |
+ |
|
365 |
+ self.x1 = x - 0.5*w |
|
366 |
+ self.y1 = y - 0.5*h |
|
367 |
+ self.x2 = x + 0.5*w |
|
368 |
+ self.y2 = y + 0.5*h |
|
369 |
+ |
|
370 |
+ self.url = url |
|
371 |
+ |
|
372 |
+ def is_inside(self, x, y): |
|
373 |
+ return self.x1 <= x and x <= self.x2 and self.y1 <= y and y <= self.y2 |
|
374 |
+ |
|
375 |
+ def get_url(self, x, y): |
|
376 |
+ if self.url is None: |
|
377 |
+ return None |
|
378 |
+ if self.is_inside(x, y): |
|
379 |
+ return Url(self, self.url) |
|
380 |
+ return None |
|
381 |
+ |
|
382 |
+ def get_jump(self, x, y): |
|
383 |
+ if self.is_inside(x, y): |
|
384 |
+ return Jump(self, self.x, self.y) |
|
385 |
+ return None |
|
386 |
+ |
|
387 |
+ def __repr__(self): |
|
388 |
+ return "<Node %s>" % self.id |
|
389 |
+ |
|
390 |
+ |
|
391 |
+def square_distance(x1, y1, x2, y2): |
|
392 |
+ deltax = x2 - x1 |
|
393 |
+ deltay = y2 - y1 |
|
394 |
+ return deltax*deltax + deltay*deltay |
|
395 |
+ |
|
396 |
+ |
|
397 |
+class Edge(Element): |
|
398 |
+ |
|
399 |
+ def __init__(self, src, dst, points, shapes): |
|
400 |
+ Element.__init__(self, shapes) |
|
401 |
+ self.src = src |
|
402 |
+ self.dst = dst |
|
403 |
+ self.points = points |
|
404 |
+ |
|
405 |
+ RADIUS = 10 |
|
406 |
+ |
|
407 |
+ def is_inside_begin(self, x, y): |
|
408 |
+ return square_distance(x, y, *self.points[0]) <= self.RADIUS*self.RADIUS |
|
409 |
+ |
|
410 |
+ def is_inside_end(self, x, y): |
|
411 |
+ return square_distance(x, y, *self.points[-1]) <= self.RADIUS*self.RADIUS |
|
412 |
+ |
|
413 |
+ def is_inside(self, x, y): |
|
414 |
+ if self.is_inside_begin(x, y): |
|
415 |
+ return True |
|
416 |
+ if self.is_inside_end(x, y): |
|
417 |
+ return True |
|
418 |
+ return False |
|
419 |
+ |
|
420 |
+ def get_jump(self, x, y): |
|
421 |
+ if self.is_inside_begin(x, y): |
|
422 |
+ return Jump(self, self.dst.x, self.dst.y, highlight=set([self, self.dst])) |
|
423 |
+ if self.is_inside_end(x, y): |
|
424 |
+ return Jump(self, self.src.x, self.src.y, highlight=set([self, self.src])) |
|
425 |
+ return None |
|
426 |
+ |
|
427 |
+ def __repr__(self): |
|
428 |
+ return "<Edge %s -> %s>" % (self.src, self.dst) |
|
429 |
+ |
|
430 |
+ |
|
431 |
+class Graph(Shape): |
|
432 |
+ |
|
433 |
+ def __init__(self, width=1, height=1, shapes=(), nodes=(), edges=()): |
|
434 |
+ Shape.__init__(self) |
|
435 |
+ |
|
436 |
+ self.width = width |
|
437 |
+ self.height = height |
|
438 |
+ self.shapes = shapes |
|
439 |
+ self.nodes = nodes |
|
440 |
+ self.edges = edges |
|
441 |
+ |
|
442 |
+ def get_size(self): |
|
443 |
+ return self.width, self.height |
|
444 |
+ |
|
445 |
+ def draw(self, cr, highlight_items=None): |
|
446 |
+ if highlight_items is None: |
|
447 |
+ highlight_items = () |
|
448 |
+ cr.set_source_rgba(0.0, 0.0, 0.0, 1.0) |
|
449 |
+ |
|
450 |
+ cr.set_line_cap(cairo.LINE_CAP_BUTT) |
|
451 |
+ cr.set_line_join(cairo.LINE_JOIN_MITER) |
|
452 |
+ |
|
453 |
+ for shape in self.shapes: |
|
454 |
+ shape.draw(cr) |
|
455 |
+ for edge in self.edges: |
|
456 |
+ should_highlight = any(e in highlight_items |
|
457 |
+ for e in (edge, edge.src, edge.dst)) |
|
458 |
+ edge.draw(cr, highlight=should_highlight) |
|
459 |
+ for node in self.nodes: |
|
460 |
+ node.draw(cr, highlight=(node in highlight_items)) |
|
461 |
+ |
|
462 |
+ def get_element(self, x, y): |
|
463 |
+ for node in self.nodes: |
|
464 |
+ if node.is_inside(x, y): |
|
465 |
+ return node |
|
466 |
+ for edge in self.edges: |
|
467 |
+ if edge.is_inside(x, y): |
|
468 |
+ return edge |
|
469 |
+ |
|
470 |
+ def get_url(self, x, y): |
|
471 |
+ for node in self.nodes: |
|
472 |
+ url = node.get_url(x, y) |
|
473 |
+ if url is not None: |
|
474 |
+ return url |
|
475 |
+ return None |
|
476 |
+ |
|
477 |
+ def get_jump(self, x, y): |
|
478 |
+ for edge in self.edges: |
|
479 |
+ jump = edge.get_jump(x, y) |
|
480 |
+ if jump is not None: |
|
481 |
+ return jump |
|
482 |
+ for node in self.nodes: |
|
483 |
+ jump = node.get_jump(x, y) |
|
484 |
+ if jump is not None: |
|
485 |
+ return jump |
|
486 |
+ return None |