Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] Scatterplot: Implement grouping of selections #2070

Merged
merged 6 commits into from
Mar 13, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Orange/widgets/tests/base.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import os
import time
from contextlib import contextmanager
import unittest
from unittest.mock import Mock

import sip

import numpy as np
from AnyQt.QtCore import Qt
from AnyQt.QtTest import QTest
from AnyQt.QtWidgets import (
QApplication, QComboBox, QSpinBox, QDoubleSpinBox, QSlider
)
Expand Down Expand Up @@ -217,6 +220,24 @@ def get_output(self, output_name, widget=None):
widget = self.widget
return self.signal_manager.outputs.get((widget, output_name), None)

@contextmanager
def modifiers(self, modifiers):
"""
Context that simulates pressed modifiers

Since QTest.keypress requries pressing some key, we simulate
pressing "BassBoost" that looks exotic enough to not meddle with
anything.
"""
old_modifiers = QApplication.keyboardModifiers()
try:
QTest.keyPress(self.widget, Qt.Key_BassBoost, modifiers)
yield
finally:
QTest.keyRelease(self.widget, Qt.Key_BassBoost, old_modifiers)




class BaseParameterMapping:
"""Base class for mapping between gui components and learner's parameters
Expand Down
4 changes: 2 additions & 2 deletions Orange/widgets/utils/annotated_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
ANNOTATED_DATA_FEATURE_NAME = "Selected"


def _get_next_name(names, name):
def get_next_name(names, name):
"""
Returns next 'possible' attribute name. The name should not be duplicated
and is generated using name parameter, appended by smallest possible index.
Expand Down Expand Up @@ -35,7 +35,7 @@ def create_annotated_table(data, selected_indices):
if data is None:
return None
names = [var.name for var in data.domain.variables + data.domain.metas]
name = _get_next_name(names, ANNOTATED_DATA_FEATURE_NAME)
name = get_next_name(names, ANNOTATED_DATA_FEATURE_NAME)
metas = data.domain.metas + (DiscreteVariable(name, ("No", "Yes")),)
domain = Domain(data.domain.attributes, data.domain.class_vars, metas)
annotated = np.zeros((len(data), 1))
Expand Down
43 changes: 39 additions & 4 deletions Orange/widgets/visualize/owscatterplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
from Orange.widgets.visualize.utils import VizRankDialogAttrPair
from Orange.widgets.widget import OWWidget, Default, AttributeList, Msg
from Orange.widgets.utils.annotated_data import (create_annotated_table,
ANNOTATED_DATA_SIGNAL_NAME)
ANNOTATED_DATA_SIGNAL_NAME,
get_next_name)


def font_resize(font, factor, minsize=None, maxsize=None):
Expand Down Expand Up @@ -280,6 +281,14 @@ def fit_to_view():
)
self.addActions([zoom_in, zoom_out, zoom_fit])

def keyPressEvent(self, event):
super().keyPressEvent(event)
self.graph.update_tooltip(event.modifiers())

def keyReleaseEvent(self, event):
super().keyReleaseEvent(event)
self.graph.update_tooltip(event.modifiers())

# def settingsFromWidgetCallback(self, handler, context):
# context.selectionPolygons = []
# for curve in self.graph.selectionCurveList:
Expand Down Expand Up @@ -471,19 +480,45 @@ def update_graph(self, reset_view=True, **_):
def selection_changed(self):
self.send_data()

@staticmethod
def create_groups_table(data, selection):
if data is None:
return None
names = [var.name for var in data.domain.variables + data.domain.metas]
name = get_next_name(names, "Selection group")
metas = data.domain.metas + (
DiscreteVariable(
name,
["Unselected"] + ["G{}".format(i + 1)
for i in range(np.max(selection))]),
)
domain = Domain(data.domain.attributes, data.domain.class_vars, metas)
table = Table(
domain, data.X, data.Y,
metas=np.hstack((data.metas, selection.reshape(len(data), 1))))
table.attributes = data.attributes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

table.ids = data.ids

table.ids = data.ids
return table

def send_data(self):
selected = None
selection = None
# TODO: Implement selection for sql data
graph = self.graph
if isinstance(self.data, SqlTable):
selected = self.data
elif self.data is not None:
selection = self.graph.get_selection()
selection = graph.get_selection()
if len(selection) > 0:
selected = self.data[selection]
if graph.selection is not None and np.max(graph.selection) > 1:
annotated = self.create_groups_table(self.data, graph.selection)
else:
annotated = create_annotated_table(self.data, selection)
self.send("Selected Data", selected)
self.send(ANNOTATED_DATA_SIGNAL_NAME,
create_annotated_table(self.data, selection))
self.send(ANNOTATED_DATA_SIGNAL_NAME, annotated)



