Saeed
Saeed

Reputation: 718

Observing the value and percentage change on a line graph like what Google does

If you search Apple stock on Google you will be taken to this page. On the chart, you can left-click and hold it and move to the right or left. If you do so, you get the change of percentage as well as value change which is shown to you as you move around.

Is it possible to create the above capability exactly as described in Python? I tried with the Plotly package, but I could not do it.

I want to do it on the following graph:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(0)
x = np.random.randn(2000)
y = np.cumsum(x)
df = pd.DataFrame(y, columns=['value'])
fig, ax = plt.subplots(figsize=(20, 4))
df['value'].plot(ax=ax)
plt.show()

In the comment section below, Joseph suggested using PyQtgraph pool of examples, but this is my first time using this package and I am not sure how to do it.

Upvotes: 3

Views: 305

Answers (1)

simon
simon

Reputation: 5451

Here is a solution that uses matplotlib with mouse events rather than pyqtgraph. It provides the move/click-and-drag behavior of the original stock plot, showing the results in the plot's legend:

  • Move mouse without clicking: show y value at x value.
  • Click and drag: show total and percentage difference between leftmost and rightmost y value in covered x range, along with a corresponding up/down arrow.

It does not plot the vertical lines and shaded areas from the original stock plot though. While this would also be possible (we could use vlines() for drawing lines and follow this article for filling/shading areas), the code would get a bit too long probably.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

def closest_index_for(x: float | None) -> int | None:
    # Return the dataframe's index that is closest to the given x value
    return None if x is None else df.index[abs(df.index - x).argmin()]

def str_move_from(unused, x_current: int, y_current: float) -> str:
    return f"{y_current:.2f} at {x_current})"

def str_drag_from(x_start: int, x_current: int, y_current: float) -> str:
    y_start = df["value"].at[x_start]
    if x_current < x_start:  # Drag to left: swap start and current
        x_start, x_current = x_current, x_start
        y_start, y_current = y_current, y_start
    y_diff = y_current - y_start
    percent = np.nan if y_start == 0 else abs(100 * y_diff / y_start)
    arrow = "" if y_diff == 0 else (" ↑" if y_diff > 0 else " ↓")
    x_range = f"from {x_start} to {x_current}"
    return f"{y_diff:+.2f} ({percent:.2f} %){arrow} {x_range}"

def update_legend(x_start: int | None, x_current: int | None):
    if x_current is not None:  # Are we inside the valid value range?
        y_current = df["value"].at[x_current]
        str_from = str_move_from if x_start is None else str_drag_from
        result_str = str_from(x_start, x_current, y_current)
        leg.texts[0].set_text(f"{line_label}: {result_str}")
        fig.canvas.draw_idle()

class PlotState:
    def __init__(self):
        self.x_start: int | None = None

    def on_press(self, event):
        self.x_start = closest_index_for(event.xdata)

    def on_release(self, event):
        self.x_start = None
        update_legend(self.x_start, closest_index_for(event.xdata))
        
    def on_move(self, event):
        update_legend(self.x_start, closest_index_for(event.xdata))
        
if __name__ == "__main__":
    # Initialize data (same as in the question)
    np.random.seed(0)
    df = pd.DataFrame(np.cumsum(np.random.randn(2000)), columns=["value"])
    # Initialize plotting (as in the question, plus legend)
    fig, ax = plt.subplots(figsize=(20, 4))
    df["value"].plot(ax=ax)
    leg = plt.legend()
    line_label = leg.texts[0].get_text()
    # Initialize event handling
    state = PlotState()
    fig.canvas.mpl_connect("button_press_event", state.on_press)
    fig.canvas.mpl_connect("button_release_event", state.on_release)
    fig.canvas.mpl_connect("motion_notify_event", state.on_move)
    
    plt.show()

Some notes on the code:

  • With closest_index_for(), we determine the index in our data frame that is closest to our event's x value. The result will be None if the event's x value is None, meaning we are outside the plot area.
  • With str_move_from() and str_drag_from() we prepare the result texts for mouse moving and click-and-drag, respectively. Some essential steps in str_drag_from():
    • We need to capture a potential division-by-zero error in the percentage change calculation (percent = np.nan if y_start == 0 ...).
    • We need to make sure that the difference and percentage change are always calculated based on the leftmost x value (if x_current < x_start ...).
  • With update_legend(), we update the legend with the determined result text and redraw the canvas. The text depends on whether we move or click and drag (... if x_start is None else ...).
  • With the PlotState class, we keep track of whether we move the mouse only (self.x_start is None) or whether we click and drag instead, and provide the corresponding event handlers (the on_*() methods). We could avoid the class by providing a global x_start variable and corresponding handler functions instead, but I think the current solution is a bit cleaner.
  • matplotlib's event handling is initialized by connecting to the necessary events ("button_press_event", "button_release_event", "motion_notify_event") via mpl_connect().
  • Overall, the code in its current form is a bit messy. In particular, I would try to avoid accessing global variables from within functions (such as df, fig, leg, line_label in update_legend()) in an actual application. I tried to find a compromise between brevity and legibility here.

Upvotes: 3

Related Questions