Mouse interactivity within subplots (resize - reorder)

Hi everyone,

Is there a way to interactively resize the elements inside subplots by dragging on the splitters separating them? (preferably client side)

In addition it would be nice to be able to reorder the subplots interactively by dragging them with the mouse. (preferably client side)

Hello @popo,

If possible, could you please provide an MRE to help with designing an interaction?

As MRE I can’t provide much since I don’t know how to add custom mouse interaction on a plotly subplot.

A minimal subplot example may look like this:

import dash
from dash import dcc, html
from plotly.subplots import make_subplots
import plotly.graph_objects as go

fig = make_subplots(rows=2, cols=1)

fig.add_trace(
    go.Scatter(x=[1, 2, 3], y=[4, 5, 6]),
    row=1, col=1
)
fig.add_trace(
    go.Scatter(x=[20, 30, 40], y=[50, 60, 70]),
    row=2, col=1
)
fig.update_layout(height=600, width=800, title_text="MRE Subplots")

app = dash.Dash()
app.layout = html.Div([
    dcc.Graph(figure=fig)
])

app.run_server() 

But the idea is to interact with the subplot layout using the mouse for

Reordening subplots
image

Resize subplots relative to each other.
image

Is there a way to do this? (preferably client side)

When you say resize, do you mean one would get smaller and the other large? Would they need to maintain aspect ratio?

Concerning drag and drop @jhupiterz had something like that in her app for the autumn challenge:

@jinnyzor:
Yes

@AIMPED
Not exactly.
I’m talking specifically about plotly subplots where you can share the x-axis etc.
To do what you mentioned, I use the dash Lumino component or js scripts like dragula.js.
I’m really looking forward for @latshawk s awesome component. it looks very promissing!

To stay on-topic, I’d say it is indeed the same functionality that you are talking about BUT inside a plotly subplot.

1 Like

Hello @popo,

Looking at this, the charts are drawn via svg, so they arent separate elements. Dragging and dropping of these elements would require redrawing the svg which would be a lot of redrawing due to how many things are going on.

I can get different elements to resize with the subsplots, but the issue comes with functionality and scale.

Thanks @jinnyzor.
I expected something along this line.

Alternatively, is it possible to add custom ‘order up/down’ and ‘scale larger/smaller’ buttons on each subplot?
(guess this is still on-topic)

  • Ideally, those buttons would appear when hovering around eg. the right upper corner of each individual subplot.
  • Is it also possible to handle the functionality of those clicks on the client side?
    (this is purely ui stuff so I presume that the server shouldn’t be bothered with this)
  • And, if one ‘would’ want to act on those ui calls, Is it possible to get notifications of those events in Pyhon via callbacks?

… I know, I’m asking a lot…
Thanks in advance.

Hello @popo,

It took me some time, but I finally got something I think you’ll be happy with. :stuck_out_tongue: You can polish it however you want to, this is just a rough showing.

dragging-subplots

All of this is done on the client side, however… You can use the clientside to chain a callback to the server, easily.

Here is the .js file:

function coolEffects() {
    $(".modebar-group:first-of-type").prepend($('<button onclick=toggleEdit(this)>Testing</button>'))
}

var sp;
var oldDom;

