Interactive Trend Line as option in Toolbar!

Peek 2025-06-23 02-47
Please note that the OLS and Theil-Sen results refer to the data points between the clicked x0 and x1. The Slope, Angle and Δy and Δx refer to the coordinates of the cliked points.

Bokeh Server:

import numpy as np
from bokeh.plotting import figure, curdoc
from bokeh.models import PointDrawTool, ColumnDataSource, HoverTool, Div, CustomAction, CustomJS
from scipy.stats import linregress, theilslopes
from bokeh.layouts import column

# ---- Generate random time series data ----
np.random.seed(0)
N = 30
x = np.arange(N)
y = np.cumsum(np.random.randn(N)) + 10  # random walk

# ---- Main plot ----
p = figure(width=800, height=500, title='Interactive Slope, OLS & Theil–Sen')
p.xaxis.axis_label = 'Time'
p.yaxis.axis_label = 'Value'
main_line = p.line(x, y, line_width=2, color="#08f", legend_label="Time Series")
p.circle(x, y, size=7, color="#08f", alpha=0.7)

# ---- Trend line for user selection ----
trend_source = ColumnDataSource(data={'x': [5, 20], 'y': [y[5], y[20]]})
trend_points = p.scatter(x='x', y='y', size=10, fill_color='orange', line_color='black', source=trend_source)
trend_line = p.line(x='x', y='y', line_color='orange', line_width=3, source=trend_source)

# Initially hide the interactive elements
trend_points.visible = False
trend_line.visible = False

draw_tool = PointDrawTool(renderers=[trend_points], add=False)
p.add_tools(draw_tool)

p.add_tools(HoverTool(tooltips=[('Time', '$x'), ('Value', '$y')], renderers=[main_line]))
results_div = Div(text="<b>Interactive mode disabled. Click the toggle tool in toolbar to start analysis.</b>", 
                  width=1100, styles={'color': 'black', 'background-color': 'lightgray', 'padding':'8px', 'border-radius':'8px'})

# ---- Custom toolbar action with JavaScript callback ----
toggle_callback = CustomJS(
    args=dict(
        trend_points=trend_points,
        trend_line=trend_line,
        draw_tool=draw_tool,
        results_div=results_div,
        plot=p
    ),
    code="""
    // Toggle visibility
    const currently_visible = trend_points.visible;
    trend_points.visible = !currently_visible;
    trend_line.visible = !currently_visible;
    
    // Toggle active tool
    if (!currently_visible) {
        // Enable interactive mode
        plot.toolbar.active_tap = draw_tool;
        results_div.text = "<b>Interactive mode enabled! Drag the red endpoints to analyze different segments.</b>";
    } else {
        // Disable interactive mode
        plot.toolbar.active_tap = null;
        results_div.text = "<b>Interactive mode disabled. Click the toggle tool in toolbar to start analysis.</b>";
    }
    """
)

# Create custom action for toolbar
toggle_action = CustomAction(
    icon="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cGF0aCBkPSJNNCAyMEwyMCA0IiBzdHJva2U9IiNmZjY2MDAiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=",
    description="Toggle Interactive Mode",
    callback=toggle_callback
)
p.add_tools(toggle_action)

# ---- Div for slope display ----
results_div = Div(text="<b>Interactive mode disabled. Click the toggle tool in toolbar to start analysis.</b>", 
                  width=750, styles={'color': 'black', 'background-color': 'lightgray', 'padding':'8px', 'border-radius':'8px'})

def update_div(attr, old, new):
    # Note: This will work when interactive mode is enabled via JavaScript
    xs = trend_source.data['x']
    ys = trend_source.data['y']
    if len(xs) != 2 or len(ys) != 2:
        results_div.text = "<b>Drag endpoints. Slope, OLS, Theil–Sen will show here.</b>"
        return
    x0, x1 = xs[0], xs[1]
    y0, y1 = ys[0], ys[1]
    idx0, idx1 = int(round(x0)), int(round(x1))
    if idx0 == idx1 or not (0 <= idx0 < N and 0 <= idx1 < N):
        results_div.text = "<b>Select two <i>different</i> points within range.</b>"
        return
    lo, hi = sorted([idx0, idx1])
    x_sel = x[lo:hi+1]
    y_sel = y[lo:hi+1]
    if len(x_sel) > 1:
        ols = linregress(x_sel, y_sel).slope
        ols_pv = linregress(x_sel, y_sel).pvalue
        theil = theilslopes(y_sel, x_sel)[0]
    else:
        ols = np.nan
        theil = np.nan
    dx = x1 - x0
    dy = y1 - y0
    if dx != 0:
        slope = dy / dx
        slope_str = f"Slope = {slope:.3f}"
    else:
        slope_str = "Slope = ∞"
    angle = (180/np.pi) * np.arctan2(dy, dx)
    results_div.text = (
        f"<b>x0 = {x0:.2f}, y0 = {y0:.2f}, x1 = {x1:.2f}, y1 = {y1:.2f}, Δx = {dx:.2f}, Δy = {dy:.2f}, {slope_str}, Angle = {angle:.2f}°<br>"
        f"OLS Slope = {ols:.3f}, OLS p-value = {ols_pv:.5f}, Theil–Sen Slope = {theil:.3f}</b>"
    )

