Creating shapes (annotations) with Patch() method, how to do if no shapes exist in initial fig?

Hi there,

I really like the Patch() method but I came a cross an issue. I wanted to add a shape in a figure which does not have a initial shape in it. Problem here is, that the Patch() method does not work as the shape list in the figure layout does not exist. So I can’t use the list specific methods such as extend or append.

I tried setting an empty shape list in the initial figure but this is getting interpreted as none by the JS so it throws an error.

undefined does not have a method named “concat” or “fantasy-land/concat”

I think this is a general issue with the Patch() method. Maybe someone has an idea how to overcome this.

Here a MRE:

import json
import random
import dash
from dash import html, dcc, Input, Output, Patch
import plotly.graph_objs as go
import numpy as np


def create_shape(x, y, size=2, color='rgba(39,43,48,255)'):
    """
    function creates a shape for a dcc.Graph object

    Parameters:
        x: x coordinate of center point for the shape
        y: y coordinate of center point for the shape
        size: size of annotation (diameter)
        color: (rgba / rgb / hex) string or any other color string recognized by plotly

    Returns:
        a list containing a dictionary, keys corresponding to dcc.Graph layout update
    """
    shape = [
        {
            'editable': True,
            'xref': 'x',
            'yref': 'y',
            'layer': 'above',
            'opacity': 1,
            'line': {
                'color': color,
                'width': 1,
                'dash': 'solid'
            },
            'fillcolor': color,
            'fillrule': 'evenodd',
            'type': 'circle',
            'x0': x - size / 2,
            'y0': y - size / 2,
            'x1': x + size / 2,
            'y1': y + size / 2
        }
    ]
    return shape


def extract_click_coordinates(click_data: dict) -> tuple[int, int]:
    """
    function extracts information from a dictionary

    Parameters:
        click_data: dictionary which is return by the plotly-dash click event on a dcc.Graph

    Returns:
        x, y: coordinates of the click event
    """
    points = click_data.get('points')[0]
    x = points.get('x')
    y = points.get('y')

    return x, y


app = dash.Dash(
    __name__,
)

fig = go.Figure(
    data=go.Heatmap(
        z=np.random.randint(0, 255, size=(30, 30))
    ),
    layout={
        'width': 900,
        'height': 900,
        # 'shapes': create_shape(10, 10, size=2, color='black'),
        'shapes': []
    }
)

app.layout = html.Div(
    [
        dcc.Graph(
            id='graph',
            figure=fig
        ),
        html.Pre(
            id='out',
            children=json.dumps(
                fig.to_dict().get('layout').get('shapes', 'empty'),
                indent=2
            )
        )
    ],
)


@app.callback(
    Output('graph', 'figure'),
    Output('out', 'children'),
    Input('graph', 'clickData'),
    prevent_initial_call=True
)
def show_fig(clickData):
    x, y = extract_click_coordinates(clickData)

    patched = Patch()
    patched['layout']['shapes'].extend(create_shape(x, y, size=2, color='white'))

    return patched, f'just checking... {random.random()}'


if __name__ == '__main__':
    app.run(debug=True)

Hello @AIMPED,

Check this out, with storing the last clickData you can determine whether or not it is the first shape added. This should keep the use of Patch() working well. :slight_smile:

import json
import random
import dash
from dash import html, dcc, Input, Output, Patch, State
import plotly.graph_objs as go
import numpy as np


def create_shape(x, y, size=2, color='rgba(39,43,48,255)'):
    """
    function creates a shape for a dcc.Graph object

    Parameters:
        x: x coordinate of center point for the shape
        y: y coordinate of center point for the shape
        size: size of annotation (diameter)
        color: (rgba / rgb / hex) string or any other color string recognized by plotly

    Returns:
        a list containing a dictionary, keys corresponding to dcc.Graph layout update
    """
    shape = [
        {
            'editable': True,
            'xref': 'x',
            'yref': 'y',
            'layer': 'above',
            'opacity': 1,
            'line': {
                'color': color,
                'width': 1,
                'dash': 'solid'
            },
            'fillcolor': color,
            'fillrule': 'evenodd',
            'type': 'circle',
            'x0': x - size / 2,
            'y0': y - size / 2,
            'x1': x + size / 2,
            'y1': y + size / 2
        }
    ]
    return shape


def extract_click_coordinates(click_data: dict) -> tuple[int, int]:
    """
    function extracts information from a dictionary

    Parameters:
        click_data: dictionary which is return by the plotly-dash click event on a dcc.Graph

    Returns:
        x, y: coordinates of the click event
    """
    points = click_data.get('points')[0]
    x = points.get('x')
    y = points.get('y')

    return x, y


app = dash.Dash(
    __name__,
)

fig = go.Figure(
    data=go.Heatmap(
        z=np.random.randint(0, 255, size=(30, 30))
    ),
    layout={
        'width': 900,
        'height': 900,
        # 'shapes': create_shape(10, 10, size=2, color='black'),
        # 'shapes': []
    }
)

app.layout = html.Div(
    [
        dcc.Graph(
            id='graph',
            figure=fig
        ),
        html.Pre(
            id='out',
            children=json.dumps(
                fig.to_dict().get('layout').get('shapes', 'empty'),
                indent=2
            )
        ),
        dcc.Store(id='oldClickData')
    ],
)


@app.callback(
    Output('graph', 'figure'),
    Output('out', 'children'),
    Output('oldClickData', 'data'),
    Input('graph', 'clickData'),
    State('oldClickData', 'data'),
    prevent_initial_call=True
)
def show_fig(clickData, oldClickData):
    x, y = extract_click_coordinates(clickData)

    patched = Patch()
    if oldClickData:
        patched['layout']['shapes'].extend(create_shape(x, y, size=2, color='white'))
    else:
        patched['layout']['shapes'] = create_shape(x, y, size=2, color='white')
    return patched, f'just checking... {random.random()}', clickData


if __name__ == '__main__':
    app.run(debug=True)
3 Likes

Ha! Sometimes it’s just that easy, thanks @jinnyzor. I’m 100% sure it took me longer to write the MRE than it took you to solve my problem :rofl:

1 Like