function toggleEdit(el) {
    if (!$(el).closest('.dash-graph').hasClass('editing')) {
        plots = []
        jQuery.fn.reverse = [].reverse;
        function SortByTop(a, b){
              var top1 = a.getBoundingClientRect().top;
              var top2 = b.getBoundingClientRect().top;
              return ((top1 < top2) ? -1 : ((top1 > top2) ? 1 : 0));
            }
        $(el).closest('.dash-graph').find('.draglayer > g').sort(SortByTop).each(function (index) {
            plots.push($('<div class="dragula">' + this.classList + '</div>'))
            var parentPos = $(this).parent()[0].getBoundingClientRect(),
            childPos = this.getBoundingClientRect(),
            relativePos = {};

            relativePos.top = childPos.top - parentPos.top,
            relativePos.right = childPos.right - parentPos.right,
            relativePos.bottom = childPos.bottom - parentPos.bottom,
            relativePos.left = childPos.left - parentPos.left;


            $(plots[plots.length -1]).css(
                {'top': relativePos.top,
                'left': relativePos.left,
                'height': childPos.height,
                'width': childPos.width,
            })
        })

        $($(el).closest('.dash-graph').find('div')[0]).append(plots)
        drake = dragula([$(el).closest('.dash-graph').find('div')[0]])
        $('.dragula').each(function(index) { if (index != $('.dragula').length-1) {
            $(this).after($('<div class="divider"><div class="fa fa-bars"></div></div>'))}}
        )
        $(".divider").each(function () {
            dragElement(this)
        })
        drake.on('drop', function() {
            $('.divider').remove()
            $('.dragula').each(function(index) { if (index < $('.dragula').length-2) {
            $(this).after($('<div class="divider"><div class="fa fa-bars"></div></div>'))}})
            $(".divider").each(function () {
                dragElement(this)
            })
        })

    } else {
        $('#swap').click()
    }
    setTimeout(function () {
    $(el).closest('.dash-graph').toggleClass('editing')

    }, 1
    )
}

function dragElement(elmnt) {
  var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
  elmnt.onmousedown = dragMouseDown;

  function dragMouseDown(e) {
    e = e || window.event;
    e.preventDefault();
    // get the mouse cursor position at startup:
    pos4 = e.clientY;
    document.onmouseup = closeDragElement;
    // call a function whenever the cursor moves:
    document.onmousemove = elementDrag;
  }

  function elementDrag(e) {
    e = e || window.event;
    e.preventDefault();
    pos2 = pos4 - e.clientY;
    pos4 = e.clientY;
    $(elmnt).prev()[0].style.height = parseFloat($(elmnt).prev()[0].style.height) - pos2 + 'px'
    $(elmnt).next()[0].style.height = parseFloat($(elmnt).next()[0].style.height) + pos2 + 'px'
  }

  function closeDragElement() {
    // stop moving when mouse button is released:
    document.onmouseup = null;
    document.onmousemove = null;
  }
}


window.fetch = new Proxy(window.fetch, {
    apply(fetch, that, args) {
        // Forward function call to the original fetch
        const result = fetch.apply(that, args);

        // Do whatever you want with the resulting Promise
        result.then((response) => {
            if (args[0] == '/_dash-update-component') {
                setTimeout(function() {coolEffects()}, 1000)
            }})
        return result
        }
    }
    )

$(document).ready(function() {
    setTimeout(function() {coolEffects()}, 1000)
})

Here is the app file:

import dash
from dash import dcc, html, Input, Output, State
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import dash_bootstrap_components as dbc
from pprint import pprint

fig = make_subplots(rows=3, cols=1)

fig.add_trace(
    go.Scatter(x=[1, 2, 3], y=[4, 5, 6]),
    row=1, col=1
)

fig.add_trace(
    go.Scatter(x=[100, 200, 300], y=[400, 500, 600]),
    row=3, col=1
)

fig.add_trace(
    go.Scatter(x=[20, 30, 40], y=[50, 60, 70]),
    row=2, col=1
)
fig.update_layout(height=600, width=800, title_text="MRE Subplots")

app = dash.Dash(__name__,
                external_stylesheets = [dbc.themes.BOOTSTRAP, dbc.icons.FONT_AWESOME,
    "https://epsi95.github.io/dash-draggable-css-scipt/dragula.css"],
                external_scripts=[{'src':"https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"},
                                 "https://cdnjs.cloudflare.com/ajax/libs/dragula/3.7.2/dragula.min.js",
                               "https://epsi95.github.io/dash-draggable-css-scipt/script.js"
])

app.layout = html.Div([
    html.Button('swap', id='swap'),
    dcc.Graph(figure=fig, id='myFig')
])

