Source code for uchrom.browser.main_window

import random
import itertools
import os.path as osp
import json

from PyQt5 import QtGui
import numpy as np

from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (QFileDialog, QMainWindow, QSplitter, QStackedWidget,
                             QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton)

from uchrom.io import read_particles
from .canvas import Canvas
from .menubar import MenuBar


HERE = osp.dirname(osp.abspath(__file__))


def random_color():
    return "#"+''.join([random.choice('0123456789ABCDEF') for _ in range(6)])


[docs] class MainWindow(QMainWindow): def __init__(self, canvas_only=False): QMainWindow.__init__(self) self.chr_colormap = {} self.resize(1100, 700) self.setWindowTitle('U-Chrom Browser') self.splitter = QSplitter(Qt.Horizontal) self.control_stack = QStackedWidget() self.canvas_grid = [] self.vsps = [] self.canvas_grid_widget = QSplitter(Qt.Horizontal) self.current_ix = (0, -1) self.canvas = None self.add_canvas('h') self.splitter.addWidget(self.canvas_grid_widget) # Create a container widget for central widget self.central_container = QWidget() self.central_layout = QVBoxLayout(self.central_container) self.central_layout.setContentsMargins(0, 0, 0, 0) self.central_layout.addWidget(self.splitter) # Create navigation panel (will be overlaid on top) self.navigation_panel = self._create_navigation_panel() self.setCentralWidget(self.central_container) self.setWindowIcon(QtGui.QIcon(osp.join(HERE, 'uc.svg'))) self.menubar = MenuBar(self) if not canvas_only: self.add_widgets() @property def link_canvas(self): return self.menubar.link_canvas def _create_navigation_panel(self): """Create the initial navigation panel displayed at startup.""" panel = QWidget() panel.setObjectName("NavigationPanel") # Set a fixed width for the panel to ensure proper centering panel.setFixedWidth(400) # Main layout layout = QVBoxLayout() layout.setAlignment(Qt.AlignCenter) layout.setSpacing(15) layout.setContentsMargins(20, 20, 20, 20) # Logo (uc.svg) centered above the title try: from PyQt5 import QtSvg from PyQt5.QtCore import QSize svg_path = osp.join(HERE, 'uc.svg') if osp.exists(svg_path): logo = QtSvg.QSvgWidget(svg_path) logo.setFixedSize(QSize(96, 96)) logo_row = QHBoxLayout() logo_row.setAlignment(Qt.AlignCenter) logo_row.addWidget(logo) layout.addLayout(logo_row) except Exception: pass # Title title = QLabel("U-Chrom") title.setStyleSheet("font-size: 48px; font-weight: bold; color: #FFFFFF;") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) # Version try: from uchrom import __version__ version_text = f"version: {__version__}" except: version_text = "version: 0.1.1" version = QLabel(version_text) version.setStyleSheet("font-size: 18px; color: #888888;") version.setAlignment(Qt.AlignCenter) layout.addWidget(version) # Add spacing before buttons layout.addSpacing(20) # Buttons container buttons_layout = QHBoxLayout() buttons_layout.setAlignment(Qt.AlignCenter) buttons_layout.setSpacing(15) # Open structure button btn_open = QPushButton("Open a structure") btn_open.setFixedSize(165, 50) btn_open.setStyleSheet(""" QPushButton { background-color: transparent; border: 2px solid #FFFFFF; color: #FFFFFF; font-size: 14px; padding: 10px 20px; border-radius: 5px; } QPushButton:hover { background-color: rgba(255, 255, 255, 0.1); } QPushButton:pressed { background-color: rgba(255, 255, 255, 0.2); } """) btn_open.clicked.connect(self.open_file_dialog) buttons_layout.addWidget(btn_open) # Load session button btn_load = QPushButton("Load session") btn_load.setFixedSize(165, 50) btn_load.setStyleSheet(""" QPushButton { background-color: transparent; border: 2px solid #FFFFFF; color: #FFFFFF; font-size: 14px; padding: 10px 20px; border-radius: 5px; } QPushButton:hover { background-color: rgba(255, 255, 255, 0.1); } QPushButton:pressed { background-color: rgba(255, 255, 255, 0.2); } """) btn_load.clicked.connect(self.load_session_dialog) buttons_layout.addWidget(btn_load) layout.addLayout(buttons_layout) panel.setLayout(layout) # Position panel in the center of main window panel.setParent(self.central_container) panel.setAttribute(Qt.WA_StyledBackground, True) # Initially hidden, will be shown in add_widgets panel.hide() return panel def _center_navigation_panel(self): """Center the navigation panel on the main window.""" if self.navigation_panel and self.central_container: # Ensure the panel has been laid out and has a proper size self.navigation_panel.adjustSize() container_size = self.central_container.size() panel_size = self.navigation_panel.size() x = (container_size.width() - panel_size.width()) // 2 y = (container_size.height() - panel_size.height()) // 2 self.navigation_panel.move(max(0, x), max(0, y)) self.navigation_panel.raise_() # Ensure panel is on top def _show_navigation_panel(self): """Show the navigation panel.""" if self.navigation_panel: self.navigation_panel.show() # Use a timer to ensure the layout is complete before centering from PyQt5.QtCore import QTimer QTimer.singleShot(0, self._center_navigation_panel) def _hide_navigation_panel(self): """Hide the navigation panel.""" if self.navigation_panel: self.navigation_panel.hide()
[docs] def resizeEvent(self, event): """Handle window resize to keep navigation panel centered.""" super().resizeEvent(event) if self.navigation_panel and self.navigation_panel.isVisible(): self._center_navigation_panel()
[docs] def add_canvas(self, orientation='h'): canvas = Canvas() canvas.create_native() canvas.main_window = self canvas.native.setParent(self) canvas.view.events.mouse_press.connect(self.on_click_canvas) canvas.view.events.mouse_move.connect(self.trigger_other_canvas_mouse_event) canvas.view.events.mouse_wheel.connect(self.trigger_other_canvas_mouse_event) self._setup_camera_sync(canvas) self.canvas = canvas ix = self.current_ix if orientation == 'h': vsp = QSplitter(Qt.Vertical) vsp.addWidget(canvas.native) self.canvas_grid.append([canvas]) self.canvas_grid_widget.addWidget(vsp) self.vsps.append(vsp) ix = (ix[0], len(self.canvas_grid) - 1) elif orientation == 'v': vsp = self.vsps[ix[1]] vsp.addWidget(canvas.native) col = self.canvas_grid[ix[1]] col.append(canvas) ix = (len(col) - 1, ix[1]) else: raise ValueError(f"orientation expect 'h' or 'v', got {orientation}") self.current_ix = ix self.control_stack.addWidget(canvas.control_panel) self.control_stack.setCurrentWidget(canvas.control_panel) self.update_canvas_border_color()
[docs] def remove_canvas(self): if len(list(self.get_all_canvas())) == 1: return ix = self.current_ix new_ix = list(ix) col = self.canvas_grid[ix[1]] col.pop(ix[0]) if len(col) == 0: self.canvas_grid.pop(ix[1]) vsp = self.vsps.pop(ix[1]) new_ix[1] = max(0, ix[1]-1) vsp.hide() del vsp new_ix[0] = min(len(col)-1, ix[0]) new_ix = tuple(new_ix) current = self.canvas current.native.setVisible(False) self.change_current_canvas(new_ix) self.control_stack.removeWidget(current.control_panel) del current
[docs] def get_all_canvas(self): return itertools.chain.from_iterable(self.canvas_grid)
def _setup_camera_sync(self, canvas): """Add VTK observer to sync camera across linked canvases.""" try: iren = canvas.backend.plotter.iren.interactor def on_interaction(*args): if not self.link_canvas: return if getattr(self, '_syncing_camera', False): return self._syncing_camera = True try: src_state = canvas.backend.get_camera_state() for other in self.get_all_canvas(): if other is not canvas: other.backend.set_camera_state(src_state) other.backend.plotter.reset_camera_clipping_range() other.backend.render() finally: self._syncing_camera = False iren.AddObserver('InteractionEvent', on_interaction) iren.AddObserver('EndInteractionEvent', on_interaction) except Exception: pass
[docs] def trigger_other_canvas_mouse_event(self, event): if not self.link_canvas: return a = event.source.canvas for b in self.get_all_canvas(): if b is not a: for wref, name in b.view.events.mouse_move.callbacks: if name == "viewbox_mouse_event": wref().viewbox_mouse_event(event) break
[docs] def get_canvas_ix(self, canvas): for j, col in enumerate(self.canvas_grid): for i, c in enumerate(col): if c is canvas: return (i, j) return None
[docs] def on_click_canvas(self, event): new = event.source.canvas new_ix = self.get_canvas_ix(new) self.change_current_canvas(new_ix)
[docs] def change_current_canvas(self, new_ix): old_ix = self.current_ix self.canvas = new = self.canvas_grid[new_ix[1]][new_ix[0]] self.menubar.set_show_axis(new.axis.visible) self.current_ix = new_ix self.control_stack.setCurrentWidget(new.control_panel) self.update_canvas_border_color()
[docs] def update_canvas_border_color(self): if (len(self.canvas_grid) == 1) and (len(self.canvas_grid[0]) == 1): return for canvas in self.get_all_canvas(): canvas.view.border_color = "#888888" if canvas is self.canvas else "#000000"
[docs] def add_widgets(self): self.splitter.addWidget(self.control_stack) self.splitter.setSizes([850, 420]) # Canvas area stretches with the window; the sidebar keeps its width. self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 0) # Hide the entire splitter (canvas + layers panel) initially until a structure is loaded self.splitter.setVisible(False) # Show the navigation panel initially self._show_navigation_panel() self.setMenuBar(self.menubar) self._connect_events()
def _connect_events(self): self.menubar.action_open.triggered.connect(self.open_file_dialog) self.menubar.action_save_session.triggered.connect(self.save_session_dialog) self.menubar.action_load_session.triggered.connect(self.load_session_dialog) self.menubar.action_add_canvas_hor.triggered.connect(self.add_canvas_hor) self.menubar.action_add_canvas_ver.triggered.connect(self.add_canvas_ver) self.menubar.action_remove_canvas.triggered.connect(self.remove_canvas) self.menubar.action_show_axis.triggered.connect(self._all_or_current(self.on_toggle_show_axis)) self.menubar.action_change_link_status.triggered.connect(self._on_link_toggled) def _on_link_toggled(self): """Sync all canvas UIs when link mode changes.""" for canvas in self.get_all_canvas(): layer = getattr(canvas.control_panel, 'current_layer', None) if layer and hasattr(layer, 'control_panel') and hasattr(layer.control_panel, 'sync_ui_from_cache'): layer.control_panel.sync_ui_from_cache() def _all_or_current(self, f): def wrap(*args, **kwargs): current = self.canvas if self.link_canvas: # apply to all canvas for c in self.get_all_canvas(): self.canvas = c f(*args, **kwargs) self.canvas = current else: # only current f(*args, **kwargs) return wrap
[docs] def add_canvas_hor(self): self.add_canvas('h')
[docs] def add_canvas_ver(self): self.add_canvas('v')
[docs] def open_file_dialog(self): options = QFileDialog.Options() options |= QFileDialog.DontUseNativeDialog path, _ = QFileDialog.getOpenFileName(self, "Open", "", "Structure Files (*.h5cd *.csv);;ChromData (*.h5cd);;CSV Files (*.csv);;All Files (*)", options=options) if path: try: print(path) df = read_particles(path) self.load_model(df, path) self.init_draw() except Exception as e: print(str(e))
[docs] def save_session_dialog(self): options = QFileDialog.Options() options |= QFileDialog.DontUseNativeDialog path, _ = QFileDialog.getSaveFileName(self, "Save Session", "", "U-Chrom Session (*.json);;All Files (*)", options=options) if path: try: self.save_session(path) except Exception as e: print(str(e))
[docs] def load_session_dialog(self): options = QFileDialog.Options() options |= QFileDialog.DontUseNativeDialog path, _ = QFileDialog.getOpenFileName(self, "Load Session", "", "U-Chrom Session (*.json);;All Files (*)", options=options) if path: try: # Open session in a new window instead of current win = MainWindow() win.load_session_file(path) win.show() except Exception as e: print(str(e))
[docs] def init_draw(self): for layer in self.canvas.control_panel.layers: layer.draw()
[docs] def on_toggle_show_axis(self, *args): val = self.menubar.show_axis self.canvas.axis.visible = val
[docs] def load_model(self, df, path: str = None): random.seed(0) for c in df['chrom'].unique(): if c not in self.chr_colormap: self.chr_colormap[c] = random_color() name = None try: if path is not None: base = osp.basename(path) name = osp.splitext(base)[0] except Exception: pass layer = self.canvas.control_panel.add_chromatin_layer(df, self.chr_colormap, name=name) try: if path is not None and hasattr(layer, 'cache_props'): layer.cache_props['data_path'] = path except Exception: pass # Hide navigation panel and show the canvas and layers panel when a structure is loaded self._hide_navigation_panel() self.splitter.setVisible(True) self.control_stack.setVisible(True)
[docs] def save_session(self, path: str): try: def _records_from_df(df): try: # Only keep common columns to keep size reasonable cols = [c for c in ['x', 'y', 'z', 'chrom', 'start', 'end'] if c in df.columns] if len(cols) == 0: return None return df[cols].to_dict('records') except Exception: return None canvases = [] for cv in self.get_all_canvas(): # Serialize layers cv_layers = [] try: for layer in getattr(cv.control_panel, 'layers', []): try: entry = { 'type': 'Chromatin', 'name': getattr(layer, 'name', None), 'locked': getattr(layer, 'locked', False), } cache = getattr(layer, 'cache_props', {}) if hasattr(layer, 'cache_props') else {} if isinstance(cache, dict): entry['style'] = cache.get('style') entry['width'] = cache.get('width') entry['opacity'] = cache.get('opacity') entry['color_mode'] = cache.get('color_mode') entry['single_color'] = cache.get('single_color') entry['chr_colormap'] = cache.get('chr_colormap', {}) entry['data_path'] = cache.get('data_path') # Include a compact props copy for easy restore entry['props'] = { k: v for k, v in cache.items() if k in ['style', 'width', 'opacity', 'color_mode', 'single_color'] } # Embed data if available df = getattr(layer, 'df', None) if df is not None: records = _records_from_df(df) entry['df_records'] = records cv_layers.append(entry) except Exception: continue except Exception: pass # Camera approximation and axis visibility try: dist = float(getattr(cv.view.camera, 'distance', 50.0)) except Exception: dist = 50.0 canvas_entry = { 'layers': cv_layers, 'camera': { # Approximate position with distance along Z 'position': [0.0, 0.0, dist] }, 'axis_visible': bool(getattr(cv.axis, 'visible', False)) } canvases.append(canvas_entry) data = { 'version': 1, 'chr_colormap': self.chr_colormap, 'canvases': canvases } with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: print(str(e))
[docs] def load_session_file(self, path: str): try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) # Basic validation if not isinstance(data, dict): print("Invalid session format: root is not an object") return version = data.get("version") try: print(f"Loaded session: {version}") except Exception: pass canvases = data.get("canvases", []) if not isinstance(canvases, list): print("Invalid session format: 'canvases' is not a list") return # Helper to coerce a DataFrame from records def _df_from_records(records): try: import pandas as pd if not isinstance(records, list): return None df = pd.DataFrame.from_records(records) # Ensure required columns exist for col in ["chrom", "x", "y", "z"]: if col not in df.columns: return None # Normalize types df["chrom"] = df["chrom"].astype(str) return df except Exception: return None # Iterate canvases as stored for i, cv in enumerate(canvases): if i > 0: # Create a new canvas to the right for each additional canvas self.add_canvas('h') # Each canvas becomes current via add_canvas; apply per-canvas settings below layers = [] try: layers = cv.get("layers", []) if isinstance(cv, dict) else [] except Exception: layers = [] # Restore layers for layer_desc in layers: try: if not isinstance(layer_desc, dict): continue layer_type = layer_desc.get("type") if layer_type != "Chromatin": continue df = None data_path = layer_desc.get("data_path") if isinstance(data_path, str) and len(data_path) > 0: try: df = read_particles(data_path) except Exception as e: print(f"Failed to read data_path: {e}") if df is None: df_records = layer_desc.get("df_records") df = _df_from_records(df_records) if df is None: # Skip if no data available continue # Colormap: prefer layer-specific; fallback to global if present layer_colormap = layer_desc.get("chr_colormap") if not isinstance(layer_colormap, dict): layer_colormap = data.get("chr_colormap", {}) if isinstance(data.get("chr_colormap"), dict) else {} # Basic props width = layer_desc.get("width") style = layer_desc.get("style") name = layer_desc.get("name") if not isinstance(width, (int, float)): width = None if not isinstance(style, str): style = None # Add layer new_layer = self.canvas.control_panel.add_chromatin_layer( df, layer_colormap, parent=None, name=name, width=width, style=style ) # Apply extended props if provided props = layer_desc.get("props") if isinstance(layer_desc.get("props"), dict) else {} color_mode = props.get("color_mode", layer_desc.get("color_mode")) single_color = props.get("single_color", layer_desc.get("single_color")) opacity = props.get("opacity", layer_desc.get("opacity")) try: if isinstance(color_mode, str): new_layer.cache_props["color_mode"] = color_mode if isinstance(single_color, str): new_layer.cache_props["single_color"] = single_color if isinstance(opacity, (int, float)): new_layer.cache_props["opacity"] = float(opacity) # Ensure current colormap persists new_layer.cache_props["chr_colormap"] = layer_colormap # Redraw to apply props new_layer.remove() new_layer.draw() except Exception as e: print(f"Failed to apply layer props: {e}") except Exception as e: print(f"Failed to restore a layer: {e}") # Restore per-canvas axis visibility try: axis_vis = cv.get("axis_visible") if isinstance(cv, dict) else None if isinstance(axis_vis, bool): self.canvas.axis.visible = axis_vis except Exception: pass # Restore camera (approximate distance from position if provided) try: cam = cv.get("camera") if isinstance(cv, dict) else None if isinstance(cam, dict): pos = cam.get("position") if isinstance(pos, list) and len(pos) == 3: try: import math dist = math.sqrt(sum([float(pos[i])**2 for i in range(3)])) self.canvas.view.camera.distance = dist except Exception: pass except Exception: pass # Hide navigation panel and show the canvas and layers panel when a session is loaded (if any layers were loaded) if len(canvases) > 0: has_layers = False for cv in canvases: layers = cv.get("layers", []) if isinstance(cv, dict) else [] if len(layers) > 0: has_layers = True break if has_layers: self._hide_navigation_panel() self.splitter.setVisible(True) self.control_stack.setVisible(True) except Exception as e: print(str(e))
[docs] def adjust_camera(self, factor=2.0): pos = self.canvas.info['df'][['x', 'y', 'z']].values norm = np.sqrt(sum([(pos[:, i])**2 for i in range(3)])) max_norm = np.max(norm) self.canvas.view.camera.distance = max_norm * factor