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

First attempt at a click anywhere #5138

Closed
wants to merge 0 commits into from
Closed

Conversation

sleighsoft
Copy link
Contributor

This adds a click anywhere feature as proposed in #2696

This PR is meant as a point of discussion. Feel free to leave feedback.

@sleighsoft
Copy link
Contributor Author

Example which adds a vertical line on clicking anywhere in the chart.

clickdemo

Code of example

import json
from textwrap import dedent as d
import dash
from dash.dependencies import Input, Output, State
import dash_core_components as dcc
import dash_html_components as html
import plotly.graph_objects as go

# Changes this to wherever you built plotlyjs with this change
app = dash.Dash(__name__, assets_folder="/home/dev/plotly.js/build/")

styles = {"pre": {"border": "thin lightgrey solid", "overflowX": "scroll"}}

app.layout = html.Div(
    className="row",
    children=[
        dcc.Graph(
            id="graph",
            className="six columns",
            figure={
                "data": [
                    {
                        "x": [1, 2, 3, 4],
                        "y": [4, 1, 3, 5],
                        "text": ["a", "b", "c", "d"],
                        "customdata": ["c.a", "c.b", "c.c", "c.d"],
                        "name": "Trace 1",
                        "mode": "lines+markers",
                        "marker": {"size": 12},
                    },
                ],
                "layout": {
                    "clickmode": "event+anywhere"
                },
            },
        ),
        html.Div(
            className="row",
            children=[
                html.Div(
                    [
                        dcc.Markdown("**Click Data**"),
                        html.Pre(id="click-data", style=styles["pre"]),
                    ],
                    className="three columns",
                ),
            ],
        ),
    ],
)


@app.callback(Output("click-data", "children"), [Input("graph", "clickData")])
def display_click_data(clickData):
    return json.dumps(clickData, indent=2)


@app.callback(
    Output("graph", "figure"), [Input("graph", "clickData")], [State("graph", "figure")]
)
def add_shape(clickData, figure):
    print(figure)
    print(clickData)

    if clickData:
        x = clickData['points'][0]['x']
        shapes = figure["layout"].setdefault("shapes", [])
        shapes.append(
            dict(
                type="line",
                x0=x,
                y0=0,
                x1=x,
                y1=max(figure['data'][0]['y']),
                line=dict(color="RoyalBlue", width=3),
            )
        )

    return figure


if __name__ == "__main__":
    app.run_server(debug=True)

@nicolaskruchten
Copy link
Contributor

Thank you very much for this pull request, it's a very exciting feature :)

We should probably chat about the external API for this before moving too much farther with it...

Some concerns/questions I have:

  1. is it backwards-compatible to reuse the current click event or do we need a new event? I'm thinking about existing Dash code that relies on the fact that the click data is always on a data point and I think we should probably create a new event in Plotly.js and a new prop in dcc.Graph
  2. how does this relate to the existing double-click event? This event already allows double-click not-on-points as far as I know and I'd like some consistency between that existing event and this new functionality if possible
  3. how should this event handle subplots? by this I mean situations like dual-y-axis charts or otherwise-overlapping subplots? What about non-2d-cartesian subplots like ternary subplots etc? What about clicks outside of subplots i.e. in the margins or between subplots? My preference today I think would be that we return a list of "matching subplots" with data coordinates (starting with 2d-cartesian, then maybe eventually expanding to ternary and polar and maybe maps but probably 3d is not a good idea... unclear what to do about stuff like pie charts and parallel categories!)

I'd love to hear your thoughts on the above questions!

@nicolaskruchten
Copy link
Contributor

Re plotly_double_click, callbacks registered against this event currently don't receive any data at all, but I think they could without it being considered a breaking change.

If we created a new event (say plotly_raw_click) that made available some extra info about what's being clicked on, I think we could pass that same structure into plotly_double_click callbacks, and add handling for both to dcc.Graph.

Note that plotly_double_click doesn't fire when the user double-clicks outside of the plotting area: https://codepen.io/plotly/pen/WoZOdq?editors=0010

@sleighsoft
Copy link
Contributor Author

sleighsoft commented Sep 14, 2020

  1. Currently it will provide only the click data when you set event+anywhere. If set, and you click on a data point it will provide exactly the same data as before. So, in that regard, it should be backwards compatible. I tried my best to make it so.
  2. I have not played around with double click yet. I will look into that in the near future.
  3. I am not familiar with subplots. Any help in that regard is welcome. If you have example code for these scenarios, please share with me. I do not have the time to code them all.

@nicolaskruchten
Copy link
Contributor

Re 1, that's a nice solution, I hadn't seen that, thanks :)

@alexcjohnson can you provide a hint re 3?

