lucky1928
lucky1928

Reputation: 8849

add line to plot for annotation

Below is a demo where I am trying to add a line annotation with vertical short lines at each end of the line. currently, the angle between the annotation line and the vertical short lines are not 90 degrees.

import numpy as np
import plotly.graph_objects as go
import math

def plot(x,y,z):
    fig = go.Figure()    
    trace1 = go.Scatter(
        x=x, y=y,
        mode='markers',
        name='markers')
    
    fig.add_traces([trace1])
    
    tickvals = [0,np.pi/2,np.pi,np.pi*3/2,2*np.pi]
    ticktext = ["0","$\\frac{\pi}{2}$","$\pi$","$\\frac{3\pi}{4}$","$2\pi$"]
    layout = dict(
        title="demo",
        xaxis_title="X",
        yaxis_title="Y",
        title_x=0.5,   
        margin=dict(l=10,t=20,r=10,b=40),
        height=300,
        xaxis=dict(
            side='bottom',
            linecolor='black',
            tickangle=0,
            tickvals = tickvals,
            ticktext=ticktext,
            #tickmode='auto',
            ticks='outside',
            ticklen=7,
            tickwidth=1,
            tickcolor='black',
            tickson="labels",
            title=dict(standoff=5),
            showticklabels=True,
        ),
        yaxis=dict(
            showgrid=True,
            zeroline=True,
            zerolinewidth=.5,
            zerolinecolor='black',
            showline=True,
            linecolor='black',            
            showticklabels=True,
        ),
    )
    
    add_line(fig,math.pi/2,1,math.pi*3/2,-1,'hello')
    
    fig.update_traces(
        marker_size=5,
    )    
    fig.update_layout(layout)
    
    fig.show()
    return

def add_line(fig,x0,y0,x1,y1,text):
    w = .4
    width = 1
    color='red'
    angle = math.atan2(y1-y0,x1-x0)
    x01 = x0 - w*math.sin(angle) # x0 outside
    y01 = y0 + w*math.cos(angle)
    x02 = x0 - w*math.sin(angle)/2 # x0 outside middle
    y02 = y0 + w*math.cos(angle)/2
    
    x11 = x1 - w*math.sin(angle) # x1 outside
    y11 = y1 + w*math.cos(angle)
    x12 = x1 - w*math.sin(angle)/2 # x1 outside middle
    y12 = y1 + w*math.cos(angle)/2

    x2 = x01 + (x11-x01)/2 # text position
    y2 = y01 + (y11 - y01)/2
    #fig.add_shape(type="line",x0=x02,y0=y02,x1=x12,y1=y12,line=dict(color="red",width=2))
        
    fig.update_layout(
        annotations=[
            dict( # annotation line
                x=x02,y=y02,ax=x12,ay=y12,
                xref='x',yref='y',axref='x',ayref='y',
                showarrow=True,arrowhead=3,arrowside='start+end',arrowsize=2,arrowwidth=width,arrowcolor=color
            ),
            dict( # start 
                x=x1,y=y1,ax=x11,ay=y11,
                xref='x',yref='y',axref='x',ayref='y',
                showarrow=True, text='',arrowwidth=width,arrowcolor=color,               
            ),
            dict( #end
                x=x0,y=y0,ax=x01,ay=y01,
                xref='x',yref='y',axref='x',ayref='y',
                showarrow=True,text='',arrowwidth=width,arrowcolor=color,                             
            ),
            dict( # text label
                x=x2, y=y2,
                text=text,textangle=-angle/math.pi*180,font=dict(color=color,size=12),
                bgcolor='white',
                showarrow=False,arrowhead=1,arrowwidth=2,
            ),            
        ],
    )
    return


n = 100
x = np.linspace(0.0, 2*np.pi, n)
y = np.sin(x)
z = np.random.randint(0,100,size=n)

plot(x,y,z)

Output: enter image description here

Upvotes: 2

Views: 262

Answers (1)

Derek O
Derek O

Reputation: 19565

This is happening because the dimensions of the figure (height x width) and the x-axis and y-axis ranges are not giving you a 1-1 aspect ratio. Consider the following simplified example:

If we plot a line between (0,0) and (2,2), and another line between (2,0) and (0,2), we should expect them to be at a 90 degree angle. However, without adjusting the x-axis and y-axis ranges or the width and height of the plot, we get a figure where the angles don't appear to be 90 because of the aspect ratio isn't 1-1.

fig2 = go.Figure()
fig2.add_trace(go.Scatter(x=[0,2],y=[0,2]))
fig2.add_trace(go.Scatter(x=[2,0],y=[0,2]))

enter image description here

To fix this, we can set the width and height of the figure to be the same, and also pick ranges for the x-axis and y-axis that cover the same distance in the x- and y-coordinates.

fig2.update_layout(width=400, height=400, xaxis_range=[-1,3], yaxis_range=[-1,3])

enter image description here

However, to do the same thing for your example is more difficult because although we can set the width and height to be the same, we can't hardcode the x- and y-ranges so we'll need to be more clever.

To guarantee the range of your x and y-axes are the same, take the difference between the two ranges, and then add half of that difference to either side of the smaller range. For example, if plotly creates a figure and the default range of the data is [0,1] on the x-axis, and [0,2] on the y-axis, the xaxis range has a distance of 1 and the yaxis range has a distance of 2, so the aspect ratio isn't 1. So we take the difference: 2-1 = 1, then add half of that to both sides of the smaller range: the xaxis range of [0,1] becomes [-0.5,1.5].

The only problem is that plotly doesn't tell you the default range – so it's useful to know that the x and y-axis default ranges for plotly are calculated by (min - range/16, max + range/16) where range = max - min of the data points. So what we can do is figure out what the default ranges for your data will be according to the plotly defaults, and force the smaller default range to be equal to the larger one.

I've modified your code accordingly (and checked that your math is correct and the lines for your annotations are perpendicular). And I disabled the AutoScale button because that will revert the x and y-axes to the plotly defaults which will undo all of our work to make the aspect ratio 1-1.

import numpy as np
import plotly.graph_objects as go
import math

def plot(x,y,z):
    fig = go.Figure()    
    trace1 = go.Scatter(
        x=x, y=y,
        mode='markers',
        name='markers')
    
    fig.add_traces([trace1])
    
    xtickvals = [0,np.pi/2,np.pi,np.pi*3/2,2*np.pi]

    ## determine the plotly default tick range on both the x and y-axes
    ## make the y-axis tick range and x-axis tick range the same distance
    ytick_range_padding = (max(y) - min(y))/16
    ytick_range_min, ytick_range_max = min(y) - ytick_range_padding, max(y) + ytick_range_padding
    ytick_range = ytick_range_max - ytick_range_min

    xtick_range_padding = (xtickvals[-1] - xtickvals[0])/16 
    xtick_range_min, xtick_range_max = xtickvals[0] - xtick_range_padding, xtickvals[-1] + xtick_range_padding
    xtick_range = xtick_range_max - xtick_range_min
    
    ## if the xtick range is larger than ytick range
    ## then the new ytick range should be (original ytick min - diff/2, original y tick max + diff/2)
    ## and vice versa if the ytick range is larger than xtick range
    if xtick_range > ytick_range:
        diff = xtick_range - ytick_range
        ytick_range_min = ytick_range_min - diff/2
        ytick_range_max = ytick_range_max + diff/2
    else:
        diff = ytick_range - xtick_range
        xtick_range_min = xtick_range_min - diff/2
        xtick_range_max = xtick_range_max + diff/2
        
    xticktext = ["0","$\\frac{\pi}{2}$","$\pi$","$\\frac{3\pi}{4}$","$2\pi$"]
    layout = dict(
        title="demo",
        xaxis_title="X",
        yaxis_title="Y",
        title_x=0.5,   
        margin=dict(l=10,t=20,r=10,b=40),
        width=300,
        height=300,
        xaxis=dict(
            side='bottom',
            linecolor='black',
            tickangle=0,
            range=[xtick_range_min, xtick_range_max],
            tickvals = xtickvals,
            ticktext=xticktext,
            #tickmode='auto',
            ticks='outside',
            ticklen=7,
            tickwidth=1,
            tickcolor='black',
            tickson="labels",
            title=dict(standoff=5),
            showticklabels=True,
        ),
        yaxis=dict(
            range=[ytick_range_min, ytick_range_max],
            showgrid=True,
            zeroline=True,
            zerolinewidth=.5,
            zerolinecolor='black',
            showline=True,
            linecolor='black',            
            showticklabels=True,
        ),
    )
    
    add_line(fig,math.pi/2,1,math.pi*3/2,-1,'hello')
    
    fig.update_traces(
        marker_size=5,
    )    
    fig.update_layout(layout)
    
    fig.show(config={
        'modeBarButtonsToRemove': ['autoScale']
    })
    return

def add_line(fig,x0,y0,x1,y1,text):
    w = .4
    width = 1
    color='red'
    angle = math.atan2(y1-y0,x1-x0)
    x01 = x0 - w*math.sin(angle) # x0 outside
    y01 = y0 + w*math.cos(angle)
    x02 = x0 - w*math.sin(angle)/2 # x0 outside middle
    y02 = y0 + w*math.cos(angle)/2
    
    x11 = x1 - w*math.sin(angle) # x1 outside
    y11 = y1 + w*math.cos(angle)
    x12 = x1 - w*math.sin(angle)/2 # x1 outside middle
    y12 = y1 + w*math.cos(angle)/2

    x2 = x01 + (x11-x01)/2 # text position
    y2 = y01 + (y11 - y01)/2
    #fig.add_shape(type="line",x0=x02,y0=y02,x1=x12,y1=y12,line=dict(color="red",width=2))

    fig.update_layout(
        annotations=[
            dict( # annotation line
                x=x02,y=y02,ax=x12,ay=y12,
                xref='x',yref='y',axref='x',ayref='y',
                # showarrow=True,arrowhead=3,arrowside='start+end',arrowsize=2,arrowwidth=width,arrowcolor=color
            ),
            dict( # start 
                x=x1,y=y1,ax=x11,ay=y11,
                xref='x',yref='y',axref='x',ayref='y',
                showarrow=True, text='',arrowwidth=width,arrowcolor=color,               
            ),
            dict( #end
                x=x0,y=y0,ax=x01,ay=y01,
                xref='x',yref='y',axref='x',ayref='y',
                showarrow=True,text='',arrowwidth=width,arrowcolor=color,                             
            ),
            dict( # text label
                x=x2, y=y2,
                text=text,textangle=-angle/math.pi*180,font=dict(color=color,size=12),
                bgcolor='white',
                showarrow=False,arrowhead=1,arrowwidth=2,
            ),            
        ],
    )
    return


n = 100
x = np.linspace(0.0, 2*np.pi, n)
y = np.sin(x)
z = np.random.randint(0,100,size=n)

plot(x,y,z)

enter image description here

Upvotes: 1

Related Questions