From eb3ad0e89cd68d0947ee3dfd78a2b358f65f76b7 Mon Sep 17 00:00:00 2001 From: janezd Date: Sat, 10 Jun 2023 18:02:27 +0200 Subject: [PATCH] Network Explorer: Edges with colors and labels --- orangecontrib/network/widgets/OWNxExplorer.py | 182 ++++++++++++++---- orangecontrib/network/widgets/graphview.py | 79 +++++--- .../widgets/tests/test_OWNxExplorer.py | 6 +- 3 files changed, 203 insertions(+), 64 deletions(-) diff --git a/orangecontrib/network/widgets/OWNxExplorer.py b/orangecontrib/network/widgets/OWNxExplorer.py index a6d5d8f..c304867 100644 --- a/orangecontrib/network/widgets/OWNxExplorer.py +++ b/orangecontrib/network/widgets/OWNxExplorer.py @@ -1,21 +1,27 @@ +from typing import Optional, Union + import numpy as np import scipy.sparse as sp from AnyQt.QtCore import QTimer, QSize, Qt, Signal, QObject, QThread import Orange -from Orange.data import Table, Domain, StringVariable +from Orange.data import Table, Domain, StringVariable, ContinuousVariable, \ + Variable from Orange.widgets import gui, widget from Orange.widgets.settings import Setting, SettingProvider +from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.plot import OWPlotGUI from Orange.widgets.visualize.utils.widget import OWDataProjectionWidget from Orange.widgets.widget import Input, Output +from orangecontrib.network.network import compose from orangecontrib.network.network.base import Network from orangecontrib.network.network.layout import fruchterman_reingold from orangecontrib.network.widgets.graphview import GraphView FR_ALLOWED_TIME = 30 +WEIGHTS_COMBO_ITEM = "Weights" class OWNxExplorer(OWDataProjectionWidget): @@ -50,6 +56,12 @@ class Outputs(OWDataProjectionWidget.Outputs): mark_min_conn = Setting(5) mark_max_conn = Setting(5) mark_most_conn = Setting(1) + # These can't be context settings. Contexts are inherited from parent class + # and use variables describing projected points (= graph nodes). Edges would + # need a separate context, so let us use hints instead. + edge_width_variable_hint: Optional[str] = Setting(None, schema_only=True) + edge_label_variable_hint: Optional[str] = Setting(None, schema_only=True) + edge_color_variable_hint: Optional[str] = Setting(None, schema_only=True) alpha_value = 255 # Override the setting from parent @@ -77,12 +89,17 @@ def __init__(self): self.mark_mode = 0 self.mark_text = "" + self.edge_width_variable = None + self.edge_label_variable = None + self.edge_color_variable = None + super().__init__() self.network = None self.node_data = None self.distance_matrix = None self.edges = None + self.edge_data = None self.positions = None self._optimizer = None @@ -101,12 +118,24 @@ def sizeHint(self): def _add_controls(self): self.gui = OWPlotGUI(self) self._add_info_box() - self.gui.point_properties_box(self.controlArea) - self._add_effects_box() - self.gui.plot_properties_box(self.controlArea) + self._add_node_box() + self._add_edge_box() + self._add_properties_box() self._add_mark_box() self.controls.attr_label.activated.connect(self.on_change_label_attr) + def _add_node_box(self): + sgui = self.gui + box = sgui.create_gridbox(self.controlArea, "Nodes") + sgui.add_widgets([ + sgui.Color, + sgui.Shape, + sgui.Label, + sgui.Size, + sgui.PointSize, + ], box) + box.layout().itemAtPosition(5, 0).widget().setText("") + def _add_info_box(self): info = gui.vBox(self.controlArea, box="Layout") gui.label( @@ -136,36 +165,49 @@ def _add_info_box(self): label="Make edges with large weights shorter", callback=self.improve) - def _add_effects_box(self): - gbox = self.gui.create_gridbox(self.controlArea, box="Widths and Sizes") - self.gui.add_widget(self.gui.PointSize, gbox) - gbox.layout().itemAtPosition(1, 0).widget().setText("Node Size:") + def _add_edge_box(self): + gbox = self.gui.create_gridbox(self.controlArea, box="Edges") + + order = (None, WEIGHTS_COMBO_ITEM, DomainModel.Separator) + DomainModel.SEPARATED + self.edge_label_model = DomainModel( + placeholder="(None)", order=order, separators=True) + self.gui._combo( + gbox, "edge_label_variable", "Label", self.edge_label_var_changed, + model=self.edge_label_model) + self.edge_color_model = DomainModel( + placeholder="(Same color)", + valid_types=DomainModel.PRIMITIVE) + self.gui._combo( + gbox, "edge_color_variable", "Color", self.edge_color_var_changed, + model=self.edge_color_model) + self.edge_width_model = DomainModel( + valid_types=ContinuousVariable, + placeholder="(Same width)", order=order, separators=False) self.gui.add_control( - gbox, gui.hSlider, "Edge width:", + gbox, + gui.comboBox, "Width:", + master=self, value="edge_width_variable", + model=self.edge_width_model, + callback=self.edge_width_var_changed, + ) + self.gui.add_control( + gbox, gui.hSlider, "", master=self, value='graph.edge_width', minValue=1, maxValue=10, step=1, - callback=self.graph.update_edges) - box = gui.vBox(None) - gbox.layout().addWidget(box, 3, 0, 1, 2) - gui.separator(box) - self.checkbox_relative_edges = gui.checkBox( - box, self, 'graph.relative_edge_widths', - 'Scale edge widths to weights', - callback=self.graph.update_edges) - self.checkbox_show_weights = gui.checkBox( - box, self, 'graph.show_edge_weights', - 'Show edge weights', - callback=self.graph.update_edge_labels) - self.checkbox_show_weights = gui.checkBox( - box, self, 'graph.label_selected_edges', - 'Label only edges of selected nodes', - callback=self.graph.update_edge_labels) + callback=self.graph.update_edge_widths) # This is ugly: create a slider that controls alpha_value so that # parent can enable and disable it - although it's never added to any # layout and visible to the user gui.hSlider(None, self, "graph.alpha_value") + def _add_properties_box(self): + sgui = self.gui + return sgui.create_box([ + sgui.LabelOnlySelected, + sgui.ClassDensity, + sgui.ShowLegend], self.controlArea, None, False) + def _add_mark_box(self): hbox = gui.hBox(None, box=True) self.mainArea.layout().addWidget(hbox) @@ -376,6 +418,22 @@ def on_change_label_attr(self): if self.mark_mode in (1, 2): self.update_marks() + @staticmethod + def _hint(var: Union[Variable, str, None]) -> Union[str, None]: + return var.name if isinstance(var, Variable) else var + + def edge_color_var_changed(self): + self.edge_color_variable_hint = self._hint(self.edge_color_variable) + self.graph.update_edge_colors() + + def edge_label_var_changed(self): + self.edge_label_variable_hint = self._hint(self.edge_label_variable) + self.graph.update_edge_labels() + + def edge_width_var_changed(self): + self.edge_width_variable_hint = self._hint(self.edge_width_variable) + self.graph.update_edge_widths() + @Inputs.node_data def set_node_data(self, data): self.node_data = data @@ -469,22 +527,32 @@ def set_actual_data(): self.cb_class_density.setEnabled(self.can_draw_density()) def set_actual_edges(): - def set_checkboxes(value): - self.checkbox_show_weights.setEnabled(value) - self.checkbox_relative_edges.setEnabled(value) - self.Warning.distance_matrix_mismatch.clear() if self.network is None: self.edges = None - set_checkboxes(False) + self.edge_data = None return - set_checkboxes(True) if network.number_of_edges(0): - self.edges = network.edges[0].edges.tocoo() + edges = network.edges[0] + self.edges = edges.edges.tocoo() + self.edge_data = edge_data = edges.edge_data + if isinstance(edge_data, np.ndarray): + if edge_data.dtype == float: + self.edge_data = Table.from_numpy( + Domain([ContinuousVariable("label")]), + np.atleast_2d(edge_data)) + else: + self.edge_data = Table.from_numpy( + Domain([], metas=[StringVariable("label")]), + np.empty((len(edge_data), 0)), + metas=edge_data.reshape(len(edge_data), 1)) + elif edge_data is not None: + assert isinstance(edges.edge_data, Table) else: self.edges = sp.coo_matrix((0, 3)) + self.edge_data = None if self.distance_matrix is not None: if len(self.distance_matrix) != self.number_of_nodes: self.Warning.distance_matrix_mismatch() @@ -496,9 +564,22 @@ def set_checkboxes(value): ) if np.allclose(self.edges.data, 0): self.edges.data[:] = 1 - set_checkboxes(False) - elif len(set(self.edges.data)) == 1: - set_checkboxes(False) + + def _retrieve(model, hint): + model.set_domain(domain) + for var in model: + if (isinstance(var, Variable) and var.name == hint + or isinstance(var, str) and var == hint): + return var + return None + + domain = None if self.edge_data is None else self.edge_data.domain + self.edge_label_variable = \ + _retrieve(self.edge_label_model, self.edge_label_variable_hint) + self.edge_color_variable = \ + _retrieve(self.edge_color_model, self.edge_color_variable_hint) + self.edge_width_variable = \ + _retrieve(self.edge_width_model, self.edge_width_variable_hint) self.stop_optimization_and_wait() set_actual_data() @@ -581,6 +662,34 @@ def get_subset_mask(self): def get_edges(self): return self.edges + def get_edge_labels(self): + if self.edge_label_variable is None: + return None + if self.edge_label_variable == WEIGHTS_COMBO_ITEM: + weights = self.edges.data + if np.allclose(np.modf(weights)[0], 0): + return np.array([str(x) for x in weights.astype(int)]) + else: + return np.array(["{:.02}".format(x) for x in weights]) + elw = self.edge_label_variable + tostr = elw.repr_val + return np.array([tostr(x) for x in self.edge_data.get_column(elw)]) + + def get_edge_widths(self): + if self.edge_width_variable is None: + return None + if self.edge_width_variable == WEIGHTS_COMBO_ITEM: + widths = self.edges.data + return widths if len(set(widths)) > 1 else None + else: + return self.edge_data.get_column(self.edge_width_variable) + + def get_edge_colors(self): + var = self.edge_color_variable + if var is None: + return None + return var.palette.values_to_qcolors(self.edge_data.get_column(var)) + def is_directed(self): return self.network is not None and self.network.edges[0].directed @@ -719,7 +828,10 @@ def main(): network = read_pajek(join(dirname(dirname(__file__)), 'networks', 'leu_by_genesets.net')) #network = read_pajek(join(dirname(dirname(__file__)), 'networks', 'davis.net')) #transform_data_to_orange_table(network) + data = Table("/Users/janez/Downloads/relations.tab") + network = compose.network_from_edge_table(data, *data.domain.metas[:2]) WidgetPreview(OWNxExplorer).run(set_graph=network) + if __name__ == "__main__": main() diff --git a/orangecontrib/network/widgets/graphview.py b/orangecontrib/network/widgets/graphview.py index 236aaff..2f3b5e5 100644 --- a/orangecontrib/network/widgets/graphview.py +++ b/orangecontrib/network/widgets/graphview.py @@ -6,7 +6,7 @@ from AnyQt.QtCore import QLineF, Qt, QRectF from AnyQt.QtGui import QPen -from Orange.util import scale +from Orange.data.util import scale from Orange.widgets.settings import Setting from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase @@ -15,6 +15,7 @@ class PlotVarWidthCurveItem(pg.PlotCurveItem): def __init__(self, directed, *args, **kwargs): self.directed = directed self.widths = kwargs.pop("widths", None) + self.colors = kwargs.pop("colors", None) self.setPen(kwargs.pop("pen", pg.mkPen(0.0))) self.sizes = kwargs.pop("size", None) self.coss = self.sins = None @@ -24,12 +25,17 @@ def setWidths(self, widths): self.widths = widths self.update() + def setEdgeColors(self, colors): + self.colors = colors + self.update() + def setPen(self, pen): self.pen = pen self.pen.setCapStyle(Qt.RoundCap) def setData(self, *args, **kwargs): self.widths = kwargs.pop("widths", self.widths) + self.colors = kwargs.pop("colors", self.colors) self.setPen(kwargs.pop("pen", self.pen)) self.sizes = kwargs.pop("size", self.sizes) super().setData(*args, **kwargs) @@ -105,7 +111,7 @@ def get_short_edge_coords(): pen = QPen(self.pen) p.setRenderHint(p.Antialiasing, True) p.setCompositionMode(p.CompositionMode_SourceOver) - if self.widths is None: + if self.widths is None and self.colors is None: p.setPen(pen) if self.directed: for (x0, y0, x1, y1), (x1w, y1w), (xa1, ya1, xa2, ya2), arc in zip( @@ -118,19 +124,31 @@ def get_short_edge_coords(): for ecoords in edge_coords[~arcs]: p.drawLine(QLineF(*ecoords)) else: + if self.widths is None: + widths = np.lib.stride_tricks.as_strided( + pen.width(), (len(edge_coords),), (0,)) + else: + widths = self.widths + if self.colors is None: + colors = np.lib.stride_tricks.as_strided( + pen.color(), (len(edge_coords),), (0,)) + else: + colors = self.colors if self.directed: - for (x0, y0, x1, y1), (x1w, y1w), (xa1, ya1, xa2, ya2), w, arc in zip( + for (x0, y0, x1, y1), (x1w, y1w), (xa1, ya1, xa2, ya2), w, c, arc in zip( edge_coords, get_short_edge_coords(), get_arrows(), - self.widths, arcs): + widths, colors, arcs): if not arc: pen.setWidth(w) + pen.setColor(c) p.setPen(pen) p.drawLine(QLineF(x0, y0, x1w, y1w)) p.drawLine(QLineF(xa1, ya1, x1, y1)) p.drawLine(QLineF(xa2, ya2, x1, y1)) else: - for ecoords, w in zip(edge_coords[~arcs], self.widths[~arcs]): + for ecoords, w, c in zip(edge_coords[~arcs], widths[~arcs], colors[~arcs]): pen.setWidth(w) + pen.setColor(c) p.setPen(pen) p.drawLine(QLineF(*ecoords)) @@ -155,11 +173,14 @@ def get_short_edge_coords(): if self.widths is None: widths = np.full(len(rxs), pen.width()) + colors = np.full(len(rxs), pen.color()) else: widths = self.widths[arcs] - for rx, ry, rfx, rfy, w in zip(rxs, rys, rfxs, rfys, widths): + colors = self.colors[arcs] + for rx, ry, rfx, rfy, w, c in zip(rxs, rys, rfxs, rfys, widths, colors): rect = QRectF(rx, ry, rfx, rfy) pen.setWidth(w) + pen.setColor(c) p.setPen(pen) p.drawArc(rect, 100 * 16, 250 * 16) if self.directed: @@ -170,10 +191,7 @@ def get_short_edge_coords(): class GraphView(OWScatterPlotBase): - show_edge_weights = Setting(False) - relative_edge_widths = Setting(True) edge_width = Setting(2) - label_selected_edges = Setting(True) COLOR_NOT_SUBSET = (255, 255, 255, 255) COLOR_SUBSET = (0, 0, 0, 255) @@ -228,7 +246,14 @@ def update_edges(self): return x, y = self.scatterplot_item.getData() edges = self.master.get_edges() - srcs, dests, weights = edges.row, edges.col, edges.data + colors = self.master.get_edge_colors() + widths = self.master.get_edge_widths() + if widths is not None: + widths = scale(widths, .7, 8) + widths[np.isnan(widths)] = 0.35 + widths *= np.log2(self.edge_width / 4 + 1) + + srcs, dests = edges.row, edges.col if self.edge_curve is None: self.pair_indices = np.empty((2 * len(srcs),), dtype=int) self.pair_indices[::2] = srcs @@ -236,12 +261,8 @@ def update_edges(self): data = dict(x=x[self.pair_indices], y=y[self.pair_indices], pen=self._edge_curve_pen(), antialias=True, - size=self.scatterplot_item.data["size"][self.pair_indices] / 2) - if self.relative_edge_widths and len(set(weights)) > 1: - data['widths'] = \ - scale(weights, .7, 8) * np.log2(self.edge_width / 4 + 1) - else: - data['widths'] = None + size=self.scatterplot_item.data["size"][self.pair_indices] / 2, + widths=widths, colors=colors) if self.edge_curve is None: self.edge_curve = PlotVarWidthCurveItem( @@ -267,14 +288,14 @@ def update_edge_labels(self): self.plot_widget.removeItem(label) self.edge_labels = [] if self.scatterplot_item is None \ - or not self.show_edge_weights \ or self.simplify & self.Simplifications.NoEdgeLabels: return edges = self.master.get_edges() - if edges is None: + labels = self.master.get_edge_labels() + if edges is None or labels is None: return - srcs, dests, weights = edges.row, edges.col, edges.data - if self.label_selected_edges: + srcs, dests = edges.row, edges.col + if self.label_only_selected: selected = self._selected_and_marked() num_selected = np.sum(selected) if num_selected >= 2: @@ -283,11 +304,7 @@ def update_edge_labels(self): selected_edges = selected[srcs] | selected[dests] srcs = srcs[selected_edges] dests = dests[selected_edges] - weights = weights[selected_edges] - if np.allclose(weights, np.round(weights)): - labels = [str(x) for x in weights.astype(int)] - else: - labels = ["{:.02}".format(x) for x in weights] + labels = labels[selected_edges] x, y = self.scatterplot_item.getData() xs = (x[srcs.astype(np.int64)] + x[dests.astype(np.int64)]) / 2 ys = (y[srcs.astype(np.int64)] + y[dests.astype(np.int64)]) / 2 @@ -298,6 +315,13 @@ def update_edge_labels(self): self.plot_widget.addItem(ti) self.edge_labels.append(ti) + def update_edge_colors(self): + self.update_edges() + + def update_edge_widths(self): + self.update_edges() + + def _remove_edges(self): if self.edge_curve: self.plot_widget.removeItem(self.edge_curve) @@ -342,6 +366,7 @@ def update_labels(self): if marked is not None and len(marked): self.selection = self._selected_and_marked() super().update_labels() + self.update_edge_labels() self.selection = saved_selection def _remove_labels(self): @@ -379,10 +404,10 @@ def select_by_click(self, _, points): def unselect_all(self): super().unselect_all() - if self.label_selected_edges: + if self.label_only_selected: self.update_edge_labels() def _update_after_selection(self): - if self.label_selected_edges: + if self.label_only_selected: self.update_edge_labels() super()._update_after_selection() diff --git a/orangecontrib/network/widgets/tests/test_OWNxExplorer.py b/orangecontrib/network/widgets/tests/test_OWNxExplorer.py index b2cd7db..16f704a 100644 --- a/orangecontrib/network/widgets/tests/test_OWNxExplorer.py +++ b/orangecontrib/network/widgets/tests/test_OWNxExplorer.py @@ -7,7 +7,8 @@ from orangewidget.tests.utils import simulate from orangecontrib.network import Network -from orangecontrib.network.widgets.OWNxExplorer import OWNxExplorer +from orangecontrib.network.widgets.OWNxExplorer import OWNxExplorer, \ + WEIGHTS_COMBO_ITEM class TestOWNxExplorer(NetworkTest): @@ -75,7 +76,8 @@ def test_get_reachable(self): def test_edge_weights(self): self.send_signal(self.widget.Inputs.network, self.davis_net) - self.widget.graph.show_edge_weights = True + self.widget.edge_label_variable = WEIGHTS_COMBO_ITEM + self.widget.graph.label_only_selected = True # Mark nodes with many connections (multiple): should show the weights for edges between marked nodes only self.widget.mark_min_conn = 8