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.

3 Likes

Pure greatness !!

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

1 Like