def send_features(self):
features = None
Expand Down
89 changes: 75 additions & 14 deletions Orange/widgets/visualize/owscatterplotgraph.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sys
import itertools
from xml.sax.saxutils import escape
from math import log10, floor, ceil
Expand All @@ -8,7 +9,8 @@
from AnyQt.QtCore import Qt, QObject, QEvent, QRectF, QPointF, QSize
from AnyQt.QtGui import (
QStaticText, QColor, QPen, QBrush, QPainterPath, QTransform, QPainter)
from AnyQt.QtWidgets import QApplication, QToolTip, QPinchGesture
from AnyQt.QtWidgets import QApplication, QToolTip, QPinchGesture, \
QGraphicsTextItem, QGraphicsRectItem

import pyqtgraph as pg
from pyqtgraph.graphicsItems.ViewBox import ViewBox
Expand Down Expand Up @@ -356,12 +358,17 @@ def mouseDragEvent(self, ev, axis=None):
pos = ev.pos()
if ev.button() == Qt.LeftButton:
self.safe_update_scale_box(ev.buttonDownPos(), ev.pos())
scene = self.scene()
dragtip = scene.drag_tooltip
if ev.isFinish():
dragtip.hide()
self.rbScaleBox.hide()
pixel_rect = QRectF(ev.buttonDownPos(ev.button()), pos)
value_rect = self.childGroup.mapRectFromParent(pixel_rect)
self.graph.select_by_rectangle(value_rect)
else:
dragtip.setPos(10, self.height() + 3)
dragtip.show() # although possibly already shown
self.safe_update_scale_box(ev.buttonDownPos(), ev.pos())
elif self.graph.state == ZOOMING or self.graph.state == PANNING:
ev.ignore()
Expand Down Expand Up @@ -492,6 +499,9 @@ def __init__(self, scatter_widget, parent=None, _="None"):
self.plot_widget.getPlotItem().buttonsHidden = True
self.plot_widget.setAntialiasing(True)
self.plot_widget.sizeHint = lambda: QSize(500, 500)
scene = self.plot_widget.scene()
self._create_drag_tooltip(scene)


self.replot = self.plot_widget.replot
ScaleScatterPlotData.__init__(self)
Expand Down Expand Up @@ -548,6 +558,39 @@ def __init__(self, scatter_widget, parent=None, _="None"):
self._tooltip_delegate = HelpEventDelegate(self.help_event)
self.plot_widget.scene().installEventFilter(self._tooltip_delegate)

def _create_drag_tooltip(self, scene):
tip_parts = [
(Qt.ShiftModifier, "Shift: Add group"),
(Qt.ShiftModifier + Qt.ControlModifier,
"Shift-{}: Append to group".
format("Cmd" if sys.platform == "darwin" else "Ctrl")),
(Qt.AltModifier, "Alt: Remove")
]
all_parts = ", ".join(part for _, part in tip_parts)
self.tiptexts = {
int(modifier): all_parts.replace(part, "<b>{}</b>".format(part))
for modifier, part in tip_parts
}
self.tiptexts[0] = all_parts

self.tip_textitem = text = QGraphicsTextItem()
# Set to the longest text
text.setHtml(self.tiptexts[Qt.ShiftModifier + Qt.ControlModifier])
text.setPos(4, 2)
r = text.boundingRect()
rect = QGraphicsRectItem(0, 0, r.width() + 8, r.height() + 4)
rect.setBrush(QColor(224, 224, 224, 212))
rect.setPen(QPen(Qt.NoPen))
self.update_tooltip(Qt.NoModifier)

scene.drag_tooltip = scene.createItemGroup([rect, text])
scene.drag_tooltip.hide()

def update_tooltip(self, modifiers):
modifiers &= Qt.ShiftModifier + Qt.ControlModifier + Qt.AltModifier
text = self.tiptexts.get(int(modifiers), self.tiptexts[0])
self.tip_textitem.setHtml(text)

def new_data(self, data, subset_data=None, **args):
self.plot_widget.clear()
self.remove_legend()
Expand Down Expand Up @@ -760,12 +803,24 @@ def make_pen(color, width):
p.setCosmetic(True)
return p

pens = [QPen(Qt.NoPen),
make_pen(QColor(255, 190, 0, 255), SELECTION_WIDTH + 1.)]
nopen = QPen(Qt.NoPen)
if self.selection is not None:
sels = np.max(self.selection)
if sels == 1:
pens = [nopen,
make_pen(QColor(255, 190, 0, 255),
SELECTION_WIDTH + 1.)]
else:
# Start with the first color so that the colors of the
# additional attribute in annotation (which start with 0,
# unselected) will match these colors
palette = ColorPaletteGenerator(number_of_colors=sels + 1)
pens = [nopen] + \
[make_pen(palette[i + 1], SELECTION_WIDTH + 1.)
for i in range(sels)]
pen = [pens[a] for a in self.selection[self.valid_data]]
else:
pen = [pens[0]] * self.n_points
pen = [nopen] * self.n_points
brush = [QBrush(QColor(255, 255, 255, 0))] * self.n_points
return pen, brush

