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
|