Browse code

Faster rendering of large graphs

iamahuman authored on 23/11/2018 10:47:03 • José Fonseca committed on 01/12/2018 16:57:56
Showing 2 changed files

... ...
@@ -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:
... ...
@@ -169,17 +169,26 @@ class DotWidget(Gtk.DrawingArea):
169 169
                 self.reload()
170 170
         return True
171 171
 
172
+    def _draw_graph(self, cr, rect):
173
+        w, h = float(rect.width), float(rect.height)
174
+        cx, cy = 0.5 * w, 0.5 * h
175
+        x, y, ratio = self.x, self.y, self.zoom_ratio
176
+        x0, y0 = x - cx / ratio, y - cy / ratio
177
+        x1, y1 = x0 + w / ratio, y0 + h / ratio
178
+        bounding = (x0, y0, x1, y1)
179
+
180
+        cr.translate(cx, cy)
181
+        cr.scale(ratio, ratio)
182
+        cr.translate(-x, -y)
183
+        self.graph.draw(cr, highlight_items=self.highlight, bounding=bounding)
184
+
172 185
     def on_draw(self, widget, cr):
173 186
         rect = self.get_allocation()
174 187
         Gtk.render_background(self.get_style_context(), cr, 0, 0,
175 188
                               rect.width, rect.height)
176 189
 
177 190
         cr.save()
178
-        cr.translate(0.5*rect.width, 0.5*rect.height)
179
-        cr.scale(self.zoom_ratio, self.zoom_ratio)
180
-        cr.translate(-self.x, -self.y)
181
-
182
-        self.graph.draw(cr, highlight_items=self.highlight)
191
+        self._draw_graph(cr, rect)
183 192
         cr.restore()
184 193
 
185 194
         self.drag_action.draw(cr)
... ...
@@ -342,13 +351,8 @@ class DotWidget(Gtk.DrawingArea):
342 351
 
343 352
     def draw_page(self, operation, context, page_nr):
344 353
         cr = context.get_cairo_context()
345
-
346 354
         rect = self.get_allocation()
347
-        cr.translate(0.5*rect.width, 0.5*rect.height)
348
-        cr.scale(self.zoom_ratio, self.zoom_ratio)
349
-        cr.translate(-self.x, -self.y)
350
-
351
-        self.graph.draw(cr, highlight_items=self.highlight)
355
+        self._draw_graph(cr, rect)
352 356
 
353 357
     def get_drag_action(self, event):
354 358
         state = event.state