1

I have created a dummy example below and would like to know the following.

Is there a way to:

  1. Load the graph in the PyQt5 WebEngineView - Done as below code.
  2. If the user interacts with the graph and changes the view angle from the initial.
  3. Call a function which then retrieves the "current camera view" from the fig displayed in the WebEngineView.
  4. Update the figure camera position to the one retrieved in (3).

I have attempted to generate a psudo code of the functionality I would like to add to the code below - I hope this is clear.

If anyone has any experience with this it would be much appreciated. I have little experience with Javascript so a detailed example would be useful.

import sys
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWebEngineWidgets import QWebEngineView
import plotly
import plotly.graph_objs as go
import numpy as np

app = QApplication(sys.argv)

# Helix equation
t = np.linspace(0, 10, 50)
x, y, z = np.cos(t), np.sin(t), t

#PseudoCallbackFunction
#Getcamera properties from the camera in plotly.
#Print the camera properties here within the python console.

fig = go.Figure(data=[go.Scatter3d(x=x, y=y, z=z,
                                   mode='markers')])

raw_html = '<html><head><meta charset="utf-8" />'
raw_html += '<script src="https://cdn.plot.ly/plotly-latest.min.js"></script></head>'
#I anticipate some type of javascript code that extracts the current camera configuration. 
#I found an example here: https://codepen.io/etpinard/pen/YedPjy?editors=0011. 
#The key information I would like signalled/pinged back to my python script is "gd.layout.scene.camera". 
#Perhaps the javascript can send the camera information back to the pseudo callback function above? An similar example here: https://community.plot.ly/t/synchronize-camera-across-3d-subplots/22236/4
raw_html += '<body>'
raw_html += plotly.offline.plot(fig, include_plotlyjs=False, output_type='div')
raw_html += '</body></html>'

view = QWebEngineView()
view.setHtml(raw_html)
view.show()

sys.exit(app.exec_())
R_100
  • 103
  • 1
  • 6
  • Could you explain some details like What is "view angle", "current camera view" and "camera position"? – eyllanesc Jan 03 '20 at 19:01
  • Hi eyllanesc, thanks for the enquiry. The default camera properties are defined as per the following link: https://plot.ly/python/3d-camera-controls/. However I understand the JavaScript in the figure contains any updates to the figure following initialising the default camera angle. I am not sure how to access the updated camera values as in the above weblink. – R_100 Jan 03 '20 at 20:14
  • I have already read that post but I have not found those concepts, could you point me exactly in which sections are defined the concepts that I have required. To help you first I need to understand what you want but you use technicalities that I don't know. – eyllanesc Jan 03 '20 at 20:17
  • Hi @eyllanesc. I tried to be a bit clearer how I would like to extend the functionality of my script with the comments above. I have also included references to some example functions which call the information I require, namely (gd.layout.scene.camera), however I do not know how to signal this information back to my python PyQt script. – R_100 Jan 06 '20 at 09:52
  • Okay, I think I understand you better, you have several plots that handle different cameras and then you want them both to be synchronized, am I correct? Your initial requirement seemed confusing to me since in the 4 steps that you indicate in the third one you should obtain the information of the view but in the fourth you want to establish the same information in the same view which seemed contradictory to me. I wait your answer. – eyllanesc Jan 06 '20 at 15:03

1 Answers1

1

If you want to interact with the elements of the WEB you could use QWebChannel. In the following example, each time the "plotly_relayout" event is triggered, a slot of a QObject will be called to emit a signal. In the same way you can update the data from Python, for example when you press the Python button it will update the camera using the animation.

import json
import math
import sys

from PyQt5.QtCore import (
    pyqtSignal,
    pyqtSlot,
    QFile,
    QIODevice,
    QObject,
    QVariant,
    QVariantAnimation,
)
from PyQt5.QtWidgets import QApplication, QPushButton, QVBoxLayout, QWidget
from PyQt5.QtWebEngineWidgets import QWebEngineProfile, QWebEngineScript, QWebEngineView
from PyQt5.QtWebChannel import QWebChannel

import plotly
import plotly.graph_objs as go

import numpy as np


class PlotlyHelper(QObject):
    cameraChanged = pyqtSignal(dict)
    updateCamera = pyqtSignal(str)

    def __init__(self, parent=None):
        super().__init__(parent)
        self._div_id = ""
        self._camera = dict()

    @property
    def div_id(self):
        return self._div_id

    @pyqtSlot(str)
    def _on_change_camera(self, data):
        camera = json.loads(data)
        self._camera = camera
        self.cameraChanged.emit(self._camera)

    @pyqtSlot(str)
    def _update_id(self, id_):
        self._div_id = id_

    @property
    def camera(self):
        return self._camera

    @camera.setter
    def camera(self, camera):
        self.updateCamera.emit(json.dumps(camera))


