Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ui.plotly plotly_selected event not triggered if many points are selected #3762

Closed
flooxo opened this issue Sep 20, 2024 · 9 comments
Closed

Comments

@flooxo
Copy link

flooxo commented Sep 20, 2024

What are you trying to do?

I want to select points in a Plotly plot in NiceGUI and, based on the selection, convert the selection into a shape and display it in the plot. This is all working so far.

However, I happened to run into the problem that if there are more than 1000 data points plotted in the plot and I select more than 20 points with the lasso select, the plotly_selected event is no longer triggered

Minimal code

import numpy as np
import plotly.graph_objects as go
from nicegui import ui

x_positive = np.arange(1, 51)
y_positive = np.zeros_like(x_positive)

x_negative = np.random.uniform(-10, 0, 1000)
y_negative = np.random.uniform(-10, 0, 1000)

x = np.concatenate([x_positive, x_negative])
y = np.concatenate([y_positive, y_negative])


data = go.Scattergl(
    x=x,
    y=y,
    mode="markers",
    marker=dict(
        size=10,
        opacity=0.6,
    ),
    name="Original Data", 
).to_plotly_json()

layout = go.Layout(
    xaxis=dict(title="X-axis"), 
    yaxis=dict(title="Y-axis"),
    hovermode="closest",
    showlegend=False,
    dragmode="lasso",
).to_plotly_json()

fig = {
    "data": [data],
    "layout": layout,
    "config": {
        "scrollZoom": True,
    },
}


plot = ui.plotly(fig).classes("w-full h-full")

plot.on("plotly_selected", ui.notify)

ui.run(reload=True)

The points are specially created so that it is easier to count how many points have been selected in the positive area

What do you expect to happen?

event is triggered

What happens instead?

no event is triggered

@flooxo
Copy link
Author

flooxo commented Sep 23, 2024

I did a bit more research on the problem and wanted to find out where the event is no longer triggered
When I inject this js code I see in the browser console that the selection event is always recognized correctly but not always for the server.

Could the reason be that there is a message size limitation for the websocket? So that if too many points are selected, the event is too big and gets lost?

 ui.run_javascript("""
     setTimeout(function() {
         var plotElement = document.querySelector('div.js-plotly-plot');
         if (plotElement) {
             plotElement.on('plotly_selected', function(eventData) {
                 console.log('plotly_selected event detected:', eventData);
             });
         } else {
             console.log('Plotly plot element not found.');
         }
     }, 1000);
 """)

@rodja
Copy link
Member

rodja commented Sep 24, 2024

You could try to change the max_http_buffer_size as discussed in #3410 to verify if it's a package size problem.

@falkoschindler
Copy link
Contributor

falkoschindler commented Sep 24, 2024

That's interesting: The "plotly_selected" event arguments have the following structure (here for selecting 2 out of 50 points):

{
    'points': [
        {'data': {'marker': {'opacity': 0.6, 'size': 10}, 'mode': 'markers', 'name': 'Original Data', 'x': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50], 'y': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'type': 'scattergl', 'selectedpoints': [0, 1]}, 'curveNumber': 0, 'pointNumber': 0, 'pointIndex': 0, 'x': 1, 'y': 0},
        {'data': {'marker': {'opacity': 0.6, 'size': 10}, 'mode': 'markers', 'name': 'Original Data', 'x': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50], 'y': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'type': 'scattergl', 'selectedpoints': [0, 1]}, 'curveNumber': 0, 'pointNumber': 1, 'pointIndex': 1, 'x': 2, 'y': 0}
    ],
    'lassoPoints': {'x': [0.40599744789451314, 0.48936622713738853, 1.114632071458954, 1.7398979157805194, 2.1150574223734586, 2.4068481497235226, 2.323479370480647, 2.3651637601020847], 'y': [-0.17037037037037037, -0.05925925925925926, 0.16296296296296298, 0.28888888888888886, 0.2814814814814815, 0.13333333333333333, -0.05925925925925926, -0.07407407407407407]},
    'selections': [{'xref': 'x', 'yref': 'y', 'line': {'width': 1, 'dash': 'dot'}, 'type': 'path', 'path': 'M0.40599744789451314,-0.17037037037037037L0.48936622713738853,-0.05925925925925926L1.114632071458954,0.16296296296296298L1.7398979157805194,0.28888888888888886L2.1150574223734586,0.2814814814814815L2.4068481497235226,0.13333333333333333L2.323479370480647,-0.05925925925925926L2.3651637601020847,-0.07407407407407407Z'}]
}
  • points seems to contain a copy of the original data for each of the selected points.
  • lassoPoints contains the lasso polygon.
  • selections contains some kind of SVG representation.

So the question is, how to get the information about selected points without lots of unnecessary copies of point clouds. Do we need to introduce some kind of args_filter parameter that accepts a JavaScript function like (e) => e.points.map(p => p.pointIndex)?


For reference, a condensed reproduction:

ui.plotly({
    'data': [{
        'x': np.concatenate([np.arange(1, 51), np.random.uniform(-10, 0, 1000)]),
        'y': np.concatenate([np.zeros(50), np.random.uniform(-10, 0, 1000)]),
        'mode': 'markers',
    }],
    'layout': {'dragmode': 'lasso'},
}).on('plotly_selected', lambda e: print(e.args))

@flooxo
Copy link
Author

flooxo commented Sep 24, 2024

You could try to change the max_http_buffer_size as discussed in #3410 to verify if it's a package size problem.

Thanks, that was also my first guess. Setting max_http_buffer_size didn't change anything for me. Maybe someone else can confirm if I did it right

points seems to contain a copy of the original data for each of the selected points.

But it would make sense if the buffer size is the bottleneck, because the problem only occurs for larger amounts of data
Because if I increase the total number of points in the plot, there is a certain level where you can no longer even select a single point

@flooxo
Copy link
Author

flooxo commented Sep 25, 2024

If you add args so that the event becomes “smaller” and contains less data, then it also triggers for large amounts of data.
The only problem is that i have not yet found a way with this method to determine the pointindex of all selectedpoints

plot.on('plotly_selected', lambda e: print(e.args), args:[“lassoPoints”])

@flooxo
Copy link
Author

flooxo commented Oct 6, 2024

I have actually found a reliable solution for the issue:

  1. Client-side event handling with JavaScript:

    • plotly_selected event is handled on the client side
    • only the relevant pointIndex values of the selected points are extracted
    • these indices are sent to the server via a separate POST request in order to avoid the overhead from the plotly event
  2. Server-side processing with FastAPI:

    • FastAPI route receives the POST request with the selected indexes
    • afterwards they can be further processed as required

Note: I'm not very familiar with js, so I'm sure there are simpler/nicer solutions. But it works for me :)


Fully working solution for #3762

import numpy as np
from nicegui import ui, app
from fastapi import Request

# needed for explicit ui context
container = ui.element()

ui.plotly(
    {
        "data": [{
            "x": np.concatenate([np.arange(1, 51), np.random.uniform(-10, 0, 1000)]),
            "y": np.concatenate([np.zeros(50), np.random.uniform(-10, 0, 1000)]),
            "mode": "markers",
            }],
        "layout": {"dragmode": "lasso"},
    }
)


def setup_js():
    ui.run_javascript(
        """
        const plotElement = document.querySelector('div.js-plotly-plot');
        if (plotElement) {
            plotElement.on('plotly_selected', eventData => {
                if (eventData?.points) {
                    const selectedIndices = eventData.points.map(p => p.pointIndex);
                    fetch('/select', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ selectedIndices })
                    });
                }
            });
        } else {
            fetch('/log_error', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ message: 'Plotly plot element not found.' })
            });
        }
        """
    )


# API route that handles the POST request
@app.post("/select")
async def select(request: Request):
    data = await request.json()
    selected_indices = data["selectedIndices"]
    print(f"Selected point indices: {selected_indices}")
    notify_ui(selected_indices)


@app.post("/log_error")
async def log_error(request: Request):
    data = await request.json()
    print(f"Client Error: {data['message']}")


def notify_ui(selected_indices):
    if selected_indices:
        with container:
            ui.notify(f"Selected point indices: {selected_indices}")


app.on_connect(setup_js)

ui.run(reload=True)

@flooxo flooxo closed this as completed Oct 6, 2024
@flooxo
Copy link
Author

flooxo commented Oct 6, 2024

A small addition, as I noticed that it is much easier to trigger custom events:

ui.add_head_html(
    """
    <script>
    document.addEventListener('DOMContentLoaded', function () {
        var plot = document.getElementsByClassName('js-plotly-plot')[0];
        
        plot.on('plotly_selected', function(eventData) {
            var pointIndices = eventData.points.map(function(point) {
                return point.pointIndex;
            });

            emitEvent('custom_point_selection', pointIndices);
        });
    });
    </script>
"""
)

ui.on("custom_point_selection", handle_selected_points)

@falkoschindler
Copy link
Contributor

@flooxo That's a great idea to filter the event data on the client before emitting a custom event to the server. You can even further simplify the code by defining a js_handler for the "plotly_selected" event:

ui.plotly({
    'data': [{
        'x': np.concatenate([np.arange(1, 51), np.random.uniform(-10, 0, 1000)]),
        'y': np.concatenate([np.zeros(50), np.random.uniform(-10, 0, 1000)]),
        'mode': 'markers',
    }],
    'layout': {'dragmode': 'lasso'},
}).on('plotly_selected', js_handler='''
    (event) => emitEvent('custom_points_selected', event.points.map(point => point.pointIndex));
''')

ui.on('custom_points_selected', lambda e: ui.notify(f'Selected {len(e.args)} points'))

@flooxo
Copy link
Author

flooxo commented Oct 6, 2024

@falkoschindler Thanks! I've also come across js_handler, but I couldn't do anything with it. The solution looks pretty nice :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants