File: owlegend.py

package info (click to toggle)
orange3 3.40.0-2
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 15,912 kB
  • sloc: python: 162,745; ansic: 622; makefile: 322; sh: 93; cpp: 77
file content (697 lines) | stat: -rw-r--r-- 22,191 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
"""Legend classes to use with `QGraphicsScene` objects."""
import numpy as np

from AnyQt.QtWidgets import (
    QGraphicsWidget, QGraphicsItem, QGraphicsRectItem, QGraphicsEllipseItem,
    QGraphicsTextItem, QGraphicsLinearLayout, QGraphicsView, QApplication
)
from AnyQt.QtGui import QColor, QBrush, QPen, QLinearGradient, QFont
from AnyQt.QtCore import Qt, QPointF, QSizeF, QPoint, QSize, QRect

from Orange.widgets.utils.colorpalettes import ContinuousPalette


class Anchorable(QGraphicsWidget):
    """Anchorable base class.

    Subclassing the `Anchorable` class will anchor the given
    `QGraphicsWidget` to a position on the viewport. This does require you to
    use the `AnchorableGraphicsView` class, it is made to be composable, so
    that should not be a problem.

    Notes
    -----
    .. note:: Subclassing this class will not make your widget movable, you
        have to do that yourself. If you do make your widget movable, this will
        handle any further positioning when the widget is moved.

    """

    __corners = ['topLeft', 'topRight', 'bottomLeft', 'bottomRight']
    TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT = __corners

    def __init__(self, parent=None, corner='bottomRight', offset=(10, 10)):
        super().__init__(parent)

        self.__corner_str = corner if corner in self.__corners else None
        # The flag indicates whether or not the item has been drawn on yet.
        # This is useful for determining the initial offset, due to the fact
        # that dimensions are available in the resize event, which can occur
        # multiple times.
        self.__has_been_drawn = False

        if isinstance(offset, tuple) or isinstance(offset, list):
            assert len(offset) == 2
            self.__offset = QPoint(*offset)
        elif isinstance(offset, QPoint):
            self.__offset = offset

    def moveEvent(self, event):
        super().moveEvent(event)
        # This check is needed because simply resizing the window will cause
        # the item to move and trigger a `moveEvent` therefore we need to check
        # that the movement was done intentionally by the user using the mouse
        if QApplication.mouseButtons() == Qt.LeftButton:
            self.recalculate_offset()

    def resizeEvent(self, event):
        # When the item is first shown, we need to update its position
        super().resizeEvent(event)
        if not self.__has_been_drawn:
            self.__offset = self.__calculate_actual_offset(self.__offset)
            self.update_pos()
            self.__has_been_drawn = True

    def showEvent(self, event):
        # When the item is first shown, we need to update its position
        super().showEvent(event)
        self.update_pos()

    def recalculate_offset(self):
        """This is called whenever the item is being moved and needs to
        recalculate its offset."""
        view = self.__get_view()
        # Get the view box and position of legend relative to the view,
        # not the scene
        pos = view.mapFromScene(self.pos())
        view_box = self.__usable_viewbox()

        self.__corner_str = self.__get_closest_corner()
        viewbox_corner = getattr(view_box, self.__corner_str)()

        self.__offset = viewbox_corner - pos

    def update_pos(self):
        """Update the widget position relative to the viewport.

        This is called whenever something happened with the view that caused
        this item to move from its anchored position, so we have to adjust the
        position to maintain the effect of being anchored."""
        view = self.__get_view()
        if self.__corner_str and view is not None:
            box = self.__usable_viewbox()
            corner = getattr(box, self.__corner_str)()
            new_pos = corner - self.__offset
            self.setPos(view.mapToScene(new_pos))

    def __calculate_actual_offset(self, offset):
        """Take the offset specified in the constructor and calculate the
        actual offset from the top left corner of the item so positioning can
        be done correctly."""
        off_x, off_y = offset.x(), offset.y()
        width = int(self.boundingRect().width())
        height = int(self.boundingRect().height())

        if self.__corner_str == self.TOP_LEFT:
            return QPoint(-off_x, -off_y)
        elif self.__corner_str == self.TOP_RIGHT:
            return QPoint(off_x + width, -off_y)
        elif self.__corner_str == self.BOTTOM_RIGHT:
            return QPoint(off_x + width, off_y + height)
        elif self.__corner_str == self.BOTTOM_LEFT:
            return QPoint(-off_x, off_y + height)

    def __get_closest_corner(self):
        view = self.__get_view()
        # Get the view box and position of legend relative to the view,
        # not the scene
        pos = view.mapFromScene(self.pos())
        legend_box = QRect(pos, self.size().toSize())
        view_box = QRect(QPoint(0, 0), view.size())

        def distance(pt1, pt2):
            # 2d euclidean distance
            return np.sqrt((pt1.x() - pt2.x()) ** 2 + (pt1.y() - pt2.y()) ** 2)

        distances = [
            (distance(getattr(view_box, corner)(),
                      getattr(legend_box, corner)()), corner)
            for corner in self.__corners
        ]
        _, corner = min(distances)
        return corner

    def __get_own_corner(self):
        view = self.__get_view()
        pos = view.mapFromScene(self.pos())
        legend_box = QRect(pos, self.size().toSize())
        return getattr(legend_box, self.__corner_str)()

    def __get_view(self):
        if self.scene() is not None:
            view, = self.scene().views()
            return view
        else:
            return None

    def __usable_viewbox(self):
        view = self.__get_view()

        if view.horizontalScrollBar().isVisible():
            height = view.horizontalScrollBar().size().height()
        else:
            height = 0

        if view.verticalScrollBar().isVisible():
            width = view.verticalScrollBar().size().width()
        else:
            width = 0

        size = view.size() - QSize(width, height)
        return QRect(QPoint(0, 0), size)


