Skip to content

Commit

Permalink
Escher interactive (#401)
Browse files Browse the repository at this point in the history
* use Escher maps interactively with CNApy

* adaptive waiting period for Escher to become operational

* bump format version, improve Map menu, basic FVA support for Escher, explicitly ingore drag on Escher map

* phase one of initial setup now on Javascript side

Co-authored-by: Paulocracy <[email protected]>
  • Loading branch information
axelvonkamp and Paulocracy authored Sep 21, 2022
1 parent fabb676 commit 7606a01
Show file tree
Hide file tree
Showing 9 changed files with 664 additions and 88 deletions.
9 changes: 6 additions & 3 deletions cnapy/appdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
class AppData:
''' The application data '''

def __init__(self):
def __init__(self, qapp):
self.qapp = qapp
self.version = "cnapy-1.1.1"
self.format_version = 1
self.format_version = 2
self.unsaved = False
self.project = ProjectData()
self.modes_coloring = False
Expand Down Expand Up @@ -329,7 +330,9 @@ def CnaMap(name):
"box-size": 1,
"zoom": 0,
"pos": (0, 0),
"boxes": {}
"boxes": {},
"view": "cnapy", # either "cnapy" or "escher"
"escher_map_data": "" # JSON string
}

def parse_scenario(text: str) -> Tuple[float, float]:
Expand Down
2 changes: 1 addition & 1 deletion cnapy/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class Application:

def __init__(self):
self.qapp = QApplication(sys.argv)
self.appdata = AppData()
self.appdata = AppData(self.qapp)
self.qapp.setStyle("fusion")
self.window = MainWindow(self.appdata)
self.appdata.window = self.window
Expand Down
78 changes: 78 additions & 0 deletions cnapy/data/escher.min.js

Large diffs are not rendered by default.

135 changes: 135 additions & 0 deletions cnapy/data/escher_cnapy.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<script src="escher.min.js" charset="utf-8"></script>
<script type="text/javascript" src="qrc:///qtwebchannel/qwebchannel.js"></script>
<script type="text/javascript">
var cnapy_bridge = null;
window.onload = function()
{
new QWebChannel(qt.webChannelTransport, function(channel) {
cnapy_bridge = channel.objects.cnapy_bridge;
var wait_count = 0;
function wait_for_map() {
if (builder.map != null) { // take this as proxy that Escher is now operational
try {
cnapy_bridge.get_map_and_geometry(map_and_geometry => {
if (map_and_geometry[0].length > 0) {
console.warn("Loading map");
builder.load_map(JSON.parse(map_and_geometry[0]));
builder.map.zoomContainer.goTo(JSON.parse(map_and_geometry[1]), JSON.parse(map_and_geometry[2]));
}
builder.passPropsSearchBar({display:true});
setTimeout(() => {cnapy_bridge.finish_setup();}, 50)
})
}
catch (error) {
alert(error);
}
}
else
if (wait_count < 50) {
wait_count += 1
console.warn(wait_count)
setTimeout(wait_for_map, 100)
}
else
alert("Escher appears inoperational, cannot display map.")
}
setTimeout(wait_for_map, 100)
});
}

const preact = escher.libs.preact;
const h = preact.createElement;

var tooltipStyle = {
'min-width': '40px',
'min-height': '10px',
'border-radius': '2px',
'border': '1px solid #b58787',
'padding': '7px',
'background-color': '#fff',
'text-align': 'left',
'font-size': '16px',
'font-family': 'sans-serif',
'color': '#111',
'box-shadow': '4px 6px 20px 0px rgba(0, 0, 0, 0.4)'
};

class CnapyTooltip extends preact.Component {
constructor() {
super()
}

componentShouldUpdate() {
// important according to Escher documentation
return false;
}

handleKeyUp(event) {
cnapy_bridge.value_changed(this.props.biggId, event.target.value, event.keyCode === 13)
}

handleOnFocusOut(event) {
cnapy_bridge.value_changed(this.props.biggId, event.target.value, true)
}

handleClickOnID(event) {
cnapy_bridge.clicked_on_id(this.props.type, this.props.biggId)
}

render () {
var tip = h('div', {className: 'cnapy-tooltip', style: tooltipStyle},
h('div', {className: 'id', onClick: (event) => this.handleClickOnID(event), style: "font-weight: bold;"}, this.props.biggId),
h('div', {className: 'name'}, this.props.name))

if (this.props.type === 'reaction') {
tip.children.push(h('input', {type: 'text',
id: 'reaction-box-input',
style: 'color: black',
onFocus: (event) => event.target.select(),
onKeyUp: (event) => this.handleKeyUp(event),
onFocusOut: (event) => this.handleOnFocusOut(event)
}))
if (cnapy_bridge) // to keep page operational outside CNApy
cnapy_bridge.set_reaction_box_scenario_value(this.props.biggId)
}

return tip
}
}
</script>

<meta charset="utf-8"/>
<body>
<div id="map_container"></div>

<script type="text/javascript">
builder = escher.Builder(null, null, null, escher.libs.d3_select('#map_container'),
{menu: 'all', fill_screen: true, never_ask_before_quit: true, tooltip_component: CnapyTooltip, scroll_behavior: 'zoom'})

function reactionOnMap(reacId) {
var records = builder.map.search_index.find(reacId);
for (i=0; i<records.length; i++) {
var record = records[i];
if (record.type == "reaction" && builder.map.reactions[record.reaction_id].bigg_id == reacId)
return true;
}
return false;
}

function highlightAndFocusReaction(reacId) {
var records = builder.map.search_index.find(reacId);
for (i=0; i<records.length; i++) {
var record = records[i];
if (record.type == "reaction" && builder.map.reactions[record.reaction_id].bigg_id == reacId) {
builder.map.highlight_reaction(record.reaction_id);
builder.map.zoom_to_reaction(record.reaction_id);
return
}
}
}

</script>
</body>
</html>
56 changes: 39 additions & 17 deletions cnapy/gui_elements/central_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
import cobra
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtpy.QtCore import Qt, Signal, Slot
from qtpy.QtCore import Qt, Signal, Slot, QSignalBlocker
from qtpy.QtGui import QColor, QBrush
from qtpy.QtWidgets import (QDialog, QLabel, QLineEdit, QPushButton, QSplitter,
QTabWidget, QVBoxLayout, QWidget, QAction)

from cnapy.appdata import AppData, CnaMap, parse_scenario
from cnapy.gui_elements.map_view import MapView
from cnapy.gui_elements.escher_map_view import EscherMapView
from cnapy.gui_elements.metabolite_list import MetaboliteList
from cnapy.gui_elements.gene_list import GeneList
from cnapy.gui_elements.mode_navigator import ModeNavigator
Expand Down Expand Up @@ -198,8 +199,9 @@ def shutdown_kernel(self):
self.console.kernel_manager.shutdown_kernel()

def switch_to_reaction(self, reaction: str):
self.tabs.setCurrentIndex(ModelTabIndex.Reactions)
if self.tabs.width() == ModelTabIndex.Reactions:
with QSignalBlocker(self.tabs): # set_current_item will update
self.tabs.setCurrentIndex(ModelTabIndex.Reactions)
if self.tabs.width() == 0:
(left, _) = self.splitter.sizes()
self.splitter.setSizes([left, 1])
self.reaction_list.set_current_item(reaction)
Expand Down Expand Up @@ -242,8 +244,22 @@ def tabs_changed(self, idx):
elif idx == ModelTabIndex.Model:
self.model_info.update()

def connect_map_view_signals(self, mmap: MapView):
mmap.switchToReactionMask.connect(self.switch_to_reaction)
mmap.minimizeReaction.connect(self.minimize_reaction)
mmap.maximizeReaction.connect(self.maximize_reaction)
mmap.reactionValueChanged.connect(self.update_reaction_value)
mmap.reactionRemoved.connect(self.update_reaction_maps)
mmap.reactionAdded.connect(self.update_reaction_maps)
mmap.mapChanged.connect(self.handle_mapChanged)

def connect_escher_map_view_signals(self, mmap: EscherMapView):
mmap.cnapy_bridge.reactionValueChanged.connect(self.update_reaction_value)
mmap.cnapy_bridge.switchToReactionMask.connect(self.switch_to_reaction)
mmap.cnapy_bridge.jumpToMetabolite.connect(self.jump_to_metabolite)

@Slot()
def add_map(self, base_name="Map"):
def add_map(self, base_name="Map", escher=False):
if base_name == "Map" or (base_name in self.appdata.project.maps.keys()):
while True:
name = base_name + " " + str(self.map_counter)
Expand All @@ -253,19 +269,23 @@ def add_map(self, base_name="Map"):
else:
name = base_name
m = CnaMap(name)

self.appdata.project.maps[name] = m
mmap = MapView(self.appdata, self, name)
mmap.switchToReactionMask.connect(self.switch_to_reaction)
mmap.minimizeReaction.connect(self.minimize_reaction)
mmap.maximizeReaction.connect(self.maximize_reaction)

mmap.reactionValueChanged.connect(self.update_reaction_value)
mmap.reactionRemoved.connect(self.update_reaction_maps)
mmap.reactionAdded.connect(self.update_reaction_maps)
mmap.mapChanged.connect(self.handle_mapChanged)
idx = self.map_tabs.addTab(mmap, m["name"])
self.update_maps()
if escher:
mmap: EscherMapView = EscherMapView(self, name)
self.connect_escher_map_view_signals(mmap)
self.appdata.project.maps[name][EscherMapView] = mmap
self.appdata.project.maps[name]['view'] = 'escher'
self.appdata.project.maps[name]['pos'] = '{"x":0,"y":0}'
self.appdata.project.maps[name]['zoom'] = '1'
# mmap.loadFinished.connect(self.finish_add_escher_map)
# mmap.cnapy_bridge.reactionValueChanged.connect(self.update_reaction_value) # connection is not made?!
# self.appdata.qapp.processEvents() # does not help
idx = self.map_tabs.addTab(mmap, m["name"])
else:
mmap = MapView(self.appdata, self, name)
self.connect_map_view_signals(mmap)
idx = self.map_tabs.addTab(mmap, m["name"])
self.update_maps() # only update mmap?
self.map_tabs.setCurrentIndex(idx)
self.parent.unsaved_changes()

Expand Down Expand Up @@ -452,7 +472,9 @@ def update_reaction_on_maps(self, old_reaction_id: str, new_reaction_id: str):
def delete_reaction_on_maps(self, reation_id: str):
for idx in range(0, self.map_tabs.count()):
m = self.map_tabs.widget(idx)
m.delete_box(reation_id)
if isinstance(m, MapView):
m.delete_box(reation_id)
#TODO: find out if a reaction can be programatically removed from Escher

def update_maps(self):
for idx in range(0, self.map_tabs.count()):
Expand Down
Loading

0 comments on commit 7606a01

Please sign in to comment.