trend_source.on_change('data', update_div)

curdoc().add_root(column(p, results_div))
curdoc().title = "Interactive Timeseries Slope"

Without Server:

import numpy as np
from bokeh.plotting import figure, show, output_file
from bokeh.models import PointDrawTool, ColumnDataSource, HoverTool, Div, CustomAction, CustomJS
from bokeh.layouts import column

# Generate random time series data
np.random.seed(0)
N = 30
x = np.arange(N)
y = np.cumsum(np.random.randn(N)) + 10  # random walk

p = figure(width=800, height=500, title='Interactive Δx, Δy, Slope')
p.xaxis.axis_label = 'Time'
p.yaxis.axis_label = 'Value'
main_line = p.line(x, y, line_width=2, color="#08f")
p.circle(x, y, size=7, color="#08f", alpha=0.7)

trend_source = ColumnDataSource(data={'x': [5, 20], 'y': [y[5], y[20]]})
trend_points = p.scatter(x='x', y='y', size=10, fill_color='orange', line_color='black', source=trend_source)
trend_line = p.line(x='x', y='y', line_color='orange', line_width=3, source=trend_source)

trend_points.visible = False
trend_line.visible = False

draw_tool = PointDrawTool(renderers=[trend_points], add=False)
p.add_tools(draw_tool)
p.add_tools(HoverTool(tooltips=[('Time', '$x'), ('Value', '$y')], renderers=[main_line]))

results_div = Div(
    text="<b>Interactive mode disabled. Click the toggle tool in toolbar to start analysis.</b>",
    width=800, styles={'color': 'black', 'background-color': 'lightgray', 'padding': '8px', 'border-radius': '8px'}
)

# --- JS callback for updating results_div with dx, dy, slope ---
update_callback = CustomJS(
    args=dict(
        trend_source=trend_source,
        results_div=results_div
    ),
    code="""
    const xs = trend_source.data.x, ys = trend_source.data.y;
    if (xs.length != 2 || ys.length != 2) {
        results_div.text = "<b>Drag endpoints. Slope will show here.</b>";
        return;
    }
    let x0 = xs[0], x1 = xs[1], y0 = ys[0], y1 = ys[1];
    let dx = x1 - x0, dy = y1 - y0;
    let slope_str;
    if (dx != 0) {
        let slope = dy / dx;
        slope_str = "Slope = " + slope.toFixed(3);
    } else {
        slope_str = "Slope = ∞";
    }
    results_div.text = `<b>Δx = ${dx.toFixed(2)}, Δy = ${dy.toFixed(2)}, ${slope_str}</b>`;
    """
)
trend_source.js_on_change('data', update_callback)

# --- Toolbar toggle button for interactive mode ---
toggle_callback = CustomJS(
    args=dict(
        trend_points=trend_points,
        trend_line=trend_line,
        draw_tool=draw_tool,
        results_div=results_div,
        plot=p
    ),
    code="""
    const currently_visible = trend_points.visible;
    trend_points.visible = !currently_visible;
    trend_line.visible = !currently_visible;
    if (!currently_visible) {
        plot.toolbar.active_tap = draw_tool;
        results_div.text = "<b>Interactive mode enabled! Drag the orange endpoints to analyze.</b>";
    } else {
        plot.toolbar.active_tap = null;
        results_div.text = "<b>Interactive mode disabled. Click the toggle tool in toolbar to start analysis.</b>";
    }
    """
)
toggle_action = CustomAction(
    icon="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cGF0aCBkPSJNNCAyMEwyMCA0IiBzdHJva2U9IiNmZjY2MDAiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=",
    description="Toggle Interactive Mode",
    callback=toggle_callback
)
p.add_tools(toggle_action)

output_file("minimal_slope_interactive.html")
show(column(p, results_div))