app.clientside_callback(
    """
        function (n, f) {
            newFig = JSON.parse(JSON.stringify(f))
            console.log(f.layout)
            dragEls = $('.dragula')
            
            l = $('.editing .draglayer > g')
            if (!sp) {
                sp = Math.abs(f.layout.yaxis.domain[0] - f.layout.yaxis2.domain[1])
            }
            
            if (dragEls[0]) {
                newLayout = JSON.parse(JSON.stringify(f.layout))
                ht = 0
                try {
                    $('.dragula').each( function () { ht += parseFloat(this.style.height)})
                    ht += sp*(dragEls.length-1)
                } catch {}
                for (y=0; y<dragEls.length; y++) {
                    per = parseFloat(dragEls[y].style.height) / ht
                    
                    if ($(dragEls[y]).text() == 'xy') {
                        ref = ''
                    } else {
                        ref = $(dragEls[y]).text().split('y')[0].split('x')[1]
                    }
                    if (y==0) {
                        newLayout['yaxis'+ref] = f.layout['yaxis'+ref]
                        newLayout['yaxis'+ref].domain = [1-per+(sp/2), 1]
                        oldDom = 1 - per
                    } 
                    else {
                        newLayout['yaxis'+ref] = f.layout['yaxis'+ref]
                        if ((oldDom - per+(sp/2)) < 0) {
                            newLayout['yaxis'+ref].domain = [0, oldDom]
                        } else {
                            newLayout['yaxis'+ref].domain = [oldDom - per+(sp/2), oldDom]
                        }
                        oldDom = oldDom - per
                        
                    }
                }
                newFig.layout = newLayout
                try {
                    setTimeout(function() {
                    drake.destroy()}, 500)
                } catch {}
                $($('.editing').find('div')[0]).empty()
                console.log(newLayout)
            }
            return JSON.parse(JSON.stringify(newFig))
        }
    """,
    Output('myFig','figure'),
    Input("swap", "n_clicks"),
    State('myFig', 'figure'),
    prevent_intial_call=True
)

app.run_server(debug=True)

css file:


.editing .main-svg {
    visibility: hidden;
}

.editing > div:first-child {
    position: absolute;
    height: 100%;
    width: 100%;
    z-index: 1;
}

.editing .modebar-container {
    z-index: 2;
}

.dragula {
    background-color: silver;
    color: white;
    font-size: 20pt;
    font-weight: heavy;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1pt dashed black;
    margin: 2px;
    cursor: grab;
}

.divider {
    box-shadow: inset 0px 0px 5px silver;
    height: 10px;
    width: 100%;
    cursor: grab;
    display: flex;
    align-items: center;
    justify-content: center;
}

Unfortunately, with this style of callback , you wont be able to individually update the figure if needed from the server, other than through controlling all the children of the page, or what not.

4 Likes

Pure greatness !!

I’ll check it out, thanks a lot !!

1 Like

In this case, since no graph shares axes, you could create new plots (Plotly.newPlot(layer,…)) each with its respective layer (layer1, layer2, layer3), in this case, as they are independent plots, each one has the same axis numbering (xy), the layers would obviously be <div> elements which you can arrange in a grid and then use some library that has drag and layer resizing functions, for example jquery

To resize
https://api.jqueryui.com/resizable/

To drag

The situation is complicated when all the graphs are within the same layer and share the same axis, either x or y, we would be talking about subplots where each one has a different numbering of its axis and at the time of creation, several <rect> elements are added to the graph. that serve to interact with the subplot and there would be no way to accommodate them all in a single <div> to manipulate them, you could perhaps add the jquery functionalities to the main <rect> that is within <g class="bglayer"> and use the events of the same jquery library to know when the dragging or resizing ends to delete the old subplot and create a new one with the new dimensions or position. Someday I will share it with you because I also have to implement this functionality in my app but at the moment I am working on other functionalities but more or less I already gave you the idea of how I plan to do it