@nicolaskruchten
Copy link
Contributor

For point 3 I believe our discussion landed on something like "for the click point, generate a list of the x/y coordinates for the intersection of every x/y pair on the plot, and then filter than by some internal data structure that tracks the mapping of traces-to-x/y-pairs"?

@sleighsoft
Copy link
Contributor Author

Regarding 1) there may still be a validation issue when creating a figure from a JSON or something. I stumbled into that once and have to dig it up again.

@alexcjohnson
Copy link
Collaborator

re subplots: the code fx/click that you're working in here already gives you one subplot in the function args. That already indicates that we're looking just at clicks that are actually over a subplot and not in the margins or gaps between subplots. That might be OK, and in fact could be nice in as far as it avoids us having to figure out whether we're over a subplot or not, even if eventually we extend this functionality to include the margins and gaps.

The subplot arg is a string (or undefined for certain subplot types). If it's a 2D cartesian subplot the string will be like 'xy' or 'x3y5'. You can then look in gd._fullLayout._plots[subplot] and get a whole bunch of useful info about this subplot. Included in that information are the xaxis and yaxis objects for this subplot (so for example gd._fullLayout._plots.x2y2.xaxis === gd._fullLayout.yaxis2) as well as overlays, which is a list of other subplot info objects that are overlaid upon this one (each with its own xaxis and yaxis objects). From this info you should be able to calculate the data positions of the click point with respect to each subplot / axis.

I'm all in favor of starting by ignoring non-cartesian subplots for now, and coming back to these later.

@sleighsoft you commented in #2696:

There is a small offset when getting the coordinates. Any ideas why this happens?

You should use the bounding box of evt.target and the _length and _offset of the relevant gd._fullLayout axes to calculate the data values, like we do here:

var dbb = evt.target.getBoundingClientRect();
xpx = evt.clientX - dbb.left;
ypx = evt.clientY - dbb.top;
// in case hover was called from mouseout into hovertext,
// it's possible you're not actually over the plot anymore
if(xpx < 0 || xpx > xaArray[0]._length || ypx < 0 || ypx > yaArray[0]._length) {
return dragElement.unhoverRaw(gd, evt);
}

That will deal with positioning on the page as well as automargins.

Also I notice you're using p2c to convert to data values. Better to use p2d at least for date axes - p2c will give you a number (milliseconds from 1970) whereas p2d will give a date string. category axes may still need p2c because category values are discrete so information is lost (though maybe that's the behavior we want in this case? perhaps not so clear cut.)

@nicolaskruchten
Copy link
Contributor

Yes, once a new flaglist attribute is added to the schema, Plotly.py's graph_objects module needs to be regenerated so its validators accept the new value. I do this on a regular basis (for every release) but you can do this in your local development environment by following the instructions here: https://github.com/plotly/plotly.py/blob/master/contributing.md#update-to-a-new-version-of-plotlyjs ... you should be able to use --devbranch and --devrepo to rebuild your local copy from your repo/branch :)

@sleighsoft
Copy link
Contributor Author

Offset has already been fixed as can be seen in the gif

@sleighsoft
Copy link
Contributor Author

@alexcjohnson so I have to switch between p2c and p2d depending on axes type?

if(data) {
if(annotationsDone && annotationsDone.then) {
// TODO(j) add gd._hoverdata to emitClick here
annotationsDone.then(emitClick);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do I pass data here. Not too familiar with JavaScript syntax

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
annotationsDone.then(emitClick);
annotationsDone.then(function() { emitClick(data); });

@sleighsoft
Copy link
Contributor Author

Not sure why the two tests fail. Help welcome:)

@alexcjohnson
Copy link
Collaborator

Offset has already been fixed as can be seen in the gif

I think what you have will still have problems if (a) something increases the margins beyond what's in layout.margins - tick labels, legend, colorbar... (b) the axes don't have the default domain: [0, 1]. Also if the graph in the gif is precisely in the top left corner of the page, there may be problems when it's positioned somewhere else. The target bounding box and _length solution is known to deal with all of these issues, but it's also important to figure out whether the axes we're looking at are actually xaxis and yaxis or if there are others.

so I have to switch between p2c and p2d depending on axes type?

c ("calculated") and d ("data") mean the same thing for numeric (linear and log) axes, and d is better for date axes. The only question is category axes, where d is the category itself and c is the numeric axis position in which the first category is 0, the second is 1, etc. I feel like there are use cases for both of these: if you want to be able to label a data position (or extract data related to the click) then the category (d) is most useful, but if you want to be able to precisely position a click between categories or off the end of the available categories (basically anywhere not exactly on a known category value) you'll need the numeric (c) value. So perhaps we should always report the p2d values as x and y, but for category axes also report the p2c value as perhaps xCalc or yCalc?

@sleighsoft
Copy link
Contributor Author

sleighsoft commented Sep 16, 2020

I like the idea of always reporting the exact position (c value) and if available/makes sense, then also report d values. This would be in line with the idea of click anywhere. Though maybe reporting c and d should be entirely separated and configurable by the user.

I always have my use case in mind, where I want to put a vertical line in a time series, so I may be biased :)