class AnchorableGraphicsView(QGraphicsView):
    """Subclass when wanting to use Anchorable items in your view."""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Handle scroll bar hiding or showing
        self.horizontalScrollBar().valueChanged.connect(
            self.update_anchored_items)
        self.verticalScrollBar().valueChanged.connect(
            self.update_anchored_items)

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self.update_anchored_items()

    def mousePressEvent(self, event):
        super().mousePressEvent(event)
        self.update_anchored_items()

    def wheelEvent(self, event):
        super().wheelEvent(event)
        self.update_anchored_items()

    def mouseMoveEvent(self, event):
        super().mouseMoveEvent(event)
        self.update_anchored_items()

    def update_anchored_items(self):
        """Update all the items that subclass the `Anchorable` class."""
        for item in self.__anchorable_items():
            item.update_pos()

    def __anchorable_items(self):
        return [i for i in self.scene().items() if isinstance(i, Anchorable)]


class ColorIndicator(QGraphicsWidget):
    """Base class for an item indicator.

    Usually the little square or circle in the legend in front of the text."""
    pass


class LegendItemSquare(ColorIndicator):
    """Legend square item.

    The legend square item is a small colored square image that can be plugged
    into the legend in front of the text object.

    This should only really be used in conjunction with ˙LegendItem˙.

    Parameters
    ----------
    color : QColor
        The color of the square.
    parent : QGraphicsItem

    See Also
    --------
    LegendItemCircle

    """

    SIZE = QSizeF(12, 12)
    _size_hint = SIZE

    def __init__(self, color, parent):
        super().__init__(parent)

        height, width = self.SIZE.height(), self.SIZE.width()
        self.__square = QGraphicsRectItem(0, 0, height, width)
        self.__square.setBrush(QBrush(color))
        self.__square.setPen(QPen(QColor(0, 0, 0, 0)))
        self.__square.setParentItem(self)
        self._size_hint = QSizeF(self.__square.boundingRect().size())

    def sizeHint(self, size_hint, size_constraint=None, *args, **kwargs):
        return self._size_hint


class LegendItemCircle(ColorIndicator):
    """Legend circle item.

    The legend circle item is a small colored circle image that can be plugged
    into the legend in front of the text object.

    This should only really be used in conjunction with ˙LegendItem˙.

    Parameters
    ----------
    color : QColor
        The color of the square.
    parent : QGraphicsItem

    See Also
    --------
    LegendItemSquare

    """

    SIZE = QSizeF(12, 12)
    _size_hint = SIZE

    def __init__(self, color, parent):
        super().__init__(parent)

        height, width = self.SIZE.height(), self.SIZE.width()
        self.__circle = QGraphicsEllipseItem(0, 0, height, width)
        self.__circle.setBrush(QBrush(color))
        self.__circle.setPen(QPen(QColor(0, 0, 0, 0)))
        self.__circle.setParentItem(self)
        self._size_hint = QSizeF(self.__circle.boundingRect().size())

    def sizeHint(self, size_hint, size_constraint=None, *args, **kwargs):
        return self._size_hint


class LegendItemTitle(QGraphicsWidget):
    """Legend item title - the text displayed in the legend.

    This should only really be used in conjunction with ˙LegendItem˙.

    Parameters
    ----------
    text : str
    parent : QGraphicsItem
    font : QFont
        This

    """
    _size_hint = QSizeF(100, 10)

    def __init__(self, text, parent, font):
        super().__init__(parent)

        self.__text = QGraphicsTextItem(text.title())
        self.__text.setParentItem(self)
        self.__text.setFont(font)
        self._size_hint = QSizeF(self.__text.boundingRect().size())

    def sizeHint(self, size_hint, size_constraint=None, *args, **kwargs):
        return self._size_hint


class LegendItem(QGraphicsLinearLayout):
    """Legend item - one entry in the legend.

    This represents one entry in the legend i.e. a color indicator and the text
    beside it.

    Parameters
    ----------
    color : QColor
        The color that the entry will represent.
    title : str
        The text that will be displayed for the color.
    parent : QGraphicsItem
    color_indicator_cls : ColorIndicator
        The type of `ColorIndicator` that will be used for the color.
    font : QFont, optional

    """

    def __init__(self, color, title, parent, color_indicator_cls, font=None):
        super().__init__()

        self.__parent = parent
        self.__color_indicator = color_indicator_cls(color, parent)
        self.__title_label = LegendItemTitle(title, parent, font=font)

        self.addItem(self.__color_indicator)
        self.addItem(self.__title_label)

        # Make sure items are aligned properly, since the color box and text
        # won't be the same height.
        self.setAlignment(self.__color_indicator, Qt.AlignCenter)
        self.setAlignment(self.__title_label, Qt.AlignCenter)
        self.setContentsMargins(0, 0, 0, 0)
        self.setSpacing(5)


class LegendGradient(QGraphicsWidget):
    """Gradient widget.

    A gradient square bar that can be used to display continuous values.

    Parameters
    ----------
    palette : iterable[QColor]
    parent : QGraphicsWidget
    orientation : Qt.Orientation

    Notes
    -----
    .. note:: While the gradient does support any number of colors, any more
        than 3 is not very readable. This should not be a problem, since Orange
        only implements 2 or 3 colors.

    """

    # Default sizes (assume gradient is vertical by default)
    GRADIENT_WIDTH = 20
    GRADIENT_HEIGHT = 150
    _size_hint = QSizeF(GRADIENT_WIDTH, GRADIENT_HEIGHT)

    def __init__(self, palette, parent, orientation):
        super().__init__(parent)

        self.__gradient = QLinearGradient()
        num_colors = len(palette)
        for idx, stop in enumerate(palette):
            self.__gradient.setColorAt(idx * (1. / (num_colors - 1)), stop)

        # We need to tell the gradient where it's start and stop points are
        self.__gradient.setStart(QPointF(0, 0))
        if orientation == Qt.Vertical:
            final_stop = QPointF(0, self.GRADIENT_HEIGHT)
        else:
            final_stop = QPointF(self.GRADIENT_HEIGHT, 0)
        self.__gradient.setFinalStop(final_stop)

        # Get the appropriate rectangle dimensions based on orientation
        if orientation == Qt.Vertical:
            width, height = self.GRADIENT_WIDTH, self.GRADIENT_HEIGHT
        elif orientation == Qt.Horizontal:
            width, height = self.GRADIENT_HEIGHT, self.GRADIENT_WIDTH

        self.__rect_item = QGraphicsRectItem(0, 0, width, height, self)
        self.__rect_item.setPen(QPen(QColor(0, 0, 0, 0)))
        self.__rect_item.setBrush(QBrush(self.__gradient))
        self._size_hint = QSizeF(self.__rect_item.boundingRect().size())

    def sizeHint(self, size_hint, size_constraint=None, *args, **kwargs):
        return self._size_hint


class ColorStripItem(QGraphicsWidget):
    def __init__(self, palette, parent, orientation):
        super().__init__(parent)
        self.__strip = palette.color_strip(150, 13, orientation)

    def paint(self, painter, option, widget):
        painter.drawPixmap(0, 0, self.__strip)

    def sizeHint(self, *_):
        return QSizeF(self.__strip.width(), self.__strip.height())


class ContinuousLegendItem(QGraphicsLinearLayout):
    """Continuous legend item.

    Contains a gradient bar with the color ranges, as well as two labels - one
    on each side of the gradient bar.

    Parameters
    ----------
    palette : iterable[QColor]
    values : iterable[float...]
        The number of values must match the number of colors in passed in the
        color palette.
    parent : QGraphicsWidget
    font : QFont
    orientation : Qt.Orientation

    """

    def __init__(self, palette, values, parent, font=None,
                 orientation=Qt.Vertical):
        if orientation == Qt.Vertical:
            super().__init__(Qt.Horizontal)
        else:
            super().__init__(Qt.Vertical)

        self.__parent = parent
        self.__palette = palette
        self.__values = values

        if isinstance(palette, ContinuousPalette):
            self.__gradient = ColorStripItem(palette, parent, orientation)
        else:
            self.__gradient = LegendGradient(palette, parent, orientation)
        self.__labels_layout = QGraphicsLinearLayout(orientation)

        str_vals = self._format_values(values)

        self.__start_label = LegendItemTitle(str_vals[0], parent, font=font)
        self.__end_label = LegendItemTitle(str_vals[1], parent, font=font)
        self.__labels_layout.addItem(self.__start_label)
        self.__labels_layout.addStretch(1)
        self.__labels_layout.addItem(self.__end_label)

        # Gradient should be to the left, then labels on the right if vertical
        if orientation == Qt.Vertical:
            self.addItem(self.__gradient)
            self.addItem(self.__labels_layout)
        # Gradient should be on the bottom, labels on top if horizontal
        elif orientation == Qt.Horizontal:
            self.addItem(self.__labels_layout)
            self.addItem(self.__gradient)

    @staticmethod
    def _format_values(values):
        """Get the formatted values to output."""
        return ['{:.3f}'.format(v) for v in values]


