source: orange-bioinformatics/orangecontrib/bio/widgets/OWVennDiagram.py @ 1904:e999c567f146

Revision 1904:e999c567f146, 33.9 KB checked in by Ales Erjavec <ales.erjavec@…>, 5 months ago (diff)

Fixed an error when removing widget inputs.

Line 
1"""
2
3"""
4from __future__ import division
5
6import math
7
8from collections import namedtuple, OrderedDict
9
10from xml.sax.saxutils import escape
11
12import numpy
13
14from PyQt4.QtGui import (
15    QComboBox, QGraphicsScene, QGraphicsView, QGraphicsWidget,
16    QGraphicsPathItem, QGraphicsTextItem, QPainterPath, QPainter,
17    QTransform, QColor, QBrush, QPen, QStyle, QPalette,
18    QApplication
19)
20
21from PyQt4.QtCore import Qt, QPointF, QRectF, QLineF
22from PyQt4.QtCore import pyqtSignal as Signal
23
24import Orange
25
26from Orange.OrangeWidgets.OWWidget import OWWidget, Multiple
27
28from Orange.OrangeWidgets import OWGUI, OWItemModels, OWColorPalette
29
30NAME = "Venn Diagram"
31
32ICON = "icons/VennDiagram.svg"
33
34INPUTS = [("Data", Orange.data.Table, "setData", Multiple)]
35
36OUTPUTS = [("Data", Orange.data.Table)]
37
38
39_InputData = namedtuple("_InputData", ["key", "name", "table"])
40_ItemSet = namedtuple("_ItemSet", ["key", "name", "title", "items"])
41
42
43class OWVennDiagram(OWWidget):
44    settingsList = ["selection", "autocommit", "inputhints"]
45
46    def __init__(self, parent=None, signalManager=None,
47                 title="Venn Diagram"):
48        super(OWVennDiagram, self).__init__(parent, signalManager, title,
49                                            wantGraph=True)
50
51        self.autocommit = False
52        # Selected disjoint subset indices
53        self.selection = []
54
55        # Stored input set hints
56        # {(index, inputname, attributes): (selectedattrname, itemsettitle)}
57        self.inputhints = {}
58
59        self.loadSettings()
60
61        # Output changed flag
62        self._changed = False
63        # Diagram update is in progress
64        self._updating = False
65        # Input update is in progress
66        self._inputUpdate = False
67
68        # Input datasets
69        self.data = OrderedDict()
70        # Extracted input item sets
71        self.itemsets = OrderedDict()
72
73        # GUI
74        box = OWGUI.widgetBox(self.controlArea, "Info")
75        self.info = OWGUI.widgetLabel(box, "No data on input\n")
76
77        self.inputsBox = OWGUI.widgetBox(self.controlArea, "Inputs")
78
79        for i in range(5):
80            box = OWGUI.widgetBox(self.inputsBox, "Input %i" % i, flat=True)
81            model = OWItemModels.VariableListModel(parent=self)
82            cb = QComboBox()
83            cb.setModel(model)
84            cb.activated[int].connect(self._on_inputAttrActivated)
85            box.setEnabled(False)
86            # Store the combo in the box for later use.
87            box.combo_box = cb
88            box.layout().addWidget(cb)
89
90        OWGUI.rubber(self.controlArea)
91
92        box = OWGUI.widgetBox(self.controlArea, "Output")
93        cb = OWGUI.checkBox(box, self, "autocommit", "Commit on any change")
94        b = OWGUI.button(box, self, "Commit", default=True)
95        OWGUI.setStopper(self, b, cb, "_changed", callback=self.commit)
96
97        # Main area view
98        self.scene = QGraphicsScene()
99        self.view = QGraphicsView(self.scene)
100        self.view.setRenderHint(QPainter.Antialiasing)
101        self.view.setBackgroundRole(QPalette.Window)
102        self.view.setFrameStyle(QGraphicsView.StyledPanel)
103
104        self.mainArea.layout().addWidget(self.view)
105        self.vennwidget = VennDiagram()
106        self.vennwidget.resize(400, 400)
107        self.vennwidget.itemTextEdited.connect(self._on_itemTextEdited)
108        self.scene.selectionChanged.connect(self._on_selectionChanged)
109
110        self.scene.addItem(self.vennwidget)
111
112        self.resize(self.controlArea.sizeHint().width() + 550,
113                    max(self.controlArea.sizeHint().height(), 550))
114
115        self._queue = []
116        self.graphButton.clicked.connect(self.saveImage)
117
118    def setData(self, data, key=None):
119        self.error(0)
120        if not self._inputUpdate:
121            self._storeHints()
122            self._inputUpdate = True
123
124        if key in self.data:
125            if data is None:
126                # Remove the input
127                self._remove(key)
128            else:
129                # Update existing item
130                self._update(key, data)
131        elif data is not None:
132            # TODO: Allow setting more them 5 inputs and let the user
133            # select the 5 to display.
134            if len(self.data) == 5:
135                self.error(0, "Can only take 5 inputs.")
136                return
137            # Add a new input
138            self._add(key, data)
139
140    def handleNewSignals(self):
141        self._inputUpdate = False
142        incremental = all(inc for _, inc in self._queue)
143
144        if incremental:
145            self._updateItemsets()
146        else:
147            self._createItemsets()
148            self._restoreHints()
149            self._updateItemsets()
150
151        del self._queue[:]
152
153        self._createDiagram()
154        if self.data:
155            self.info.setText("{} datasets on input.\n".format(len(self.data)))
156        else:
157            self.info.setText("No data on input\n")
158
159        OWWidget.handleNewSignals(self)
160
161    def _invalidate(self, keys=None, incremental=True):
162        """
163        Invalidate input for a list of input keys.
164        """
165        if keys is None:
166            keys = self.data.keys()
167
168        self._queue.extend((key, incremental) for key in keys)
169
170    def itemsetAttr(self, key):
171        index = self.data.keys().index(key)
172        _, combo = self._controlAtIndex(index)
173        model = combo.model()
174        attr_index = combo.currentIndex()
175        if attr_index >= 0:
176            return model[attr_index]
177        else:
178            return None
179
180    def _controlAtIndex(self, index):
181        group_box = self.inputsBox.layout().itemAt(index).widget()
182        combo = group_box.combo_box
183        return group_box, combo
184
185    def _setAttributes(self, index, attrs):
186        box, combo = self._controlAtIndex(index)
187        model = combo.model()
188
189        if attrs is None:
190            model[:] = []
191            box.setEnabled(False)
192        else:
193            if model[:] != attrs:
194                model[:] = attrs
195
196            box.setEnabled(True)
197
198    def _add(self, key, table):
199        name = table.name
200        index = len(self.data)
201        self.data[key] = _InputData(key, name, table)
202
203        attrs = string_attributes(table.domain)
204        self._setAttributes(index, attrs)
205
206        self._invalidate([key], incremental=False)
207
208        item = self.inputsBox.layout().itemAt(index)
209        box = item.widget()
210        box.setTitle("Input: {}".format(name))
211
212    def _remove(self, key):
213        index = self.data.keys().index(key)
214        box, combo = self._controlAtIndex(index)
215        self._setAttributes(index, None)
216
217        del self.data[key]
218
219        layout = self.inputsBox.layout()
220        item = layout.takeAt(index)
221        layout.addItem(item)
222        inputs = self.data.values()
223
224        for i in range(5):
225            box, _ = self._controlAtIndex(i)
226            if i < len(inputs):
227                title = "Input: {}".format(inputs[i].name)
228            else:
229                title = "Input {}".format(i)
230            box.setTitle(title)
231
232        self._invalidate([key], incremental=False)
233
234    def _update(self, key, table):
235        name = table.name
236        index = self.data.keys().index(key)
237        self.data[key] = self.data[key]._replace(name=name, table=table)
238
239        attrs = string_attributes(table.domain)
240
241        self._setAttributes(index, attrs)
242        self._invalidate([key])
243
244        item = self.inputsBox.layout().itemAt(index)
245        box = item.widget()
246        box.setTitle("Input: {}".format(name))
247
248    def _updateItemsets(self):
249        assert self.data.keys() == self.itemsets.keys()
250        for key, input in self.data.items():
251            attr = self.itemsetAttr(key)
252            items = [str(inst[attr]) for inst in input.table
253                     if not inst[attr].is_special()]
254
255            item = self.itemsets[key]
256            item = item._replace(items=items)
257            if item.name != input.name:
258                item = item._replace(name=input.name, title=input.name)
259            self.itemsets[key] = item
260
261    def _createItemsets(self):
262        olditemsets = dict(self.itemsets)
263        self.itemsets.clear()
264        for key, input in self.data.items():
265            attr = self.itemsetAttr(key)
266            items = [str(inst[attr]) for inst in input.table
267                     if not inst[attr].is_special()]
268
269            title = input.name
270            if key in olditemsets and olditemsets[key].name == input.name:
271                title = olditemsets[key].title
272
273            itemset = _ItemSet(key=key, name=input.name, title=title,
274                               items=items)
275            self.itemsets[key] = itemset
276
277    def _storeHints(self):
278        if self.data:
279            self.inputhints.clear()
280            for i, (key, input) in enumerate(self.data.items()):
281                attrs = string_attributes(input.table.domain)
282                attrs = tuple(attr.name for attr in attrs)
283                selected = self.itemsetAttr(key)
284                itemset = self.itemsets[key]
285                self.inputhints[(i, input.name, attrs)] = \
286                    (selected.name, itemset.title)
287
288    def _restoreHints(self):
289        settings = []
290        for i, (key, input) in enumerate(self.data.items()):
291            attrs = string_attributes(input.table.domain)
292            attrs = tuple(attr.name for attr in attrs)
293            hint = self.inputhints.get((i, input.name, attrs), None)
294            if hint is not None:
295                attr, name = hint
296                attr_ind = attrs.index(attr)
297                settings.append((attr_ind, name))
298            else:
299                return
300
301        # all inputs match the stored hints
302        for i, key in enumerate(self.itemsets):
303            attr, itemtitle = settings[i]
304            self.itemsets[key] = self.itemsets[key]._replace(title=itemtitle)
305            _, cb = self._controlAtIndex(i)
306            cb.setCurrentIndex(attr)
307
308    def _createDiagram(self):
309        self._updating = True
310
311        oldselection = list(self.selection)
312
313        self.vennwidget.clear()
314        n = len(self.itemsets)
315        self.disjoint = disjoint(set(s.items) for s in self.itemsets.values())
316
317        vennitems = []
318        colors = OWColorPalette.ColorPaletteHSV(n)
319
320        for i, (key, item) in enumerate(self.itemsets.items()):
321            gr = VennSetItem(text=item.title, count=len(item.items))
322            color = colors[i]
323            color.setAlpha(100)
324            gr.setBrush(QBrush(color))
325            gr.setPen(QPen(Qt.NoPen))
326            vennitems.append(gr)
327
328        self.vennwidget.setItems(vennitems)
329
330        def label_text(i):
331            return "".join(chr(ord("A") + i)
332                           for i, b in enumerate(setkey(i, n)) if b)
333
334        for i, area in enumerate(self.vennwidget.vennareas()):
335            area_items = list(self.disjoint[i])
336            if i:
337                area.setText("{0}".format(len(area_items)))
338
339            label = label_text(i)
340            head = "<h4>|{}| = {}</h4>".format(label, len(area_items))
341            if len(area_items) > 32:
342                items_str = ", ".join(map(escape, area_items[:32]))
343                hidden = len(area_items) - 32
344                tooltip = ("{}<span>{}, ...</br>({} items not shown)<span>"
345                           .format(head, items_str, hidden))
346            elif area_items:
347                tooltip = "{}<span>{}</span>".format(
348                    head,
349                    ", ".join(map(escape, area_items))
350                )
351            else:
352                tooltip = head
353
354            area.setToolTip(tooltip)
355
356            area.setPen(QPen(QColor(10, 10, 10, 200), 1.5))
357            area.setFlag(QGraphicsPathItem.ItemIsSelectable, True)
358            area.setSelected(i in oldselection)
359
360        self._updating = False
361        self._on_selectionChanged()
362
363    def _on_selectionChanged(self):
364        if self._updating:
365            return
366
367        areas = self.vennwidget.vennareas()
368        indices = [i for i, area in enumerate(areas)
369                   if area.isSelected()]
370
371        self.selection = indices
372
373        self.invalidateOutput()
374
375    def _on_inputAttrActivated(self, attr_index):
376        combo = self.sender()
377        # Find the input index to which the combo box belongs
378        # (they are reordered when removing inputs).
379        index = None
380        inputs = self.data.items()
381        for i in range(len(inputs)):
382            _, c = self._controlAtIndex(i)
383            if c is combo:
384                index = i
385                break
386
387        assert (index is not None)
388
389        key, _ = inputs[index]
390
391        self._invalidate([key])
392        self._updateItemsets()
393        self._createDiagram()
394
395    def _on_itemTextEdited(self, index, text):
396        text = str(text)
397        key = self.itemsets.keys()[index]
398        self.itemsets[key] = self.itemsets[key]._replace(title=text)
399
400    def invalidateOutput(self):
401        if self.autocommit:
402            self.commit()
403        else:
404            self._changed = True
405
406    def commit(self):
407        selected_subsets = []
408
409        selected_items = reduce(
410            set.union, [self.disjoint[index] for index in self.selection],
411            set()
412        )
413
414        def match(val):
415            if val.is_special():
416                return False
417            else:
418                return str(val) in selected_items
419
420        for key, input in self.data.items():
421            attr = self.itemsetAttr(key)
422            mask = map(match, (inst[attr] for inst in input.table))
423            subset = input.table.select(mask)
424
425            if subset:
426                selected_subsets.append(subset)
427
428        if selected_subsets:
429            data = table_concat(selected_subsets)
430        else:
431            data = None
432
433        self.send("Data", data)
434
435    def saveImage(self):
436        from Orange.OrangeWidgets.OWDlgs import OWChooseImageSizeDlg
437        dlg = OWChooseImageSizeDlg(self.scene, parent=self)
438        dlg.exec_()
439
440    def getSettings(self, *args, **kwargs):
441        self._storeHints()
442        return OWWidget.getSettings(self, *args, **kwargs)
443
444
445def table_concat(tables):
446    features = []
447    features_seen = set()
448    class_var = None
449    metas = {}
450    metas_seen = set()
451
452    for table in tables:
453        features.extend(f for f in table.domain.features
454                        if f not in features_seen)
455        features_seen.update(features)
456
457        if table.domain.class_var is not None:
458            if class_var is not None and table.domain.class_var != class_var:
459                raise ValueError("Tables contains mismatching class var.")
460            else:
461                class_var = table.domain.class_var
462                features_seen.add(class_var)
463
464        new_metas = {mid: meta
465                     for mid, meta in table.domain.getmetas().items()
466                     if meta not in metas_seen}
467
468        metas_seen.update(new_metas.itervalues())
469
470        metas.update(new_metas)
471
472    domain = Orange.data.Domain(features, class_var)
473    domain.addmetas(metas)
474
475    new_table = Orange.data.Table(domain)
476    for table in tables:
477        new_table.extend(table.translate(domain))
478
479    return new_table
480
481
482def string_attributes(domain):
483    """
484    Return all string attributes from the domain.
485    """
486    return [attr for attr in domain.variables +
487                domain.getmetas().values()
488            if isinstance(attr, Orange.feature.String)]
489
490
491def disjoint(sets):
492    """
493    Return all disjoint subsets.
494    """
495    sets = list(sets)
496    n = len(sets)
497    disjoint_sets = [None] * (2 ** n)
498    for i in range(2 ** n):
499        key = setkey(i, n)
500        included = [s for s, inc in zip(sets, key) if inc]
501        excluded = [s for s, inc in zip(sets, key) if not inc]
502        if any(included):
503            s = reduce(set.intersection, included)
504        else:
505            s = set()
506
507        s = reduce(set.difference, excluded, s)
508
509        disjoint_sets[i] = s
510
511    return disjoint_sets
512
513
514class VennSetItem(QGraphicsPathItem):
515    def __init__(self, parent=None, text=None, count=None):
516        super(VennSetItem, self).__init__(parent)
517        self.text = text
518        self.count = count
519
520
521# TODO: Use palette's selected/highligted text / background colors to
522# indicate selection
523
524class VennIntersectionArea(QGraphicsPathItem):
525    def __init__(self, parent=None, text=""):
526        super(QGraphicsPathItem, self).__init__(parent)
527        self.setAcceptHoverEvents(True)
528        self.setPen(QPen(Qt.NoPen))
529
530        self.text = QGraphicsTextItem(self)
531        layout = self.text.document().documentLayout()
532        layout.documentSizeChanged.connect(self._onLayoutChanged)
533
534        self._text = ""
535        self._anchor = QPointF()
536
537    def setText(self, text):
538        if self._text != text:
539            self._text = text
540            self.text.setPlainText(text)
541
542    def text(self):
543        return self._text
544
545    def setTextAnchor(self, pos):
546        if self._anchor != pos:
547            self._anchor = pos
548            self._updateTextAnchor()
549
550    def hoverEnterEvent(self, event):
551        self.setZValue(self.zValue() + 1)
552        return QGraphicsPathItem.hoverEnterEvent(self, event)
553
554    def hoverLeaveEvent(self, event):
555        self.setZValue(self.zValue() - 1)
556        return QGraphicsPathItem.hoverLeaveEvent(self, event)
557
558#     def mousePressEvent(self, event):
559#         pos = event.pos()
560#         parent = self.parentItem()
561#         pbrect = parent.boundingRect()
562#         w, h = pbrect.width(), pbrect.height()
563#         print "(%.3f, %.3f)" % (pos.x() / w, pos.y() / h)
564#         super(VennIntersectionArea, self).mousePressEvent(event)
565
566    def paint(self, painter, option, widget=None):
567        painter.save()
568        path = self.path()
569        brush = QBrush(self.brush())
570        pen = QPen(self.pen())
571
572        if option.state & QStyle.State_Selected:
573            pen.setColor(Qt.red)
574            brush.setStyle(Qt.DiagCrossPattern)
575            brush.setColor(QColor(40, 40, 40, 100))
576
577        elif option.state & QStyle.State_MouseOver:
578            pen.setColor(Qt.blue)
579
580        if option.state & QStyle.State_MouseOver:
581            brush.setColor(QColor(100, 100, 100, 100))
582            if brush.style() == Qt.NoBrush:
583                # Make sure the highlight is actually visible.
584                brush.setStyle(Qt.SolidPattern)
585
586        painter.setPen(pen)
587        painter.setBrush(brush)
588        painter.drawPath(path)
589        painter.restore()
590
591    def itemChange(self, change, value):
592        if change == QGraphicsPathItem.ItemSelectedHasChanged:
593            if value.toBool():
594                self.setZValue(self.zValue() + 1)
595            else:
596                self.setZValue(self.zValue() - 1)
597
598        return QGraphicsPathItem.itemChange(self, change, value)
599
600    def _updateTextAnchor(self):
601        rect = self.text.boundingRect()
602        pos = anchor_rect(rect, self._anchor)
603        self.text.setPos(pos)
604
605    def _onLayoutChanged(self):
606        self._updateTextAnchor()
607
608
609class GraphicsTextEdit(QGraphicsTextItem):
610    #: Edit triggers
611    NoEditTriggers, DoubleClicked = 0, 1
612
613    editingFinished = Signal()
614    editingStarted = Signal()
615
616    documentSizeChanged = Signal()
617
618    def __init__(self, *args, **kwargs):
619        super(GraphicsTextEdit, self).__init__(*args, **kwargs)
620        self.setTabChangesFocus(True)
621        self._edittrigger = GraphicsTextEdit.DoubleClicked
622        self._editing = False
623        self.document().documentLayout().documentSizeChanged.connect(
624            self.documentSizeChanged
625        )
626
627    def mouseDoubleClickEvent(self, event):
628        super(GraphicsTextEdit, self).mouseDoubleClickEvent(event)
629        if self._edittrigger == GraphicsTextEdit.DoubleClicked:
630            self._start()
631
632    def focusOutEvent(self, event):
633        super(GraphicsTextEdit, self).focusOutEvent(event)
634
635        if self._editing:
636            self._end()
637
638    def _start(self):
639        self._editing = True
640        self.setTextInteractionFlags(Qt.TextEditorInteraction)
641        self.setFocus(Qt.MouseFocusReason)
642        self.editingStarted.emit()
643
644    def _end(self):
645        self._editing = False
646        self.setTextInteractionFlags(Qt.NoTextInteraction)
647        self.editingFinished.emit()
648
649
650class VennDiagram(QGraphicsWidget):
651    # rect and petal are for future work
652    Circle, Ellipse, Rect, Petal = 1, 2, 3, 4
653
654    TitleFormat = "<center><h4>{0}</h4>{1}</center>"
655
656    selectionChanged = Signal()
657    itemTextEdited = Signal(int, str)
658
659    def __init__(self, parent=None):
660        super(VennDiagram, self).__init__(parent)
661        self.shapeType = VennDiagram.Circle
662
663        self._setup()
664
665    def _setup(self):
666        self._items = []
667        self._vennareas = []
668        self._textitems = []
669
670    def item(self, index):
671        return self._items[index]
672
673    def items(self):
674        return list(self._items)
675
676    def count(self):
677        return len(self._items)
678
679    def setItems(self, items):
680        if self._items:
681            self.clear()
682
683        self._items = list(items)
684
685        for item in self._items:
686            item.setParentItem(self)
687            item.setVisible(True)
688
689        fmt = self.TitleFormat.format
690
691        font = self.font()
692        font.setPixelSize(14)
693
694        for item in items:
695            text = GraphicsTextEdit(self)
696            text.setFont(font)
697            text.setDefaultTextColor(QColor("#333"))
698            text.setHtml(fmt(escape(item.text), item.count))
699            text.adjustSize()
700            text.editingStarted.connect(self._on_editingStarted)
701            text.editingFinished.connect(self._on_editingFinished)
702            text.documentSizeChanged.connect(
703                self._on_itemTextSizeChanged
704            )
705
706            self._textitems.append(text)
707
708        self._vennareas = [
709            VennIntersectionArea(parent=self)
710            for i in range(2 ** len(items))
711        ]
712        self._subsettextitems = [
713            QGraphicsTextItem(parent=self)
714            for i in range(2 ** len(items))
715        ]
716
717        self._updateLayout()
718
719    def clear(self):
720        scene = self.scene()
721        items = self.vennareas() + self.items() + self._textitems
722
723        for item in self._textitems:
724            item.editingStarted.disconnect(self._on_editingStarted)
725            item.editingFinished.disconnect(self._on_editingFinished)
726            item.documentSizeChanged.disconnect(
727                self._on_itemTextSizeChanged
728            )
729
730        self._items = []
731        self._vennareas = []
732        self._textitems = []
733
734        for item in items:
735            item.setVisible(False)
736            item.setParentItem(None)
737            if scene is not None:
738                scene.removeItem(item)
739
740    def vennareas(self):
741        return list(self._vennareas)
742
743    def setFont(self, font):
744        if self._font != font:
745            self.prepareGeometryChange()
746            self._font = font
747
748            for item in self.items():
749                item.setFont(font)
750
751    def _updateLayout(self):
752        rect = self.geometry()
753        n = len(self._items)
754        if not n:
755            return
756
757        regions = venn_diagram(n, shape=self.shapeType)
758
759        # The y axis in Qt points downward
760        transform = QTransform().scale(1, -1)
761        regions = map(transform.map, regions)
762
763        union_brect = reduce(QRectF.united,
764                             (path.boundingRect() for path in regions))
765
766        scalex = rect.width() / union_brect.width()
767        scaley = rect.height() / union_brect.height()
768        scale = min(scalex, scaley)
769
770        transform = QTransform().scale(scale, scale)
771
772        regions = [transform.map(path) for path in regions]
773
774        center = rect.width() / 2, rect.height() / 2
775        for item, path in zip(self.items(), regions):
776            item.setPath(path)
777            item.setPos(*center)
778
779        intersections = venn_intersections(regions)
780        assert len(intersections) == 2 ** n
781        assert len(self.vennareas()) == 2 ** n
782
783        anchors = [(0, 0)] + subset_anchors(self._items)
784
785        anchor_transform = QTransform().scale(rect.width(), -rect.height())
786        for i, area in enumerate(self.vennareas()):
787            area.setPath(intersections[setkey(i, n)])
788            area.setPos(*center)
789            x, y = anchors[i]
790            anchor = anchor_transform.map(QPointF(x, y))
791            area.setTextAnchor(anchor)
792            area.setZValue(30)
793
794        self._updateTextAnchors()
795
796    def _updateTextAnchors(self):
797        n = len(self._items)
798
799        items = self._items
800        dist = 15
801
802        shape = reduce(QPainterPath.united, [item.path() for item in items])
803        brect = shape.boundingRect()
804        bradius = max(brect.width() / 2, brect.height() / 2)
805
806        center = self.boundingRect().center()
807
808        anchors = _category_anchors(items)
809        self._textanchors = []
810        for angle, anchor_h, anchor_v in anchors:
811            line = QLineF.fromPolar(bradius, angle)
812            ext = QLineF.fromPolar(dist, angle)
813            line = QLineF(line.p1(), line.p2() + ext.p2())
814            line = line.translated(center)
815
816            anchor_pos = line.p2()
817            self._textanchors.append((anchor_pos, anchor_h, anchor_v))
818
819        for i in range(n):
820            self._updateTextItemPos(i)
821
822    def _updateTextItemPos(self, i):
823        item = self._textitems[i]
824        anchor_pos, anchor_h, anchor_v = self._textanchors[i]
825        rect = item.boundingRect()
826        pos = anchor_rect(rect, anchor_pos, anchor_h, anchor_v)
827        item.setPos(pos)
828
829    def setGeometry(self, geometry):
830        super(VennDiagram, self).setGeometry(geometry)
831        self._updateLayout()
832
833    def paint(self, painter, option, w):
834        super(VennDiagram, self).paint(painter, option, w)
835#         painter.drawRect(self.boundingRect())
836
837    def _on_editingStarted(self):
838        item = self.sender()
839        index = self._textitems.index(item)
840        text = self._items[index].text
841        item.setTextWidth(-1)
842        item.setHtml(self.TitleFormat.format(escape(text), "<br/>"))
843
844    def _on_editingFinished(self):
845        item = self.sender()
846        index = self._textitems.index(item)
847        text = item.toPlainText()
848        if text != self._items[index].text:
849            self._items[index].text = text
850
851            self.itemTextEdited.emit(index, text)
852
853        item.setHtml(
854            self.TitleFormat.format(escape(text), self._items[index].count))
855        item.adjustSize()
856
857    def _on_itemTextSizeChanged(self):
858        item = self.sender()
859        index = self._textitems.index(item)
860        self._updateTextItemPos(index)
861
862
863def anchor_rect(rect, anchor_pos,
864                anchor_h=Qt.AnchorHorizontalCenter,
865                anchor_v=Qt.AnchorVerticalCenter):
866
867    if anchor_h == Qt.AnchorLeft:
868        x = anchor_pos.x()
869    elif anchor_h == Qt.AnchorHorizontalCenter:
870        x = anchor_pos.x() - rect.width() / 2
871    elif anchor_h == Qt.AnchorRight:
872        x = anchor_pos.x() - rect.width()
873    else:
874        raise ValueError(anchor_h)
875
876    if anchor_v == Qt.AnchorTop:
877        y = anchor_pos.y()
878    elif anchor_v == Qt.AnchorVerticalCenter:
879        y = anchor_pos.y() - rect.height() / 2
880    elif anchor_v == Qt.AnchorBottom:
881        y = anchor_pos.y() - rect.height()
882    else:
883        raise ValueError(anchor_v)
884
885    return QPointF(x, y)
886
887
888def radians(angle):
889    return 2 * math.pi * angle / 360
890
891
892def unit_point(x, r=1.0):
893    x = radians(x)
894    return (r * math.cos(x), r * math.sin(x))
895
896
897def _category_anchors(shapes):
898    n = len(shapes)
899    return _CATEGORY_ANCHORS[n - 1]
900
901
902# (angle, horizontal anchor, vertical anchor)
903_CATEGORY_ANCHORS = (
904    # n == 1
905    ((90, Qt.AnchorHorizontalCenter, Qt.AnchorBottom),),
906    # n == 2
907    ((180, Qt.AnchorRight, Qt.AnchorVerticalCenter),
908     (0, Qt.AnchorLeft, Qt.AnchorVerticalCenter)),
909    # n == 3
910    ((150, Qt.AnchorRight, Qt.AnchorBottom),
911     (30, Qt.AnchorLeft, Qt.AnchorBottom),
912     (270, Qt.AnchorHorizontalCenter, Qt.AnchorTop)),
913    # n == 4
914    ((270 + 45, Qt.AnchorLeft, Qt.AnchorTop),
915     (270 - 45, Qt.AnchorRight, Qt.AnchorTop),
916     (90 - 15, Qt.AnchorLeft, Qt.AnchorBottom),
917     (90 + 15, Qt.AnchorRight, Qt.AnchorBottom)),
918    # n == 5
919    ((90 - 5, Qt.AnchorHorizontalCenter, Qt.AnchorBottom),
920     (18 - 5, Qt.AnchorLeft, Qt.AnchorVerticalCenter),
921     (306 - 5, Qt.AnchorLeft, Qt.AnchorTop),
922     (234 - 5, Qt.AnchorRight, Qt.AnchorTop),
923     (162 - 5, Qt.AnchorRight, Qt.AnchorVerticalCenter),)
924)
925
926
927def subset_anchors(shapes):
928    n = len(shapes)
929    if n == 1:
930        return [(0, 0)]
931    elif n == 2:
932        return [unit_point(180, r=1/3),
933                unit_point(0, r=1/3),
934                (0, 0)]
935    elif n == 3:
936        return [unit_point(150, r=0.35),  # A
937                unit_point(30, r=0.35),   # B
938                unit_point(90, r=0.27),   # AB
939                unit_point(270, r=0.35),  # C
940                unit_point(210, r=0.27),  # AC
941                unit_point(330, r=0.27),  # BC
942                unit_point(0, r=0),       # ABC
943                ]
944    elif n == 4:
945        anchors = [
946            (0.400, 0.110),    # A
947            (-0.400, 0.110),   # B
948            (0.000, -0.285),   # AB
949            (0.180, 0.330),    # C
950            (0.265, 0.205),    # AC
951            (-0.240, -0.110),  # BC
952            (-0.100, -0.190),  # ABC
953            (-0.180, 0.330),   # D
954            (0.240, -0.110),   # AD
955            (-0.265, 0.205),   # BD
956            (0.100, -0.190),   # ABD
957            (0.000, 0.250),    # CD
958            (0.153, 0.090),    # ACD
959            (-0.153, 0.090),   # BCD
960            (0.000, -0.060),   # ABCD
961        ]
962        return anchors
963
964    elif n == 5:
965        anchors = [None] * 32
966        # Base anchors
967        A = (0.033, 0.385)
968        AD = (0.095, 0.250)
969        AE = (-0.100, 0.265)
970        ACE = (-0.130, 0.220)
971        ADE = (0.010, 0.225)
972        ACDE = (-0.095, 0.175)
973        ABCDE = (0.0, 0.0)
974
975        anchors[-1] = ABCDE
976
977        bases = [(0b00001, A),
978                 (0b01001, AD),
979                 (0b10001, AE),
980                 (0b10101, ACE),
981                 (0b11001, ADE),
982                 (0b11101, ACDE)]
983
984        for i in range(5):
985            for index, anchor in bases:
986                index = bit_rot_left(index, i, bits=5)
987                assert anchors[index] is None
988                anchors[index] = rotate_point(anchor, - 72 * i)
989
990        assert all(anchors[1:])
991        return anchors[1:]
992
993
994def bit_rot_left(x, y, bits=32):
995    mask = 2 ** bits - 1
996    x_masked = x & mask
997    return (x << y) & mask | (x_masked >> bits - y)
998
999
1000def rotate_point(p, angle):
1001    r = radians(angle)
1002    R = numpy.array([[math.cos(r), -math.sin(r)],
1003                     [math.sin(r), math.cos(r)]])
1004    x, y = numpy.dot(R, p)
1005    return (float(x), float(y))
1006
1007
1008def line_extended(line, distance):
1009    """
1010    Return an QLineF extended by `distance` units in the positive direction.
1011    """
1012    angle = line.angle() / 360 * 2 * math.pi
1013    dx, dy = unit_point(angle, r=distance)
1014    return QLineF(line.p1(), line.p2() + QPointF(dx, dy))
1015
1016
1017def circle_path(center, r=1.0):
1018    return ellipse_path(center, r, r, rotation=0)
1019
1020
1021def ellipse_path(center, a, b, rotation=0):
1022    if not isinstance(center, QPointF):
1023        center = QPointF(*center)
1024
1025    brect = QRectF(-a, -b, 2 * a, 2 * b)
1026
1027    path = QPainterPath()
1028    path.addEllipse(brect)
1029
1030    if rotation != 0:
1031        transform = QTransform().rotate(rotation)
1032        path = transform.map(path)
1033
1034    path.translate(center)
1035    return path
1036
1037
1038# TODO: Should include anchors for text layout (both inside and outside).
1039# for each item {path: QPainterPath,
1040#                text_anchors: [{center}] * (2 ** n)
1041#                mayor_axis: QLineF,
1042#                boundingRect QPolygonF (with 4 vertices)}
1043#
1044# Should be a new class with overloads for ellipse/circle, rect, and petal
1045# shapes, should store all constructor parameters, rotation, center,
1046# mayor/minor axis.
1047
1048
1049def venn_diagram(n, shape=VennDiagram.Circle):
1050    if n < 1 or n > 5:
1051        raise ValueError()
1052
1053    paths = []
1054
1055    if n == 1:
1056        paths = [circle_path(center=(0, 0), r=0.5)]
1057    elif n == 2:
1058        angles = [180, 0]
1059        paths = [circle_path(center=unit_point(x, r=1/6), r=1/3)
1060                 for x in angles]
1061    elif n == 3:
1062        angles = [150 - 120 * i for i in range(3)]
1063        paths = [circle_path(center=unit_point(x, r=1/6), r=1/3)
1064                 for x in angles]
1065    elif n == 4:
1066        # Constants shamelessly stolen from VennDiagram R package
1067        paths = [
1068            ellipse_path((0.65 - 0.5, 0.47 - 0.5), 0.35, 0.20, 45),
1069            ellipse_path((0.35 - 0.5, 0.47 - 0.5), 0.35, 0.20, 135),
1070            ellipse_path((0.5 - 0.5, 0.57 - 0.5), 0.35, 0.20, 45),
1071            ellipse_path((0.5 - 0.5, 0.57 - 0.5), 0.35, 0.20, 134),
1072        ]
1073    elif n == 5:
1074        # Constants shamelessly stolen from VennDiagram R package
1075        d = 0.13
1076        a, b = 0.24, 0.48
1077        a, b = b, a
1078        a, b = 0.48, 0.24
1079        paths = [ellipse_path(unit_point((1 - i) * 72, r=d),
1080                              a, b, rotation=90 - (i * 72))
1081                 for i in range(5)]
1082
1083    return paths
1084
1085
1086def setkey(intval, n):
1087    return tuple(bool(intval & (2 ** i)) for i in range(n))
1088
1089
1090def keyrange(n):
1091    if n < 0:
1092        raise ValueError()
1093
1094    for i in range(2 ** n):
1095        yield setkey(i, n)
1096
1097
1098def venn_intersections(paths):
1099    n = len(paths)
1100    return {key: venn_intersection(paths, key) for key in keyrange(n)}
1101
1102
1103def venn_intersection(paths, key):
1104    if not any(key):
1105        return QPainterPath()
1106
1107    # first take the intersection of all included paths
1108    path = reduce(QPainterPath.intersected,
1109                  (path for path, included in zip(paths, key) if included))
1110
1111    # subtract all the excluded sets (i.e. take the intersection
1112    # with the excluded set complements)
1113    path = reduce(QPainterPath.subtracted,
1114                  (path for path, included in zip(paths, key) if not included),
1115                  path)
1116
1117    return path
1118
1119
1120if __name__ == "__main__":
1121    app = QApplication([])
1122    w = OWVennDiagram()
1123    data = Orange.data.Table("brown-selected")
1124
1125    data.domain.addmetas({-42: Orange.feature.String("Test")})
1126    for i, inst in enumerate(data):
1127        inst[-42] = "{}".format(i % 30)
1128
1129    random = Orange.misc.Random()
1130    indices = Orange.data.sample.SubsetIndices2(
1131        p0=0.7, random_generator=random)
1132
1133    d1 = data.select(indices(data))
1134    d2 = data.select(indices(data))
1135    d3 = data.select(indices(data))
1136    d4 = data.select(indices(data))
1137    d5 = data.select(indices(data))
1138
1139    n = 5
1140    for i, data in zip(range(n), [d1, d2, d3, d4, d5]):
1141        data.name = chr(ord("A") + i)
1142        w.setData(data, key=i)
1143
1144    w.handleNewSignals()
1145    w.show()
1146    app.exec_()
1147    w.saveSettings()
Note: See TracBrowser for help on using the repository browser.