I'll try to update my example code to account for more of the cases discussed above.

@alexcjohnson
Copy link
Collaborator

For date axes I think we should NOT report c values (milliseconds), only d (date strings). We have very confusing behavior around providing date values as milliseconds, and offsetting by the local time zone or not. With date strings the behavior is much clearer. And for numeric axes there's no distinction. So this only applies to category axes. And I don't think it needs to be configurable, we should just report both values on category axes and the user can take whichever they want.

@sleighsoft
Copy link
Contributor Author

Any suggestions on how/where to add tests for this as well as how to run test. I haven't researched that yet.

@alexcjohnson
Copy link
Collaborator

Check out https://github.com/plotly/plotly.js/blob/master/test/jasmine/tests/click_test.js - it gives a lot of relevant example tests, and you can add new tests for this behavior there too. As to running the tests, see https://github.com/plotly/plotly.js/blob/master/CONTRIBUTING.md#jasmine-tests

@sleighsoft
Copy link
Contributor Author

Yes, once a new flaglist attribute is added to the schema, Plotly.py's graph_objects module needs to be regenerated so its validators accept the new value. I do this on a regular basis (for every release) but you can do this in your local development environment by following the instructions here: https://github.com/plotly/plotly.py/blob/master/contributing.md#update-to-a-new-version-of-plotlyjs ... you should be able to use --devbranch and --devrepo to rebuild your local copy from your repo/branch :)

I am not quite sure I can follow the description in the link you provided.
Currently I have:

  • a local version of dash-core-components
  • a local version of plotly.js (where I added anywhere to the list of flags for clickmode)

Do I also have to clone plotly.py? Or do I have to push my changes to plotly.js to a Github branch? I cannot just point it to my local plotly.js directory?

@sleighsoft
Copy link
Contributor Author

Updated demo:

33763390-60f22082-dc21-11e7-972c-038455bcff90

import json
import time
from textwrap import dedent as d
import dash
from dash.dependencies import Input, Output, State
import dash_core_components as dcc
import dash_html_components as html
import plotly.graph_objects as go
import plotly.express as px

app = dash.Dash(__name__, assets_folder="/home/dev/plotly.js/build/")

styles = {"pre": {"border": "thin lightgrey solid", "overflowX": "scroll"}}


def scatter_with_int_xaxis():
    return go.Figure(
        {
            "data": [
                {
                    "x": [1, 2, 3, 4],
                    "y": [4, 1, 3, 5],
                    "mode": "lines+markers",
                    "marker": {"size": 12},
                },
            ],
            "layout": {"clickmode": "event+anywhere"},
        }
    )


def scatter_with_date_xaxis():
    return go.Figure(
        {
            "data": [
                {
                    "x": [time.time() + 60 * i for i in range(4)],
                    "y": [4, 1, 3, 5],
                    "mode": "lines+markers",
                    "marker": {"size": 12},
                },
            ],
            "layout": {
                "clickmode": "event+anywhere",
                "xaxis": dict(type="date", tickmode="auto"),
            },
        }
    )


def bar_chart_with_date_xaxis():
    data_canada = px.data.gapminder().query("country == 'Canada'")
    fig = px.bar(data_canada, x="year", y="pop")
    fig.update_layout(clickmode="event+anywhere")
    return fig


def add_vertical_line_shape(clickData, figure):
    x = clickData["points"][0]["x"]
    shapes = figure["layout"].setdefault("shapes", [])
    shapes.append(
        dict(
            type="line",
            x0=x,
            y0=0,
            x1=x,
            y1=1,
            yref="paper",
            line=dict(color="RoyalBlue", width=3),
        )
    )