class Legend(Anchorable):
    """Base legend class.

    This class provides common attributes for any legend subclasses:
      - Behaviour on `QGraphicsScene`
      - Appearance of legend

    Parameters
    ----------
    parent : QGraphicsItem, optional
    orientation : Qt.Orientation, optional
        The default orientation is vertical
    domain : Orange.data.domain.Domain, optional
        This field is left optional as in some cases, we may want to simply
        pass in a list that represents the legend.
    items : Iterable[QColor, str]
    bg_color : QColor, optional
    font : QFont, optional
    color_indicator_cls : ColorIndicator
        The color indicator class that will be used to render the indicators.

    See Also
    --------
    OWDiscreteLegend
    OWContinuousLegend
    OWContinuousLegend

    Notes
    -----
    .. warning:: If the domain parameter is supplied, the items parameter will
        be ignored.

    """

    def __init__(self, parent=None, orientation=Qt.Vertical, domain=None,
                 items=None, bg_color=QColor(232, 232, 232, 196),
                 font=None, color_indicator_cls=LegendItemSquare, **kwargs):
        super().__init__(parent, **kwargs)

        self._layout = None
        self.orientation = orientation
        self.bg_color = QBrush(bg_color)
        self.color_indicator_cls = color_indicator_cls

        # Set default font if none is given
        if font is None:
            self.font = QFont()
            self.font.setPointSize(10)
        else:
            self.font = font

        self.setFlags(QGraphicsWidget.ItemIsMovable |
                      QGraphicsItem.ItemIgnoresTransformations)

        self._setup_layout()
        if domain is not None:
            self.set_domain(domain)
        elif items is not None:
            self.set_items(items)

    def _clear_layout(self):
        self._layout = None
        for child in self.children():
            child.setParent(None)

    def _setup_layout(self):
        self._clear_layout()

        self._layout = QGraphicsLinearLayout(self.orientation)
        self._layout.setContentsMargins(10, 5, 10, 5)
        # If horizontal, there needs to be horizontal space between the items
        if self.orientation == Qt.Horizontal:
            self._layout.setSpacing(10)
        # If vertical spacing, vertical space is provided by child layouts
        else:
            self._layout.setSpacing(0)
        self.setLayout(self._layout)

    def set_domain(self, domain):
        """Handle receiving the domain object.

        Parameters
        ----------
        domain : Orange.data.domain.Domain

        Returns
        -------

        Raises
        ------
        AttributeError
            If the domain does not contain the correct type of class variable.

        """
        raise NotImplementedError()

    def set_items(self, values):
        """Handle receiving an array of items.

        Parameters
        ----------
        values : iterable[object, QColor]

        Returns
        -------

        """
        raise NotImplementedError()

    @staticmethod
    def _convert_to_color(obj):
        if isinstance(obj, QColor):
            return obj
        elif isinstance(obj, tuple) or isinstance(obj, list) \
                or isinstance(obj, np.ndarray):
            assert len(obj) in (3, 4)
            return QColor(*obj)
        else:
            return QColor(obj)

    def setVisible(self, is_visible):
        """Only display the legend if it contains any items."""
        return super().setVisible(is_visible and len(self._layout) > 0)

    def paint(self, painter, options, widget=None):
        painter.save()
        pen = QPen(QColor(196, 197, 193, 200), 1)
        brush = QBrush(QColor(self.bg_color))

        painter.setPen(pen)
        painter.setBrush(brush)
        painter.drawRect(self.contentsRect())
        painter.restore()


class OWDiscreteLegend(Legend):
    """Discrete legend.

    See Also
    --------
    Legend
    OWContinuousLegend

    """

    def set_domain(self, domain):
        class_var = domain.class_var

        if not class_var.is_discrete:
            raise AttributeError('[OWDiscreteLegend] The class var provided '
                                 'was not discrete.')

        self.set_items(zip(class_var.values, list(class_var.colors)))

    def set_items(self, values):
        for class_name, color in values:
            legend_item = LegendItem(
                color=self._convert_to_color(color),
                title=class_name,
                parent=self,
                color_indicator_cls=self.color_indicator_cls,
                font=self.font
            )
            self._layout.addItem(legend_item)


class OWContinuousLegend(Legend):
    """Continuous legend.

    See Also
    --------
    Legend
    OWDiscreteLegend

    """

    def __init__(self, *args, **kwargs):
        # Variables used in the `set_` methods must be set before calling super
        self.__range = kwargs.get('range', ())

        super().__init__(*args, **kwargs)

        self._layout.setContentsMargins(10, 10, 10, 10)

    def set_domain(self, domain):
        class_var = domain.class_var

        if not class_var.is_continuous:
            raise AttributeError('[OWContinuousLegend] The class var provided '
                                 'was not continuous.')

        # The first and last values must represent the range, the rest should
        # be dummy variables, as they are not shown anywhere
        values = self.__range
        self.set_items((values, class_var.palette))

    def set_items(self, values):
        vals, palette = values
        if self.orientation == Qt.Vertical:
            vals = list(reversed(vals))
        self._layout.addItem(ContinuousLegendItem(
            palette=palette,
            values=vals,
            parent=self,
            font=self.font,
            orientation=self.orientation
        ))


class OWBinnedContinuousLegend(Legend):
    """Binned continuous legend in case you don't like gradients.

    This is not implemented yet, but in case it ever needs to be, the stub is
    available.

    See Also
    --------
    Legend
    OWDiscreteLegend
    OWContinuousLegend

    """

    def set_domain(self, domain):
        pass

    def set_items(self, values):
        pass