1from xml.sax.saxutils import escape
2
3import numpy as np
4
5from AnyQt.QtCore import QSize, Signal, Qt
6from AnyQt.QtWidgets import QApplication
7
8from orangewidget.utils.visual_settings_dlg import VisualSettingsDialog
9
10from Orange.data import (
11    Table, ContinuousVariable, Domain, Variable, StringVariable
12)
13from Orange.data.util import get_unique_names, array_equal
14from Orange.data.sql.table import SqlTable
15from Orange.statistics.util import bincount
16
17from Orange.widgets import gui, report
18from Orange.widgets.settings import (
19    Setting, ContextSetting, DomainContextHandler, SettingProvider
20)
21from Orange.widgets.utils import colorpalettes
22from Orange.widgets.utils.annotated_data import (
23    create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME, create_groups_table
24)
25from Orange.widgets.utils.plot import OWPlotGUI
26from Orange.widgets.utils.sql import check_sql_input
27from Orange.widgets.visualize.owscatterplotgraph import (
28    OWScatterPlotBase, MAX_COLORS
29)
30from Orange.widgets.visualize.utils.component import OWGraphWithAnchors
31from Orange.widgets.widget import OWWidget, Input, Output, Msg
32
33
34# maximum number of shapes (including Other)
35MAX_SHAPES = len(OWScatterPlotBase.CurveSymbols) - 1
36
37MAX_POINTS_IN_TOOLTIP = 5
38
39
40class OWProjectionWidgetBase(OWWidget, openclass=True):
41    """
42    Base widget for widgets that use attribute data to set the colors, labels,
43    shapes and sizes of points.
44
45    The widgets defines settings `attr_color`, `attr_label`, `attr_shape`
46    and `attr_size`, but leaves defining the gui to the derived widgets.
47    These are expected to have controls that manipulate these settings,
48    and the controls are expected to use attribute models.
49
50    The widgets also defines attributes `data` and `valid_data` and expects
51    the derived widgets to use them to store an instances of `data.Table`
52    and a bool `np.ndarray` with indicators of valid (that is, shown)
53    data points.
54    """
55    attr_color = ContextSetting(None, required=ContextSetting.OPTIONAL)
56    attr_label = ContextSetting(None, required=ContextSetting.OPTIONAL)
57    attr_shape = ContextSetting(None, required=ContextSetting.OPTIONAL)
58    attr_size = ContextSetting(None, required=ContextSetting.OPTIONAL)
59
60    class Information(OWWidget.Information):
61        missing_size = Msg(
62            "Points with undefined '{}' are shown in smaller size")
63        missing_shape = Msg(
64            "Points with undefined '{}' are shown as crossed circles")
65
66    def __init__(self):
67        super().__init__()
68        self.data = None
69        self.valid_data = None
70
71    def init_attr_values(self):
72        """
73        Set the models for `attr_color`, `attr_shape`, `attr_size` and
74        `attr_label`. All values are set to `None`, except `attr_color`
75        which is set to the class variable if it exists.
76        """
77        data = self.data
78        domain = data.domain if data and len(data) else None
79        for attr in ("attr_color", "attr_shape", "attr_size", "attr_label"):
80            getattr(self.controls, attr).model().set_domain(domain)
81            setattr(self, attr, None)
82        if domain is not None:
83            self.attr_color = domain.class_var
84
85    def get_coordinates_data(self):
86        """A get coordinated method that returns no coordinates.
87
88        Derived classes must override this method.
89        """
90        return None, None
91
92    def get_subset_mask(self):
93        """
94        Return the bool array indicating the points in the subset
95
96        The base method does nothing and would usually be overridden by
97        a method that returns indicators from the subset signal.
98
99        Do not confuse the subset with selection.
100
101        Returns:
102            (np.ndarray or `None`): a bool array of indicators
103        """
104        return None
105
106    def get_column(self, attr, filter_valid=True,
107                   max_categories=None, return_labels=False):
108        """
109        Retrieve the data from the given column in the data table
110
111        The method:
112        - densifies sparse data,
113        - converts arrays with dtype object to floats if the attribute is
114          actually primitive,
115        - filters out invalid data (if `filter_valid` is `True`),
116        - merges infrequent (discrete) values into a single value
117          (if `max_categories` is set).
118
119        Tha latter feature is used for shapes and labels, where only a
120        specified number of different values is shown, and others are
121        merged into category 'Other'. In this case, the method may return
122        either the data (e.g. color indices, shape indices) or the list
123        of retained values, followed by `['Other']`.
124
125        Args:
126            attr (:obj:~Orange.data.Variable): the column to extract
127            filter_valid (bool): filter out invalid data (default: `True`)
128            max_categories (int): merge infrequent values (default: `None`);
129                ignored for non-discrete attributes
130            return_labels (bool): return a list of labels instead of data
131                (default: `False`)
132
133        Returns:
134            (np.ndarray): (valid) data from the column, or a list of labels
135        """
136        if attr is None:
137            return None
138
139        needs_merging = attr.is_discrete \
140                        and max_categories is not None \
141                        and len(attr.values) >= max_categories
142        if return_labels and not needs_merging:
143            assert attr.is_discrete
144            return attr.values
145
146        all_data = self.data.get_column_view(attr)[0]
147        if all_data.dtype == object and attr.is_primitive():
148            all_data = all_data.astype(float)
149        if filter_valid and self.valid_data is not None:
150            all_data = all_data[self.valid_data]
151        if not needs_merging:
152            return all_data
153
154        dist = bincount(all_data, max_val=len(attr.values) - 1)[0]
155        infrequent = np.zeros(len(attr.values), dtype=bool)
156        infrequent[np.argsort(dist)[:-(max_categories-1)]] = True
157        if return_labels:
158            return [value for value, infreq in zip(attr.values, infrequent)
159                    if not infreq] + ["Other"]
160        else:
161            result = all_data.copy()
162            freq_vals = [i for i, f in enumerate(infrequent) if not f]
163            for i, infreq in enumerate(infrequent):
164                if infreq:
165                    result[all_data == i] = max_categories - 1
166                else:
167                    result[all_data == i] = freq_vals.index(i)
168            return result
169
170    # Sizes
171    def get_size_data(self):
172        """Return the column corresponding to `attr_size`"""
173        return self.get_column(self.attr_size)
174
175    def impute_sizes(self, size_data):
176        """
177        Default imputation for size data
178
179        Let the graph handle it, but add a warning if needed.
180
181        Args:
182            size_data (np.ndarray): scaled points sizes
183        """
184        if self.graph.default_impute_sizes(size_data):
185            self.Information.missing_size(self.attr_size)
186        else:
187            self.Information.missing_size.clear()
188
189    def sizes_changed(self):
190        self.graph.update_sizes()
191
192    # Colors
193    def get_color_data(self):
194        """Return the column corresponding to color data"""
195        return self.get_column(self.attr_color, max_categories=MAX_COLORS)
196
197    def get_color_labels(self):
198        """
199        Return labels for the color legend
200
201        Returns:
202            (list of str): labels
203        """
204        if self.attr_color is None:
205            return None
206        if not self.attr_color.is_discrete:
207            return self.attr_color.str_val
208        return self.get_column(self.attr_color, max_categories=MAX_COLORS,
209                               return_labels=True)
210
211    def is_continuous_color(self):
212        """
213        Tells whether the color is continuous
214
215        Returns:
216            (bool):
217        """
218        return self.attr_color is not None and self.attr_color.is_continuous
219
220    def get_palette(self):
221        """
222        Return a palette suitable for the current `attr_color`
223
224        This method must be overridden if the widget offers coloring that is
225        not based on attribute values.
226        """
227        attr = self.attr_color
228        if not attr:
229            return None
230        palette = attr.palette
231        if attr.is_discrete and len(attr.values) >= MAX_COLORS:
232            values = self.get_color_labels()
233            colors = [palette.palette[attr.to_val(value)]
234                      for value in values[:-1]] + [[192, 192, 192]]
235
236            palette = colorpalettes.DiscretePalette.from_colors(colors)
237        return palette
238
239    def can_draw_density(self):
240        """
241        Tells whether the current data and settings are suitable for drawing
242        densities
243
244        Returns:
245            (bool):
246        """
247        return self.data is not None and self.data.domain is not None and \
248            len(self.data) > 1 and self.attr_color is not None
249
250    def colors_changed(self):
251        self.graph.update_colors()
252        self._update_opacity_warning()
253        self.cb_class_density.setEnabled(self.can_draw_density())
254
255    # Labels
256    def get_label_data(self, formatter=None):
257        """Return the column corresponding to label data"""
258        if self.attr_label:
259            label_data = self.get_column(self.attr_label)
260            if formatter is None:
261                formatter = self.attr_label.str_val
262            return np.array([formatter(x) for x in label_data])
263        return None
264
265    def labels_changed(self):
266        self.graph.update_labels()
267
268    # Shapes
269    def get_shape_data(self):
270        """
271        Return labels for the shape legend
272
273        Returns:
274            (list of str): labels
275        """
276        return self.get_column(self.attr_shape, max_categories=MAX_SHAPES)
277
278    def get_shape_labels(self):
279        return self.get_column(self.attr_shape, max_categories=MAX_SHAPES,
280                               return_labels=True)
281
282    def impute_shapes(self, shape_data, default_symbol):
283        """
284        Default imputation for shape data
285
286        Let the graph handle it, but add a warning if needed.
287
288        Args:
289            shape_data (np.ndarray): scaled points sizes
290            default_symbol (str): a string representing the symbol
291        """
292        if self.graph.default_impute_shapes(shape_data, default_symbol):
293            self.Information.missing_shape(self.attr_shape)
294        else:
295            self.Information.missing_shape.clear()
296
297    def shapes_changed(self):
298        self.graph.update_shapes()
299
300    # Tooltip
301    def _point_tooltip(self, point_id, skip_attrs=()):
302        def show_part(_point_data, singular, plural, max_shown, _vars):
303            cols = [escape('{} = {}'.format(var.name, _point_data[var]))
304                    for var in _vars[:max_shown + 2]
305                    if _vars == domain.class_vars
306                    or var not in skip_attrs][:max_shown]
307            if not cols:
308                return ""
309            n_vars = len(_vars)
310            if n_vars > max_shown:
311                cols[-1] = "... and {} others".format(n_vars - max_shown + 1)
312            return \
313                "<b>{}</b>:<br/>".format(singular if n_vars < 2 else plural) \
314                + "<br/>".join(cols)
315
316        domain = self.data.domain
317        parts = (("Class", "Classes", 4, domain.class_vars),
318                 ("Meta", "Metas", 4, domain.metas),
319                 ("Feature", "Features", 10, domain.attributes))
320
321        point_data = self.data[point_id]
322        return "<br/>".join(show_part(point_data, *columns)
323                            for columns in parts)
324
325    def get_tooltip(self, point_ids):
326        """
327        Return the tooltip string for the given points
328
329        The method is called by the plot on mouse hover
330
331        Args:
332            point_ids (list): indices into `data`
333
334        Returns:
335            (str):
336        """
337        point_ids = \
338            np.flatnonzero(self.valid_data)[np.asarray(point_ids, dtype=int)]
339        text = "<hr/>".join(self._point_tooltip(point_id)
340                            for point_id in point_ids[:MAX_POINTS_IN_TOOLTIP])
341        if len(point_ids) > MAX_POINTS_IN_TOOLTIP:
342            text = "{} instances<hr/>{}<hr/>...".format(len(point_ids), text)
343        return text
344
345    def keyPressEvent(self, event):
346        """Update the tip about using the modifier keys when selecting"""
347        super().keyPressEvent(event)
348        self.graph.update_tooltip(event.modifiers())
349
350    def keyReleaseEvent(self, event):
351        """Update the tip about using the modifier keys when selecting"""
352        super().keyReleaseEvent(event)
353        self.graph.update_tooltip(event.modifiers())
354
355
356class OWDataProjectionWidget(OWProjectionWidgetBase, openclass=True):
357    """
358    Base widget for widgets that get Data and Data Subset (both
359    Orange.data.Table) on the input, and output Selected Data and Data
360    (both Orange.data.Table).
361
362    Beside that the widget displays data as two-dimensional projection
363    of points.
364    """
365    class Inputs:
366        data = Input("Data", Table, default=True)
367        data_subset = Input("Data Subset", Table)
368
369    class Outputs:
370        selected_data = Output("Selected Data", Table, default=True)
371        annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table)
372
373    class Warning(OWProjectionWidgetBase.Warning):
374        too_many_labels = Msg(
375            "Too many labels to show (zoom in or label only selected)")
376        subset_not_subset = Msg(
377            "Subset data contains some instances that do not appear in "
378            "input data")
379        subset_independent = Msg(
380            "No subset data instances appear in input data")
381        transparent_subset = Msg(
382            "Increase opacity if subset is difficult to see")
383
384    settingsHandler = DomainContextHandler()
385    selection = Setting(None, schema_only=True)
386    visual_settings = Setting({}, schema_only=True)
387    auto_commit = Setting(True)
388
389    GRAPH_CLASS = OWScatterPlotBase
390    graph = SettingProvider(OWScatterPlotBase)
391    graph_name = "graph.plot_widget.plotItem"
392    embedding_variables_names = ("proj-x", "proj-y")
393    buttons_area_orientation = Qt.Vertical
394
395    input_changed = Signal(object)
396    output_changed = Signal(object)
397
398    def __init__(self):
399        super().__init__()
400        self.subset_data = None
401        self.subset_indices = None
402        self.__pending_selection = self.selection
403        self._invalidated = True
404        self._domain_invalidated = True
405        self.setup_gui()
406        VisualSettingsDialog(self, self.graph.parameter_setter.initial_settings)
407
408    # GUI
409    def setup_gui(self):
410        self._add_graph()
411        self._add_controls()
412        self._add_buttons()
413        self.input_changed.emit(None)
414        self.output_changed.emit(None)
415
416    def _add_graph(self):
417        box = gui.vBox(self.mainArea, True, margin=0)
418        self.graph = self.GRAPH_CLASS(self, box)
419        box.layout().addWidget(self.graph.plot_widget)
420        self.graph.too_many_labels.connect(
421            lambda too_many: self.Warning.too_many_labels(shown=too_many))
422
423    def _add_controls(self):
424        self.gui = OWPlotGUI(self)
425        area = self.controlArea
426        self._point_box = self.gui.point_properties_box(area)
427        self._effects_box = self.gui.effects_box(area)
428        self._plot_box = self.gui.plot_properties_box(area)
429
430    def _add_buttons(self):
431        gui.rubber(self.controlArea)
432        self.gui.box_zoom_select(self.buttonsArea)
433        gui.auto_send(self.buttonsArea, self, "auto_commit")
434
435    @property
436    def effective_variables(self):
437        return self.data.domain.attributes
438
439    @property
440    def effective_data(self):
441        return self.data.transform(Domain(self.effective_variables,
442                                          self.data.domain.class_vars,
443                                          self.data.domain.metas))
444
445    # Input
446    @Inputs.data
447    @check_sql_input
448    def set_data(self, data):
449        data_existed = self.data is not None
450        effective_data = self.effective_data if data_existed else None
451        self.closeContext()
452        self.data = data
453        self.check_data()
454        self.init_attr_values()
455        self.openContext(self.data)
456        self._invalidated = not (
457            data_existed and self.data is not None and
458            array_equal(effective_data.X, self.effective_data.X))
459        self._domain_invalidated = not (
460            data_existed and self.data is not None and
461            effective_data.domain.checksum()
462            == self.effective_data.domain.checksum())
463        if self._invalidated:
464            self.clear()
465            self.input_changed.emit(data)
466        self.enable_controls()
467
468    def check_data(self):
469        self.clear_messages()
470
471    def enable_controls(self):
472        self.cb_class_density.setEnabled(self.can_draw_density())
473
474    @Inputs.data_subset
475    @check_sql_input
476    def set_subset_data(self, subset):
477        self.subset_data = subset
478
479    def handleNewSignals(self):
480        self._handle_subset_data()
481        if self._invalidated:
482            self._invalidated = False
483            self.setup_plot()
484        else:
485            self.graph.update_point_props()
486        self._update_opacity_warning()
487        self.unconditional_commit()
488
489    def _handle_subset_data(self):
490        self.Warning.subset_independent.clear()
491        self.Warning.subset_not_subset.clear()
492        if self.data is None or self.subset_data is None:
493            self.subset_indices = set()
494        else:
495            self.subset_indices = set(self.subset_data.ids)
496            ids = set(self.data.ids)
497            if not self.subset_indices & ids:
498                self.Warning.subset_independent()
499            elif self.subset_indices - ids:
500                self.Warning.subset_not_subset()
501
502    def _update_opacity_warning(self):
503        self.Warning.transparent_subset(
504            shown=self.subset_indices and self.graph.alpha_value < 128)
505
506    def get_subset_mask(self):
507        if not self.subset_indices:
508            return None
509        valid_data = self.data[self.valid_data]
510        return np.fromiter((ex.id in self.subset_indices for ex in valid_data),
511                           dtype=bool, count=len(valid_data))
512
513    # Plot
514    def get_embedding(self):
515        """A get embedding method.
516
517        Derived classes must override this method. The overridden method
518        should return embedding for all data (valid and invalid). Invalid
519        data embedding coordinates should be set to 0 (in some cases to Nan).
520
521        The method should also set self.valid_data.
522
523        Returns:
524            np.array: Array of embedding coordinates with shape
525            len(self.data) x 2
526        """
527        raise NotImplementedError
528
529    def get_coordinates_data(self):
530        embedding = self.get_embedding()
531        if embedding is not None and len(embedding[self.valid_data]):
532            return embedding[self.valid_data].T
533        return None, None
534
535    def setup_plot(self):
536        self.graph.reset_graph()
537        self.__pending_selection = self.selection or self.__pending_selection
538        self.apply_selection()
539
540    # Selection
541    def apply_selection(self):
542        pending = self.__pending_selection
543        if self.data is not None and pending is not None and len(pending) \
544                and max(i for i, _ in pending) < self.graph.n_valid:
545            index_group = np.array(pending).T
546            selection = np.zeros(self.graph.n_valid, dtype=np.uint8)
547            selection[index_group[0]] = index_group[1]
548
549            self.selection = self.__pending_selection
550            self.__pending_selection = None
551            self.graph.selection = selection
552            self.graph.update_selection_colors()
553            if self.graph.label_only_selected:
554                self.graph.update_labels()
555
556    def selection_changed(self):
557        sel = None if self.data and isinstance(self.data, SqlTable) \
558            else self.graph.selection
559        self.selection = [(i, x) for i, x in enumerate(sel) if x] \
560            if sel is not None else None
561        self.commit()
562
563    # Output
564    def commit(self):
565        self.send_data()
566
567    def send_data(self):
568        group_sel, data, graph = None, self._get_projection_data(), self.graph
569        if graph.selection is not None:
570            group_sel = np.zeros(len(data), dtype=int)
571            group_sel[self.valid_data] = graph.selection
572        selected = self._get_selected_data(
573            data, graph.get_selection(), group_sel)
574        self.output_changed.emit(selected)
575        self.Outputs.selected_data.send(selected)
576        self.Outputs.annotated_data.send(
577            self._get_annotated_data(data, group_sel,
578                                     graph.selection))
579
580    def _get_projection_data(self):
581        if self.data is None or self.embedding_variables_names is None:
582            return self.data
583        variables = self._get_projection_variables()
584        data = self.data.transform(Domain(self.data.domain.attributes,
585                                          self.data.domain.class_vars,
586                                          self.data.domain.metas + variables))
587        data.metas[:, -2:] = self.get_embedding()
588        return data
589
590    def _get_projection_variables(self):
591        names = get_unique_names(
592            self.data.domain, self.embedding_variables_names)
593        return ContinuousVariable(names[0]), ContinuousVariable(names[1])
594
595    @staticmethod
596    def _get_selected_data(data, selection, group_sel):
597        return create_groups_table(data, group_sel, False, "Group") \
598            if len(selection) else None
599
600    @staticmethod
601    def _get_annotated_data(data, group_sel, graph_sel):
602        if data is None:
603            return None
604        if graph_sel is not None and np.max(graph_sel) > 1:
605            return create_groups_table(data, group_sel)
606        else:
607            if group_sel is None:
608                mask = np.full((len(data), ), False)
609            else:
610                mask = np.nonzero(group_sel)[0]
611            return create_annotated_table(data, mask)
612
613    # Report
614    def send_report(self):
615        if self.data is None:
616            return
617
618        caption = self._get_send_report_caption()
619        self.report_plot()
620        if caption:
621            self.report_caption(caption)
622
623    def _get_send_report_caption(self):
624        return report.render_items_vert((
625            ("Color", self._get_caption_var_name(self.attr_color)),
626            ("Label", self._get_caption_var_name(self.attr_label)),
627            ("Shape", self._get_caption_var_name(self.attr_shape)),
628            ("Size", self._get_caption_var_name(self.attr_size)),
629            ("Jittering", self.graph.jitter_size != 0 and
630             "{} %".format(self.graph.jitter_size))))
631
632    # Customize plot
633    def set_visual_settings(self, key, value):
634        self.graph.parameter_setter.set_parameter(key, value)
635        self.visual_settings[key] = value
636
637    @staticmethod
638    def _get_caption_var_name(var):
639        return var.name if isinstance(var, Variable) else var
640
641    # Misc
642    def sizeHint(self):
643        return QSize(1132, 708)
644
645    def clear(self):
646        self.selection = None
647        self.graph.selection = None
648
649    def onDeleteWidget(self):
650        super().onDeleteWidget()
651        self.graph.plot_widget.getViewBox().deleteLater()
652        self.graph.plot_widget.clear()
653        self.graph.clear()
654
655
656class OWAnchorProjectionWidget(OWDataProjectionWidget, openclass=True):
657    """ Base widget for widgets with graphs with anchors. """
658    SAMPLE_SIZE = 100
659
660    GRAPH_CLASS = OWGraphWithAnchors
661    graph = SettingProvider(OWGraphWithAnchors)
662
663    class Outputs(OWDataProjectionWidget.Outputs):
664        components = Output("Components", Table)
665
666    class Error(OWDataProjectionWidget.Error):
667        sparse_data = Msg("Sparse data is not supported")
668        no_valid_data = Msg("No projection due to no valid data")
669        no_instances = Msg("At least two data instances are required")
670        proj_error = Msg("An error occurred while projecting data.\n{}")
671
672    def __init__(self):
673        self.projector = self.projection = None
674        super().__init__()
675        self.graph.view_box.started.connect(self._manual_move_start)
676        self.graph.view_box.moved.connect(self._manual_move)
677        self.graph.view_box.finished.connect(self._manual_move_finish)
678
679    def check_data(self):
680        def error(err):
681            err()
682            self.data = None
683
684        super().check_data()
685        if self.data is not None:
686            if self.data.is_sparse():
687                error(self.Error.sparse_data)
688            elif len(self.data) < 2:
689                error(self.Error.no_instances)
690            else:
691                if not np.sum(np.all(np.isfinite(self.data.X), axis=1)):
692                    error(self.Error.no_valid_data)
693
694    def init_projection(self):
695        self.projection = None
696        if not self.effective_variables:
697            return
698        try:
699            self.projection = self.projector(self.effective_data)
700        except Exception as ex:  # pylint: disable=broad-except
701            self.Error.proj_error(ex)
702
703    def get_embedding(self):
704        self.valid_data = None
705        if self.data is None or self.projection is None:
706            return None
707        embedding = self.projection(self.data).X
708        self.valid_data = np.all(np.isfinite(embedding), axis=1)
709        return embedding
710
711    def get_anchors(self):
712        if self.projection is None:
713            return None, None
714        components = self.projection.components_
715        if components.shape == (1, 1):
716            components = np.array([[1.], [0.]])
717        return components.T, [a.name for a in self.effective_variables]
718
719    def _manual_move_start(self):
720        self.graph.set_sample_size(self.SAMPLE_SIZE)
721
722    def _manual_move(self, anchor_idx, x, y):
723        self.projection.components_[:, anchor_idx] = [x, y]
724        self.graph.update_coordinates()
725
726    def _manual_move_finish(self, anchor_idx, x, y):
727        self._manual_move(anchor_idx, x, y)
728        self.graph.set_sample_size(None)
729        self.commit()
730
731    def _get_projection_data(self):
732        if self.data is None or self.projection is None:
733            return None
734        proposed = [a.name for a in self.projection.domain.attributes]
735        names = get_unique_names(self.data.domain, proposed)
736
737        if proposed != names:
738            attributes = tuple([attr.copy(name=name) for name, attr in
739                                zip(names, self.projection.domain.attributes)])
740        else:
741            attributes = self.projection.domain.attributes
742        return self.data.transform(
743            Domain(self.data.domain.attributes,
744                   self.data.domain.class_vars,
745                   self.data.domain.metas + attributes))
746
747    def commit(self):
748        super().commit()
749        self.send_components()
750
751    def send_components(self):
752        components = None
753        if self.data is not None and self.projection is not None:
754            proposed = [var.name for var in self.effective_variables]
755            comp_name = get_unique_names(proposed, 'component')
756            meta_attrs = [StringVariable(name=comp_name)]
757            domain = Domain(self.effective_variables, metas=meta_attrs)
758            components = Table(domain, self._send_components_x(),
759                               metas=self._send_components_metas())
760            components.name = "components"
761        self.Outputs.components.send(components)
762
763    def _send_components_x(self):
764        return self.projection.components_
765
766    def _send_components_metas(self):
767        variable_names = [a.name for a in self.projection.domain.attributes]
768        return np.array(variable_names, dtype=object)[:, None]
769
770    def clear(self):
771        super().clear()
772        self.projector = self.projection = None
773
774
775if __name__ == "__main__":
776    class OWProjectionWidgetWithName(OWDataProjectionWidget):
777        name = "projection"
778
779        def get_embedding(self):
780            if self.data is None:
781                return None
782            self.valid_data = np.any(np.isfinite(self.data.X), 1)
783            x_data = self.data.X
784            x_data[x_data == np.inf] = np.nan
785            x_data = np.nanmean(x_data[self.valid_data], 1)
786            y_data = np.ones(len(x_data))
787            return np.vstack((x_data, y_data)).T
788
789    app = QApplication([])
790    ow = OWProjectionWidgetWithName()
791    table = Table("iris")
792    ow.set_data(table)
793    ow.set_subset_data(table[::10])
794    ow.handleNewSignals()
795    ow.show()
796    app.exec()
797    ow.saveSettings()
798