JS = """
var plotly_helper = null;
var gd = null;

function create_connection(){
    plotly_helper._update_id(gd.id)
    gd.on('plotly_relayout', function(){
        plotly_helper._on_change_camera(JSON.stringify(gd.layout.scene.camera));
    })
    plotly_helper.updateCamera.connect(function(message){
        Plotly.relayout(gd, 'scene.camera', JSON.parse(message))
    });
}
// https://stackoverflow.com/a/7559453/6622587
(function wait_element() {
    var elements = document.getElementsByClassName("js-plotly-plot")
    if( elements.length == 1 ) {
        gd = elements[0];
        if(plotly_helper !== null){
            create_connection();
        }
    } else {
        setTimeout( wait_element, 500 );
    }
})();

(function wait_qt() {
    if (typeof qt != 'undefined') {
        new QWebChannel(qt.webChannelTransport, function (channel) {
            plotly_helper = channel.objects.plotly_helper;
            if(gd !== null){
                create_connection();
            }
        });
    } else {
        setTimeout( wait_qt, 500 );
    }
})();
"""


def get_webchannel_source():
    file = QFile(":/qtwebchannel/qwebchannel.js")
    if not file.open(QIODevice.ReadOnly):
        return ""
    content = file.readAll()
    file.close()
    return content.data().decode()


class Widget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.button = QPushButton(
            "Start Animation", checkable=True, clicked=self.on_clicked
        )
        self.view = QWebEngineView()

        lay = QVBoxLayout(self)
        lay.addWidget(self.button)
        lay.addWidget(self.view)

        self.resize(640, 480)

        self.create_plot()

    def create_plot(self):
        t = np.linspace(0, 10, 50)
        x, y, z = np.cos(t), np.sin(t), t
        fig = go.Figure(data=[go.Scatter3d(x=x, y=y, z=z, mode="markers")])

        raw_html = '<html><head><meta charset="utf-8" />'
        raw_html += (
            '<script src="https://cdn.plot.ly/plotly-latest.min.js"></script></head>'
        )
        raw_html += "<body>"
        raw_html += plotly.offline.plot(fig, include_plotlyjs=False, output_type="div")
        raw_html += "</body></html>"

        profile = QWebEngineProfile.defaultProfile()

        script = QWebEngineScript()
        script.setName("create_connection")
        script.setSourceCode(get_webchannel_source() + "\n" + JS)
        script.setInjectionPoint(QWebEngineScript.DocumentReady)
        script.setWorldId(QWebEngineScript.MainWorld)
        script.setRunsOnSubFrames(False)
        profile.scripts().insert(script)

        self.plotly_helper = PlotlyHelper()
        self.plotly_helper.cameraChanged.connect(
            lambda camera: print("[LOG] camera:", camera)
        )

        channel = QWebChannel(self)
        channel.registerObject("plotly_helper", self.plotly_helper)
        self.view.page().setWebChannel(channel)

        self.view.setHtml(raw_html)

        self.animation = QVariantAnimation(
            startValue=0.0,
            endValue=2 * math.pi,
            valueChanged=self.on_value_changed,
            duration=1000,
            loopCount=-1,
        )

    @pyqtSlot(bool)
    def on_clicked(self, checked):
        if checked:
            self.button.setText("Stop Animation")
            self.animation.start()
        else:
            self.button.setText("Start Animation")
            self.animation.stop()

    @pyqtSlot(QVariant)
    def on_value_changed(self, value):
        camera = dict(
            up=dict(x=0, y=math.cos(value), z=math.sin(value)),
            center=dict(x=0, y=0, z=0),
            eye=dict(x=1.25, y=1.25, z=1.25),
        )
        self.plotly_helper.camera = camera


if __name__ == "__main__":

    sys.argv.append("--remote-debugging-port=8000")

    app = QApplication(sys.argv)
    w = Widget()
    w.show()
    sys.exit(app.exec_())
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • Amazing - thank you for your help eyllanesc. This has all the components I needed help with. All the best! – R_100 Jan 06 '20 at 23:00
  • I had marked your answer as correct, however it does not yet register my vote publicly until I reach 15 credits of experience on stackoverflow. Hopefully this should not take too long. Best – R_100 Jan 07 '20 at 20:19