1""" OWMarkerGenes """ 2from typing import List, Tuple, Iterable, Optional 3from functools import partial 4 5import numpy as np 6import requests 7from orangewidget.settings import Setting, ContextHandler 8 9from AnyQt import QtGui, QtCore 10from AnyQt.QtCore import Qt, QSize, QMimeData, QModelIndex, QAbstractItemModel, QSortFilterProxyModel, pyqtSignal 11from AnyQt.QtWidgets import QLayout, QWidget, QLineEdit, QTreeView, QGridLayout, QTextBrowser, QAbstractItemView 12 13from Orange.data import Table, RowInstance 14from Orange.widgets import gui, widget, settings 15 16from orangecontrib.bioinformatics.utils import serverfiles 17from orangecontrib.bioinformatics.widgets.utils.data import TAX_ID, GENE_ID_COLUMN, GENE_AS_ATTRIBUTE_NAME 18from orangecontrib.bioinformatics.widgets.utils.settings import MarkerGroupContextHandler 19 20SERVER_FILES_DOMAIN = 'marker_genes' 21GROUP_BY_ITEMS = ["Cell Type", "Function"] 22FILTER_COLUMNS_DEFAULT = ["Name", "Entrez ID"] 23NUM_LINES_TEXT = 5 24MAP_GROUP_TO_TAX_ID = {'Human': '9606', 'Mouse': '10090'} 25 26 27class TreeItem(object): 28 def __init__( 29 self, name: str, is_type_item: bool, data_row: Optional[RowInstance], parent: Optional["TreeItem"] = None 30 ): 31 self.name: str = name 32 self.parentItem: Optional["TreeItem"] = None 33 self.childItems: List = [] 34 self.change_parent(parent) 35 self.isTypeItem: bool = is_type_item 36 37 self.data_row: Optional[RowInstance] = data_row 38 # holds the information whether element is expanded in the tree 39 self.expanded = False 40 41 def change_parent(self, parent: Optional["TreeItem"] = None) -> None: 42 """ 43 Function replaces the item parent. 44 45 Parameters 46 ---------- 47 parent 48 Parent which is set as an items parent 49 """ 50 self.parentItem = parent 51 if parent is not None: 52 parent.append_child(self) 53 54 @staticmethod 55 def change_parents(items: Iterable["TreeItem"], parent: "TreeItem") -> None: 56 """ 57 This function assigns all items a parent 58 59 Parameters 60 ---------- 61 items 62 Items that will get parent assigned 63 parent 64 Parent that will be assigned 65 """ 66 for it in items: 67 it.change_parent(parent) 68 69 def append_child(self, item: "TreeItem") -> None: 70 """ 71 This function appends child to self. This function just append a child 72 and do not set parent of child item (it is set by function calling this function. 73 74 Parameters 75 ---------- 76 item 77 Child item. 78 """ 79 self.childItems.append(item) 80 81 def child(self, row: int) -> "TreeItem": 82 """ 83 Returns row-th child the item. 84 85 Parameters 86 ---------- 87 row 88 Index of the child 89 90 Returns 91 ------- 92 Child item 93 """ 94 return self.childItems[row] 95 96 def remove_children(self, position: int, count: int) -> bool: 97 """ 98 This function removes children form position to position + count 99 100 Parameters 101 ---------- 102 position 103 Position of the first removed child 104 count 105 Number of removed children 106 Returns 107 ------- 108 Success of removing 109 """ 110 # we could check for every index separately but in case when all items cannot be removed there is something 111 # wrong with the cal - just refuse to do the removal 112 if position < 0 or position + count > len(self.childItems): 113 return False 114 # remove from the back otherwise you get index out of range when removing more than one 115 for i in range(position + count - 1, position - 1, -1): 116 self.childItems.pop(i) 117 return True 118 119 def children_count(self) -> int: 120 """ 121 Function return number fo children of an item. 122 123 Returns 124 ------- 125 Number of children. 126 """ 127 return len(self.childItems) 128 129 def row(self) -> int: 130 """ 131 Function returns the index of the item in a parents list. If it does not have parent returns 0. 132 133 Returns 134 ------- 135 The index of an item. 136 """ 137 if self.parentItem: 138 return self.parentItem.childItems.index(self) 139 return 0 140 141 def child_from_name_index(self, name: str) -> Tuple[Optional[int], Optional["TreeItem"]]: 142 """ 143 This function returns the index (in the array of children) and item with the name specified. 144 145 Parameters 146 ---------- 147 name 148 The name of requested item. 149 150 Returns 151 ------- 152 The index and the searched item. If it does not exist return None. 153 """ 154 for i, c in enumerate(self.childItems): 155 if c.name == name: 156 return i, c 157 return None, None 158 159 def contains_text(self, text: str, filter_columns: List[str]) -> bool: 160 """ 161 Function indicates whether the text is present in any of columns in FILTER_COLUMNS. 162 163 Parameters 164 ---------- 165 text 166 Tested text. 167 filter_columns 168 Columns used in filtering 169 170 Returns 171 ------- 172 Boolean that indicates that text is present in the item. 173 """ 174 if self.data_row is not None: 175 return any(text.lower() in str(self.data_row[col]).lower() for col in filter_columns) 176 else: 177 return False 178 179 def get_data_rows(self) -> List[RowInstance]: 180 """ 181 This function returns a list of rows present in self and any children node. 182 183 Returns 184 ------- 185 Rows with data items. 186 """ 187 rows = [self.data_row] if self.data_row is not None else [] 188 for c in self.childItems: 189 rows += c.get_data_rows() 190 return rows 191 192 def count_marker_genes(self) -> int: 193 """ 194 This function counts number of marker genes in its tree 195 196 Returns 197 ------- 198 int 199 Number of marker genes in the tree including itself. 200 """ 201 return sum(c.count_marker_genes() for c in self.childItems) + (0 if self.data_row is None else 1) 202 203 204class TreeModel(QAbstractItemModel): 205 206 MIME_TYPE = "application/x-Orange-TreeModelData" 207 # it was slow to relay on the qt model signals since they are emitted every time endInsertRows is called 208 # this one will be emitted just a the end 209 data_added = pyqtSignal() 210 data_removed = pyqtSignal() 211 expand_item = pyqtSignal(QModelIndex, bool) 212 213 def __init__(self, data: Table, parent_column: str, parent: QModelIndex = None) -> None: 214 super().__init__(parent) 215 216 self.rootItem = TreeItem("Genes", False, None) 217 self.setup_model_data(data, self.rootItem, parent_column) 218 self.filter_columns = FILTER_COLUMNS_DEFAULT + [parent_column] 219 220 def flags(self, index: QModelIndex) -> Qt.ItemFlag: 221 if not index.isValid(): 222 return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDropEnabled 223 return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsDragEnabled 224 225 def supportedDropActions(self) -> Qt.DropAction: # noqa: N802 226 return Qt.MoveAction 227 228 def supportedDragActions(self) -> Qt.DropAction: # noqa: N802 229 return Qt.MoveAction 230 231 def columnCount(self, parent: QModelIndex = None) -> int: # noqa: N802 232 """ 233 Our TreeView has only one column 234 """ 235 return 1 236 237 def rowCount(self, parent: QModelIndex = None) -> int: # noqa: N802 238 """ 239 Function returns the number children of an item. 240 """ 241 if not parent or not parent.isValid(): 242 parent_item = self.rootItem 243 else: 244 parent_item = self.node_from_index(parent) 245 246 count = parent_item.children_count() 247 return count 248 249 def data(self, index: QModelIndex, role=None) -> Optional[str]: 250 """ 251 Function returns the index's data that are shown in the view. 252 """ 253 if not index.isValid() or role != Qt.DisplayRole: 254 return None 255 item = self.node_from_index(index) 256 rd = item.name 257 return rd 258 259 def headerData(self, section, orientation, role=None): # noqa: N802 260 """ 261 Return header data (they are no used so function is just here to make Qt happy). 262 """ 263 if orientation == Qt.Horizontal and role == Qt.DisplayRole: 264 return self.rootItem.name 265 return None 266 267 def index(self, row: int, column: int, parent: QModelIndex = None) -> QModelIndex: 268 """ 269 Function create QModelIndex from the parent item and row and column index. 270 271 Parameters 272 ---------- 273 row 274 Row of the child 275 column 276 Column of th child - not used since we have only one column. 277 parent 278 Parent of the child we are making index of. 279 280 Returns 281 ------- 282 Index of the child at row-th row of the parent. 283 """ 284 if not self.hasIndex(row, column, parent): 285 return QModelIndex() 286 287 if not parent.isValid(): 288 parent_item = self.rootItem 289 else: 290 parent_item = self.node_from_index(parent) 291 292 child_item = parent_item.child(row) 293 if child_item: 294 return self.createIndex(row, column, child_item) 295 else: 296 return QModelIndex() 297 298 def parent(self, index: QModelIndex) -> QModelIndex: 299 """ 300 Function returns parent of the item in index. 301 """ 302 if not index.isValid(): 303 return QModelIndex() 304 305 child_item = self.node_from_index(index) 306 parent_item = child_item.parentItem if child_item else None 307 308 if not parent_item or parent_item == self.rootItem: 309 return QModelIndex() 310 311 return self.createIndex(parent_item.row(), 0, parent_item) 312 313 def removeRows( # noqa: N802 314 self, position: int, rows: int, parent: QModelIndex = QModelIndex(), emit_data_removed: bool = True 315 ) -> bool: 316 """ 317 This function implements removing rows form position to position + rows form the model. It also removes 318 a parent when it becomes empty. If remove is called over the parent it remove children together with the 319 parent. 320 321 Parameters 322 ---------- 323 emit_data_removed 324 It indicates whether we emit dataRemoved signal. It makes possible that it is emitted when function called 325 by Qt and not when called by removeNodeList. When qt call this function indices are optimized to be in 326 range and this function is called just once for consecutive data. It is not true for data from 327 removeNodeList. In this case we call removeRows for each removed item and it can become slow when many 328 items are removed. 329 """ 330 node = self.node_from_index(parent) 331 if not node: 332 return False 333 if not (position == 0 and node.children_count() == rows) or not node.isTypeItem: 334 # when not all children are removed or when type item is removed - when removed node is a type item its 335 # parent is not 336 self.beginRemoveRows(QModelIndex() if node is self.rootItem else parent, position, position + rows - 1) 337 success = node.remove_children(position, rows) 338 else: 339 # when node is a children of a type item and all children of node needs to be removed, remove node itself 340 self.beginRemoveRows(QModelIndex(), node.row(), node.row()) 341 success = self.rootItem.remove_children(node.row(), 1) 342 self.endRemoveRows() 343 if emit_data_removed: 344 self.data_removed.emit() 345 return success 346 347 def mimeTypes(self) -> List[str]: # noqa: N802 348 return [self.MIME_TYPE] 349 350 def mimeData(self, index_list: List[QModelIndex]) -> QMimeData: # noqa: N802 351 """ 352 This function packs data in QMimeData in order to move them to other view with drag and drop functionality. 353 """ 354 items = [self.node_from_index(index) for index in index_list] 355 mime = QMimeData() 356 # the encoded 'data' is empty, variables are passed by properties 357 mime.setData(self.MIME_TYPE, b'') 358 mime.setProperty("_items", items) 359 mime.setProperty("_source_id", id(self)) 360 return mime 361 362 def dropMimeData( # noqa: N802 363 self, mime: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex = QModelIndex 364 ) -> bool: 365 """ 366 This function unpacks QMimeData and insert them to the view where they are dropped. 367 Inserting is a bit complicated since sometimes we need to create a parent when it is not in the view yet: 368 - when child (marker gene) is dropped we insert it under the current parent (group) if it exist othervise 369 we crate a new parent. 370 - when group item is inserted it merge it with current same parent item if exist or insert it if it does 371 not exists 372 """ 373 if action == Qt.IgnoreAction: 374 return True 375 if not mime.hasFormat(self.MIME_TYPE): 376 return False 377 items = mime.property("_items") 378 if items is None: 379 return False 380 # when parent item in items remove its child since all group will be moved 381 items = [it for it in items if it.parentItem not in items] 382 self.insert_items(items) 383 self.data_added.emit() 384 return True 385 386 def insert_items(self, items: List[TreeItem]) -> None: 387 """ 388 This function goes through all items and insert them in the view. 389 Parameters 390 ---------- 391 items 392 List of items that will be inserted. 393 """ 394 for item in items: 395 if item.isTypeItem: 396 # inserting group item 397 self.insert_group(item) 398 else: 399 # inserting children (genes) 400 self.insert_child_item(item) 401 402 def insert_group(self, item: TreeItem) -> None: 403 """ 404 This function makes insert of the item in the model in case that it is a group item. 405 406 Parameters 407 ---------- 408 item 409 Item that is inserted - group item (not gene). 410 """ 411 expand = None 412 413 item_curr_idx, curr_item = self.rootItem.child_from_name_index(item.name) 414 if item_curr_idx is None: 415 # it is not in the view yet - just insert it 416 self.beginInsertRows(QModelIndex(), self.rootItem.children_count(), self.rootItem.children_count()) 417 item.change_parent(self.rootItem) 418 expand = item.expanded 419 else: 420 # it is in the view already - insert only children 421 tr_idx = self.createIndex(item_curr_idx, 0, curr_item) 422 self.beginInsertRows(tr_idx, self.rowCount(tr_idx), self.rowCount(tr_idx) + len(item.childItems) - 1) 423 TreeItem.change_parents(item.childItems, curr_item) 424 self.endInsertRows() 425 426 # emit the signal if item must be expanded 427 if expand is not None: 428 self.expand_item.emit(self.index_from_node(item), expand) 429 430 def insert_child_item(self, item: TreeItem) -> None: 431 """ 432 This function makes insert of the item in the model in case that it is a child (gene) item. 433 434 Parameters 435 ---------- 436 item 437 Item that is inserted - child item (gene). 438 """ 439 expand_parent = None 440 parent_curr_idx, curr_parent = self.rootItem.child_from_name_index(item.parentItem.name) 441 if parent_curr_idx is None: 442 # parent (group) do not exists yet - crate it 443 self.beginInsertRows(QModelIndex(), self.rootItem.children_count(), self.rootItem.children_count()) 444 curr_parent = TreeItem(item.parentItem.name, True, None, self.rootItem) 445 # when creating new parent because child is inserted always expend since it is expanded in the other view 446 expand_parent = True 447 else: 448 # parent (group) exists - insert into it 449 tr_idx = self.createIndex(parent_curr_idx, 0, curr_parent) 450 self.beginInsertRows(tr_idx, self.rowCount(tr_idx), self.rowCount(tr_idx)) 451 item.change_parent(curr_parent) 452 self.endInsertRows() 453 454 # emit the signal if parent must be expanded 455 if expand_parent is not None: 456 self.expand_item.emit(self.index_from_node(curr_parent), expand_parent) 457 458 def canDropMimeData( # noqa: N802 459 self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex 460 ) -> bool: 461 """ 462 This function enable or disable drop action to the view. With current implementation we disable drop action to 463 the same view. 464 """ 465 if data.property("_source_id") == id(self): 466 return False 467 else: 468 return True 469 470 def remove_node_list(self, indices: List[QModelIndex]) -> None: 471 """ 472 Sometimes we need to remove items provided as a list of QModelIndex. Since it is not possible directly with 473 removeRows it is implemented with this function. 474 475 Parameters 476 ---------- 477 indices 478 List of nodes as QModelIndex 479 """ 480 for idx in indices: 481 node = self.node_from_index(idx) 482 if node in node.parentItem.childItems: # it is possible that item already removed since no children left 483 parent_idx = self.index_from_node(node.parentItem) 484 self.removeRows(node.row(), 1, parent_idx, emit_data_removed=False) 485 self.data_removed.emit() 486 487 def node_from_index(self, index: QModelIndex) -> TreeItem: # noqa: N802 488 """ 489 Function returns a TreeItem saved in the index. 490 491 Parameters 492 ---------- 493 index 494 Index of the TreeItem 495 496 Returns 497 ------- 498 Tree item from the index. 499 """ 500 if index.isValid(): 501 return index.internalPointer() 502 else: 503 return self.rootItem 504 505 def index_from_node(self, node: TreeItem) -> QModelIndex: # noqa: N802 506 """ 507 This function is similar to the index function. We use it in case when we create index but not base on the 508 parent index. We could call index but it is faster to create it directly. 509 510 Parameters 511 ---------- 512 node 513 TreeItem for which we crate index. 514 515 Returns 516 ------- 517 QModelIndex for TreeItem 518 """ 519 return self.createIndex(node.row(), 0, node) 520 521 def index_from_name(self, name: str, parent: TreeItem) -> Optional[QModelIndex]: # noqa: N802 522 """ 523 Function create index from tree item name. 524 525 Parameters 526 ---------- 527 name 528 Name of the item 529 parent 530 Parent TreeItem 531 532 Returns 533 ------- 534 QModelIndex for an item 535 """ 536 i, node = parent.child_from_name_index(name) 537 if i is None: 538 return None 539 return self.createIndex(i, 0, node) 540 541 def setup_model_data(self, data_table: Table, parent: TreeItem, parent_column: str) -> None: # noqa: N802 542 """ 543 Function populates the view with the items from the data table. Items are inserted as a tree with the depth 2 544 each row (gene from the table) get inserted under the parent item (group) which is defined with the 545 paren_column parameter (e.g. marker gene). 546 547 Parameters 548 ---------- 549 data_table 550 Data table with marker genes. 551 parent 552 Parent under which items are inserted (usually root item of the model). 553 parent_column 554 Column which is the group for items in the tree. 555 """ 556 parents_dict = {} 557 names = data_table.get_column_view("Name")[0] 558 types = data_table.get_column_view(parent_column)[0] 559 for n, pt, row in zip(names, types, data_table): 560 if pt not in parents_dict: 561 parents_dict[pt] = TreeItem(pt, True, None, parent) 562 563 TreeItem(n, False, row, parents_dict[pt]) 564 565 def set_expanded(self, index: QModelIndex, expanded: bool) -> None: 566 """ 567 Sets the expanded flag of the item. This flag tell whether the item in the view is expanded. 568 569 Parameters 570 ---------- 571 index 572 Index that is expanded/collapsed 573 expanded 574 If true expanded otherwise collapsed 575 """ 576 node = self.node_from_index(index) 577 node.expanded = expanded 578 579 def __len__(self): 580 """ 581 Len function in this case returns number of marker genes in the view. 582 583 Returns 584 ------- 585 int 586 Number of marker genes in the view. 587 """ 588 return self.rootItem.count_marker_genes() 589 590 591class FilterProxyModel(QSortFilterProxyModel): 592 """ 593 FilterProxyModel is used to sort items in alphabetical order, and to make filter on the view of available genes. 594 595 Parameters 596 ---------- 597 filter_line_edit 598 Line edit used to filter the view. 599 """ 600 601 def __init__(self, filter_line_edit: QLineEdit) -> None: 602 super().__init__() 603 self.filter_line_edit = filter_line_edit 604 if filter_line_edit is not None: 605 filter_line_edit.textChanged.connect(self.setFilterFixedString) 606 607 # have marker genes in the alphabetical order 608 self.sort(0) 609 610 def filterAcceptsRow(self, p_int: int, index: QModelIndex) -> bool: # noqa: N802 611 """ 612 Indicates whether the item should be shown based on the the filter string. 613 """ 614 if self.filter_line_edit is None: 615 return True 616 model = self.sourceModel() 617 idx = model.index(p_int, 0, index) 618 res = model.node_from_index(idx).contains_text(self.filter_line_edit.text(), self.sourceModel().filter_columns) 619 620 if model.hasChildren(idx): 621 num_items = model.rowCount(idx) 622 for i in range(num_items): 623 res = res or self.filterAcceptsRow(i, idx) 624 return res 625 626 627class TreeView(QTreeView): 628 """ 629 QTreeView with some additional settings. 630 """ 631 632 def __init__(self, *args, **kwargs) -> None: 633 super().__init__(*args, **kwargs) 634 self.setHeaderHidden(True) 635 636 # drag and drop enabled 637 self.setDragEnabled(True) 638 self.setAcceptDrops(True) 639 self.setDropIndicatorShown(True) 640 self.setSelectionMode(QAbstractItemView.ExtendedSelection) 641 self.setUniformRowHeights(True) 642 643 # the view needs to be aware of the other view for the expanding purposes 644 self.otherView = None 645 646 def mousePressEvent(self, e: QtGui.QMouseEvent) -> None: # noqa: N802 647 super().mousePressEvent(e) 648 self.handle_expand(e) 649 650 def mouseDoubleClickEvent(self, e: QtGui.QMouseEvent) -> None: # noqa: N802 651 super().mouseDoubleClickEvent(e) 652 self.handle_expand(e) 653 654 def handle_expand(self, e: QtGui.QMouseEvent) -> None: 655 """ 656 Check if click happened on the part with cause expanding the item in the view. If so also expand/collapse 657 the same item in the other view. 658 """ 659 clicked_index = self.indexAt(e.pos()) 660 if clicked_index.isValid(): 661 # if click is left from text then it is expand event 662 vrect = self.visualRect(clicked_index) 663 item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() 664 if e.pos().x() < item_identation: 665 self.expand_in_other_view(clicked_index, not self.isExpanded(clicked_index)) 666 667 def expand_in_other_view(self, clicked_index: QModelIndex, is_expanded: bool) -> None: 668 """ 669 Expand/collapse item which is same to the clicked_index in the other view. The other view is one of the left 670 if click happened on the right and vice versa. 671 672 Parameters 673 ---------- 674 clicked_index 675 Index of the item that was expanded/collapsed 676 is_expanded 677 This flag is true if the item was expanded otherwise if false (collapsed). 678 """ 679 source_model_this = self.model().sourceModel() 680 source_model_other = self.otherView.model().sourceModel() 681 clicked_model_index = self.model().mapToSource(clicked_index) 682 683 # register expanding change 684 source_model_this.set_expanded(clicked_model_index, is_expanded) 685 686 clicked_name = source_model_this.node_from_index(clicked_model_index).name 687 688 index_other = source_model_other.index_from_name(clicked_name, source_model_other.rootItem) 689 if index_other is not None: 690 # if same item (item with the same name) exists in the other view 691 self.otherView.setExpanded(index_other, is_expanded) 692 693 def setModel(self, model: QtCore.QAbstractItemModel) -> None: # noqa: N802 694 """ 695 Override setModel that we can connect expandItem signal. This signal is emitted when model 696 expand the item. 697 """ 698 super().setModel(model) 699 self.model().sourceModel().expand_item.connect(self.setExpanded) 700 701 def setExpanded(self, index: QModelIndex, expand: bool) -> None: # noqa: N802 702 """ 703 Set item expanded/collapsed. 704 705 Parameters 706 ---------- 707 index 708 Item's index 709 expand 710 This flag is true if the item was expanded otherwise if false (collapsed). 711 """ 712 self.model().sourceModel().set_expanded(index, expand) 713 index = self.model().mapFromSource(index) 714 if expand: 715 self.expand(index) 716 else: 717 self.collapse(index) 718 719 720class OWMarkerGenes(widget.OWWidget): 721 name = "Marker Genes" 722 icon = 'icons/OWMarkerGenes.svg' 723 priority = 130 724 725 replaces = ['orangecontrib.single_cell.widgets.owmarkergenes.OWMarkerGenes'] 726 727 class Warning(widget.OWWidget.Warning): 728 using_local_files = widget.Msg("Can't connect to serverfiles. Using cached files.") 729 730 class Outputs: 731 genes = widget.Output("Genes", Table) 732 733 want_main_area = True 734 want_control_area = True 735 736 auto_commit = Setting(True) 737 selected_source = Setting("") 738 selected_organism = Setting("") 739 selected_root_attribute = Setting(0) 740 741 settingsHandler = MarkerGroupContextHandler() # noqa: N815 742 selected_genes = settings.ContextSetting([]) 743 744 settings_version = 2 745 746 _data = None 747 _available_sources = None 748 749 def __init__(self) -> None: 750 super().__init__() 751 752 # define the layout 753 main_area = QWidget(self.mainArea) 754 self.mainArea.layout().addWidget(main_area) 755 layout = QGridLayout() 756 main_area.setLayout(layout) 757 layout.setContentsMargins(4, 4, 4, 4) 758 759 # filter line edit 760 self.filter_line_edit = QLineEdit() 761 self.filter_line_edit.setPlaceholderText("Filter marker genes") 762 layout.addWidget(self.filter_line_edit, 0, 0, 1, 3) 763 764 # define available markers view 765 self.available_markers_view = TreeView() 766 box = gui.vBox(self.mainArea, "Available markers", addToLayout=False) 767 box.layout().addWidget(self.available_markers_view) 768 layout.addWidget(box, 1, 0, 2, 1) 769 770 # create selected markers view 771 self.selected_markers_view = TreeView() 772 box = gui.vBox(self.mainArea, "Selected markers", addToLayout=False) 773 box.layout().addWidget(self.selected_markers_view) 774 layout.addWidget(box, 1, 2, 2, 1) 775 776 self.available_markers_view.otherView = self.selected_markers_view 777 self.selected_markers_view.otherView = self.available_markers_view 778 779 # buttons 780 box = gui.vBox(self.mainArea, addToLayout=False, margin=0) 781 layout.addWidget(box, 1, 1, 1, 1) 782 self.move_button = gui.button(box, self, ">", callback=self._move_selected) 783 784 self._init_description_area(layout) 785 self._init_control_area() 786 self._load_data() 787 788 def _init_description_area(self, layout: QLayout) -> None: 789 """ 790 Function define an info area with description of the genes and add it to the layout. 791 """ 792 box = gui.widgetBox(self.mainArea, "Description", addToLayout=False) 793 self.descriptionlabel = QTextBrowser( 794 openExternalLinks=True, textInteractionFlags=(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse) 795 ) 796 box.setMaximumHeight(self.descriptionlabel.fontMetrics().height() * (NUM_LINES_TEXT + 3)) 797 798 # description filed 799 self.descriptionlabel.setText("Select a gene to see information.") 800 self.descriptionlabel.setFrameStyle(QTextBrowser.NoFrame) 801 # no (white) text background 802 self.descriptionlabel.viewport().setAutoFillBackground(False) 803 box.layout().addWidget(self.descriptionlabel) 804 layout.addWidget(box, 3, 0, 1, 3) 805 806 def _init_control_area(self) -> None: 807 """ 808 Function defines dropdowns and the button in the control area. 809 """ 810 box = gui.widgetBox(self.controlArea, 'Database', margin=0) 811 self.source_index = -1 812 self.db_source_cb = gui.comboBox(box, self, 'source_index') 813 self.db_source_cb.activated[int].connect(self._set_db_source_index) 814 815 box = gui.widgetBox(self.controlArea, 'Organism', margin=0) 816 self.organism_index = -1 817 self.group_cb = gui.comboBox(box, self, 'organism_index') 818 self.group_cb.activated[int].connect(self._set_group_index) 819 820 box = gui.widgetBox(self.controlArea, 'Group by', margin=0) 821 self.group_by_cb = gui.comboBox( 822 box, self, 'selected_root_attribute', items=GROUP_BY_ITEMS, callback=self._setup 823 ) 824 825 gui.rubber(self.controlArea) 826 gui.auto_commit(self.controlArea, self, "auto_commit", "Commit") 827 828 def sizeHint(self): 829 return super().sizeHint().expandedTo(QSize(900, 500)) 830 831 @property 832 def available_sources(self) -> dict: 833 return self._available_sources 834 835 @available_sources.setter 836 def available_sources(self, value: dict) -> None: 837 """ 838 Set _available_sources variable, add them to dropdown, and select the source that was previously. 839 """ 840 self._available_sources = value 841 842 items = sorted(list(value.keys()), reverse=True) # panglao first 843 try: 844 idx = items.index(self.selected_source) 845 except ValueError: 846 idx = -1 847 848 self.db_source_cb.clear() 849 self.db_source_cb.addItems(items) 850 851 if idx != -1: 852 self.source_index = idx 853 self.selected_source = items[idx] 854 elif items: 855 self.source_index = min(max(self.source_index, 0), len(items) - 1) 856 857 self._set_db_source_index(self.source_index) 858 859 @property 860 def data(self) -> Table: 861 return self._data 862 863 @data.setter 864 def data(self, value: Table): 865 """ 866 Set the source data. The data is then filtered on the first meta column (group). 867 Select set dropdown with the groups and select the one that was selected previously. 868 """ 869 self._data = value 870 domain = value.domain 871 872 if domain.metas: 873 group = domain.metas[0] 874 groupcol, _ = value.get_column_view(group) 875 876 if group.is_string: 877 group_values = list(set(groupcol)) 878 elif group.is_discrete: 879 group_values = group.values 880 else: 881 raise TypeError("Invalid column type") 882 group_values = sorted(group_values) # human first 883 884 try: 885 idx = group_values.index(self.selected_organism) 886 except ValueError: 887 idx = -1 888 889 self.group_cb.clear() 890 self.group_cb.addItems(group_values) 891 892 if idx != -1: 893 self.organism_index = idx 894 self.selected_organism = group_values[idx] 895 elif group_values: 896 self.organism_index = min(max(self.organism_index, 0), len(group_values) - 1) 897 898 self._set_group_index(self.organism_index) 899 900 def _load_data(self) -> None: 901 """ 902 Collect available data sources (marker genes data sets). 903 """ 904 self.Warning.using_local_files.clear() 905 906 found_sources = {} 907 try: 908 found_sources.update(serverfiles.ServerFiles().allinfo(SERVER_FILES_DOMAIN)) 909 except requests.exceptions.ConnectionError: 910 found_sources.update(serverfiles.allinfo(SERVER_FILES_DOMAIN)) 911 self.Warning.using_local_files() 912 913 self.available_sources = {item.get('title').split(': ')[-1]: item for item in found_sources.values()} 914 915 def _source_changed(self) -> None: 916 """ 917 Respond on change of the source and download the data. 918 """ 919 if self.available_sources: 920 file_name = self.available_sources[self.selected_source]['filename'] 921 922 try: 923 serverfiles.update(SERVER_FILES_DOMAIN, file_name) 924 except requests.exceptions.ConnectionError: 925 # try to update file. Ignore network errors. 926 pass 927 928 try: 929 file_path = serverfiles.localpath_download(SERVER_FILES_DOMAIN, file_name) 930 except requests.exceptions.ConnectionError as err: 931 # Unexpected error. 932 raise err 933 self.data = Table.from_file(file_path) 934 935 def _setup(self) -> None: 936 """ 937 Setup the views with data. 938 """ 939 self.closeContext() 940 self.selected_genes = [] 941 self.openContext((self.selected_organism, self.selected_source)) 942 data_not_selected, data_selected = self._filter_data_group(self.data) 943 944 # add model to available markers view 945 group_by = GROUP_BY_ITEMS[self.selected_root_attribute] 946 tree_model = TreeModel(data_not_selected, group_by) 947 proxy_model = FilterProxyModel(self.filter_line_edit) 948 proxy_model.setSourceModel(tree_model) 949 950 self.available_markers_view.setModel(proxy_model) 951 self.available_markers_view.selectionModel().selectionChanged.connect( 952 partial(self._on_selection_changed, self.available_markers_view) 953 ) 954 955 tree_model = TreeModel(data_selected, group_by) 956 proxy_model = FilterProxyModel(self.filter_line_edit) 957 proxy_model.setSourceModel(tree_model) 958 self.selected_markers_view.setModel(proxy_model) 959 960 self.selected_markers_view.selectionModel().selectionChanged.connect( 961 partial(self._on_selection_changed, self.selected_markers_view) 962 ) 963 self.selected_markers_view.model().sourceModel().data_added.connect(self._selected_markers_changed) 964 self.selected_markers_view.model().sourceModel().data_removed.connect(self._selected_markers_changed) 965 966 # update output and messages 967 self._selected_markers_changed() 968 969 def _filter_data_group(self, data: Table) -> Tuple[Table, Tuple]: 970 """ 971 Function filter the table based on the selected group (Mouse, Human) and divide them in two groups based on 972 selected_data variable. 973 974 Parameters 975 ---------- 976 data 977 Table to be filtered 978 979 Returns 980 ------- 981 data_not_selected 982 Data that will initially be in available markers view. 983 data_selected 984 Data that will initially be in selected markers view. 985 """ 986 group = data.domain.metas[0] 987 gvec = data.get_column_view(group)[0] 988 989 if group.is_string: 990 mask = gvec == self.selected_organism 991 else: 992 mask = gvec == self.organism_index 993 data = data[mask] 994 995 # divide data based on selected_genes variable (context) 996 unique_gene_names = np.core.defchararray.add( 997 data.get_column_view("Entrez ID")[0].astype(str), data.get_column_view("Cell Type")[0].astype(str) 998 ) 999 mask = np.isin(unique_gene_names, self.selected_genes) 1000 data_not_selected = data[~mask] 1001 data_selected = data[mask] 1002 return data_not_selected, data_selected 1003 1004 def commit(self) -> None: 1005 rows = self.selected_markers_view.model().sourceModel().rootItem.get_data_rows() 1006 if len(rows) > 0: 1007 metas = [r.metas for r in rows] 1008 data = Table.from_numpy(self.data.domain, np.empty((len(metas), 0)), metas=np.array(metas)) 1009 # always false for marker genes data tables in single cell 1010 data.attributes[GENE_AS_ATTRIBUTE_NAME] = False 1011 # set taxonomy id in data.attributes 1012 data.attributes[TAX_ID] = MAP_GROUP_TO_TAX_ID.get(self.selected_organism, '') 1013 # set column id flag 1014 data.attributes[GENE_ID_COLUMN] = "Entrez ID" 1015 data.name = 'Marker Genes' 1016 else: 1017 data = None 1018 self.Outputs.genes.send(data) 1019 1020 def _update_description(self, view: TreeView) -> None: 1021 """ 1022 Upate the description about the gene. Only in case when one gene is selected. 1023 """ 1024 selection = self._selected_rows(view) 1025 qmodel = view.model().sourceModel() 1026 1027 if len(selection) > 1 or len(selection) == 0 or qmodel.node_from_index(selection[0]).data_row is None: 1028 self.descriptionlabel.setText("Select a gene to see information.") 1029 else: 1030 data_row = qmodel.node_from_index(selection[0]).data_row 1031 self.descriptionlabel.setHtml( 1032 f"<b>Gene name:</b> {data_row['Name']}<br/>" 1033 f"<b>Entrez ID:</b> {data_row['Entrez ID']}<br/>" 1034 f"<b>Cell Type:</b> {data_row['Cell Type']}<br/>" 1035 f"<b>Function:</b> {data_row['Function']}<br/>" 1036 f"<b>Reference:</b> <a href='{data_row['URL']}'>{data_row['Reference']}</a>" 1037 ) 1038 1039 def _update_data_info(self) -> None: 1040 """ 1041 Updates output info in the control area. 1042 """ 1043 sel_model = self.selected_markers_view.model().sourceModel() 1044 self.info.set_output_summary(f"Selected: {str(len(sel_model))}") 1045 1046 # callback functions 1047 1048 def _selected_markers_changed(self) -> None: 1049 """ 1050 This function is called when markers in the selected view are added or removed. 1051 """ 1052 rows = self.selected_markers_view.model().sourceModel().rootItem.get_data_rows() 1053 self.selected_genes = [row["Entrez ID"].value + row["Cell Type"].value for row in rows] 1054 self._update_data_info() 1055 self.commit() 1056 1057 def _on_selection_changed(self, view: TreeView) -> None: 1058 """ 1059 When selection in one of the view changes in a view button should change a sign in the correct direction and 1060 other view should reset the selection. Also gene description is updated. 1061 """ 1062 self.move_button.setText(">" if view is self.available_markers_view else "<") 1063 if view is self.available_markers_view: 1064 self.selected_markers_view.clearSelection() 1065 else: 1066 self.available_markers_view.clearSelection() 1067 self._update_description(view) 1068 1069 def _set_db_source_index(self, source_index: int) -> None: 1070 """ 1071 Set the index of selected database source - index in a dropdown. 1072 """ 1073 self.source_index = source_index 1074 self.selected_source = self.db_source_cb.itemText(source_index) 1075 self._source_changed() 1076 1077 def _set_group_index(self, group_index: int) -> None: 1078 """ 1079 Set the index of organism - index in a dropdown. 1080 """ 1081 self.organism_index = group_index 1082 self.selected_organism = self.group_cb.itemText(group_index) 1083 self._setup() 1084 1085 def _move_selected(self) -> None: 1086 """ 1087 Move selected genes when button clicked. 1088 """ 1089 if self._selected_rows(self.selected_markers_view): 1090 self._move_selected_from_to(self.selected_markers_view, self.available_markers_view) 1091 elif self._selected_rows(self.available_markers_view): 1092 self._move_selected_from_to(self.available_markers_view, self.selected_markers_view) 1093 1094 # support functions for callbacks 1095 1096 def _move_selected_from_to(self, src: TreeView, dst: TreeView) -> None: 1097 """ 1098 Function moves items from src model to dst model. 1099 """ 1100 selected_items = self._selected_rows(src) 1101 1102 src_model = src.model().sourceModel() 1103 dst_model = dst.model().sourceModel() 1104 1105 # move data as mimeData from source to destination tree view 1106 mime_data = src_model.mimeData(selected_items) 1107 # remove nodes from the source view 1108 src_model.remove_node_list(selected_items) 1109 dst_model.dropMimeData(mime_data, Qt.MoveAction, -1, -1) 1110 1111 @staticmethod 1112 def _selected_rows(view: TreeView) -> List[QModelIndex]: 1113 """ 1114 Return the selected rows in the view. 1115 """ 1116 rows = view.selectionModel().selectedRows() 1117 return list(map(view.model().mapToSource, rows)) 1118 1119 @classmethod 1120 def migrate_settings(cls, settings, version=0): 1121 def migrate_to_version_2(): 1122 settings["selected_source"] = settings.pop("selected_db_source", "") 1123 settings["selected_organism"] = settings.pop("selected_group", "") 1124 if "context_settings" in settings: 1125 for co in settings["context_settings"]: 1126 co.values["selected_genes"] = [g[0] + g[1] for g in co.values["selected_genes"]] 1127 1128 if version < 2: 1129 migrate_to_version_2() 1130 1131 1132if __name__ == "__main__": 1133 from orangewidget.utils.widgetpreview import WidgetPreview 1134 1135 WidgetPreview(OWMarkerGenes).run() 1136