Reputation: 718
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
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:
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:
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.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()
:
percent = np.nan if y_start == 0 ...
).if x_current < x_start ...
).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 ...
).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()
.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