app.layout = html.Div(
    className="row",
    children=[
        dcc.Graph(
            id="scatter_with_int_xaxis",
            className="six columns",
            figure=scatter_with_int_xaxis(),
        ),
        html.Div(
            className="row",
            children=[
                html.Div(
                    [
                        dcc.Markdown("**Click Data**"),
                        html.Pre(
                            id="click-scatter_with_int_xaxis", style=styles["pre"]
                        ),
                    ],
                    className="three columns",
                ),
            ],
        ),
        dcc.Graph(
            id="scatter_with_date_xaxis",
            className="six columns",
            figure=scatter_with_date_xaxis(),
        ),
        html.Div(
            className="row",
            children=[
                html.Div(
                    [
                        dcc.Markdown("**Click Data**"),
                        html.Pre(
                            id="click-scatter_with_date_xaxis", style=styles["pre"]
                        ),
                    ],
                    className="three columns",
                ),
            ],
        ),
        dcc.Graph(
            id="bar_chart_with_date_xaxis",
            className="six columns",
            figure=bar_chart_with_date_xaxis(),
        ),
        html.Div(
            className="row",
            children=[
                html.Div(
                    [
                        dcc.Markdown("**Click Data**"),
                        html.Pre(
                            id="click-bar_chart_with_date_xaxis", style=styles["pre"]
                        ),
                    ],
                    className="three columns",
                ),
            ],
        ),
    ],
)


@app.callback(
    Output("click-scatter_with_int_xaxis", "children"),
    [Input("scatter_with_int_xaxis", "clickData")],
)
def display_click_scatter_with_int_xaxis(clickData):
    return json.dumps(clickData, indent=2)


@app.callback(
    Output("scatter_with_int_xaxis", "figure"),
    [Input("scatter_with_int_xaxis", "clickData")],
    [State("scatter_with_int_xaxis", "figure")],
)
def add_shape_to_scatter_with_int_xaxis(clickData, figure):
    print(figure)
    print(clickData)

    if clickData:
        add_vertical_line_shape(clickData, figure)

    return figure


@app.callback(
    Output("click-scatter_with_date_xaxis", "children"),
    [Input("scatter_with_date_xaxis", "clickData")],
)
def display_click_scatter_with_date_xaxis(clickData):
    return json.dumps(clickData, indent=2)


@app.callback(
    Output("scatter_with_date_xaxis", "figure"),
    [Input("scatter_with_date_xaxis", "clickData")],
    [State("scatter_with_date_xaxis", "figure")],
)
def add_shape_to_scatter_with_date_xaxis(clickData, figure):
    print(figure)
    print(clickData)

    if clickData:
        add_vertical_line_shape(clickData, figure)

    return figure


@app.callback(
    Output("click-bar_chart_with_date_xaxis", "children"),
    [Input("bar_chart_with_date_xaxis", "clickData")],
)
def display_click_bar_chart_with_date_xaxis(clickData):
    return json.dumps(clickData, indent=2)


@app.callback(
    Output("bar_chart_with_date_xaxis", "figure"),
    [Input("bar_chart_with_date_xaxis", "clickData")],
    [State("bar_chart_with_date_xaxis", "figure")],
)
def add_shape_to_bar_chart_with_date_xaxis(clickData, figure):
    print(figure)
    print(clickData)

    if clickData:
        add_vertical_line_shape(clickData, figure)

    return figure


if __name__ == "__main__":
    app.run_server(debug=True)

@sleighsoft
Copy link
Contributor Author

I know this is slightly off-topic here, but how would I achieve something like this: https://community.plotly.com/t/moving-shapes-with-mouse-in-plotly-js-reactjs/11457? Again, also willing to add this functionality to plotly if not too difficult.

@archmoj archmoj added status: in progress community community contribution feature something new and removed type: new trace type labels Sep 23, 2020
@nicolaskruchten
Copy link
Contributor

Hi @sleighsoft, I'm sorry I'm so behind on answering your questions!

I am not quite sure I can follow the description in the link you provided.
Currently I have:

a local version of dash-core-components
a local version of plotly.js (where I added anywhere to the list of flags for clickmode)
Do I also have to clone plotly.py? Or do I have to push my changes to plotly.js to a Github branch? I cannot just point it to my local plotly.js directory?

Doing development across all three layers here is pretty tricky, indeed :(

You'll have to push your branch up to Github and wait for the Plotly.js CI to run, then you'll have to clone plotly.py and run the commands in the readme and install that locally, then you'll have to build a Dash app with the plotly.min.js bundle from CI in your assets folder (I don't think you'll need to do anything to dash-core-components in this case)

@sleighsoft
Copy link
Contributor Author

@nicolaskruchten Thank you for getting back to me :) Puh... that sounds like a terrible number of steps to follow. I hope I'll find the time to continue this, though it probably won't be within the next 2-3 weeks.

@ChrisVcX93
Copy link

ChrisVcX93 commented Oct 3, 2021

Screenshot 2021-10-03 at 1 40 58 PM

Sorry I am pretty new to Dash. I am currently doing a project whereby I would like to annotate the figure with a line or circle when the user clicks anywhere on the graph. I tried to use the above code, but however I am only able to annotate where there are points in the graph (refer to above picture). May I know if I am missing anything out ? Thank you for the help in advance !

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

Successfully merging this pull request may close these issues.

5 participants