Expand Down Expand Up @@ -1033,25 +1088,31 @@ def select(self, points):
# noinspection PyArgumentList
if self.data is None:
return
keys = QApplication.keyboardModifiers()
if self.selection is None or not keys & (
Qt.ShiftModifier + Qt.ControlModifier + Qt.AltModifier):
self.selection = np.full(len(self.data), False, dtype=np.bool)
if self.selection is None:
self.selection = np.zeros(len(self.data), dtype=np.uint8)
indices = [p.data() for p in points]
keys = QApplication.keyboardModifiers()
# Remove from selection
if keys & Qt.AltModifier:
self.selection[indices] = False
elif keys & Qt.ControlModifier:
self.selection[indices] = ~self.selection[indices]
else: # Handle shift and no modifiers
self.selection[indices] = True
self.selection[indices] = 0
# Append to the last group
elif keys & Qt.ShiftModifier and keys & Qt.ControlModifier:
self.selection[indices] = np.max(self.selection)
# Create a new group
elif keys & Qt.ShiftModifier:
self.selection[indices] = np.max(self.selection) + 1
# No modifiers: new selection
else:
self.selection = np.zeros(len(self.data), dtype=np.uint8)
self.selection[indices] = 1
self.update_colors(keep_colors=True)
if self.label_only_selected:
self.update_labels()
self.master.selection_changed()

def get_selection(self):
if self.selection is None:
return np.array([], dtype=int)
return np.array([], dtype=np.uint8)
else:
return np.flatnonzero(self.selection)

Expand Down
74 changes: 73 additions & 1 deletion Orange/widgets/visualize/tests/test_owscatterplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
from unittest.mock import MagicMock
import numpy as np

from AnyQt.QtCore import QRectF
from AnyQt.QtCore import QRectF, Qt

from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable
from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin, datasets
from Orange.widgets.visualize.owscatterplot import \
OWScatterPlot, ScatterPlotVizRank
from Orange.widgets.tests.utils import simulate

from Orange.widgets.utils.annotated_data import ANNOTATED_DATA_SIGNAL_NAME


class TestOWScatterPlot(WidgetTest, WidgetOutputsTestMixin):
@classmethod
Expand Down Expand Up @@ -129,3 +131,73 @@ def test_regression_line(self):
self.widget.cb_attr_y.setCurrentIndex(4)
self.assertFalse(self.widget.cb_reg_line.isEnabled())
self.assertIsNone(self.widget.graph.reg_line_item)

def test_group_selections(self):
self.send_signal("Data", self.data)
graph = self.widget.graph
points = graph.scatterplot_item.points()
sel_column = np.zeros((len(self.data), 1))

x = self.data.X

def selectedx():
return self.get_output("Selected Data").X

def annotated():
return self.get_output(ANNOTATED_DATA_SIGNAL_NAME).metas

def annotations():
return self.get_output(ANNOTATED_DATA_SIGNAL_NAME
).domain.metas[0].values

# Select 0:5
graph.select(points[:5])
np.testing.assert_equal(selectedx(), x[:5])
sel_column[:5] = 1
np.testing.assert_equal(annotated(), sel_column)
self.assertEqual(annotations(), ["No", "Yes"])

# Shift-select 5:10; now we have groups 0:5 and 5:10
with self.modifiers(Qt.ShiftModifier):
graph.select(points[5:10])
np.testing.assert_equal(selectedx(), x[:10])
sel_column[5:10] = 2
np.testing.assert_equal(annotated(), sel_column)
self.assertEqual(len(annotations()), 3)

# Select: 15:20; we have 15:20
graph.select(points[15:20])
sel_column = np.zeros((len(self.data), 1))
sel_column[15:20] = 1
np.testing.assert_equal(selectedx(), x[15:20])
self.assertEqual(annotations(), ["No", "Yes"])

# Alt-select (remove) 10:17; we have 17:20
with self.modifiers(Qt.AltModifier):
graph.select(points[10:17])
np.testing.assert_equal(selectedx(), x[17:20])
sel_column[15:17] = 0
np.testing.assert_equal(annotated(), sel_column)
self.assertEqual(annotations(), ["No", "Yes"])

# Ctrl-Shift-select (add-to-last) 10:17; we have 17:25
with self.modifiers(Qt.ShiftModifier | Qt.ControlModifier):
graph.select(points[20:25])
np.testing.assert_equal(selectedx(), x[17:25])
sel_column[20:25] = 1
np.testing.assert_equal(annotated(), sel_column)
self.assertEqual(annotations(), ["No", "Yes"])

# Shift-select (add) 30:35; we have 17:25, 30:35
with self.modifiers(Qt.ShiftModifier):
graph.select(points[30:35])
# ... then Ctrl-Shift-select (add-to-last) 10:17; we have 17:25, 30:40
with self.modifiers(Qt.ShiftModifier | Qt.ControlModifier):
graph.select(points[35:40])
sel_column[30:40] = 2
np.testing.assert_equal(annotated(), sel_column)
self.assertEqual(len(annotations()), 3)

if __name__ == "__main__":
import unittest
unittest.main()
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading