How to autoscale the Y-axis if line hidden on Legend

Trying a simple script to plot line charts. Y-axis is integer, X-axis is a time.Multiple lines could be displayed on chart at the same time. I can hide a line by clicking corresponding option on Legend.
Let say, the range of line A values is 0…3000 and for line B it is 0…15. When line A is hidden, the line B can be barely distinguished. Is it possible to automatically re-scale the Y-axis in this case from 0…3000 to 0…15 range?
ChatGPT suggests approaches using LegendClick or CustomJS that should be triggered on legend_click event, but both options not available neither in 3.1 Bokeh version, nor in 3.7…
The piece of suggested code:

# JavaScript callback to rescale the y-axis when legend items are clicked
js_callback = """
    var lines = cb_obj;
    var all_visible_y_values = [];

    // Find all visible lines and their y-values
    for (var i = 0; i < lines.length; i++) {
        if (lines[i].visible) {
            var y_values = lines[i].data_source.data['value'];
            all_visible_y_values = all_visible_y_values.concat(y_values);
        }
    }

    // Update y-range based on visible lines
    if (all_visible_y_values.length > 0) {
        var min_value = Math.min.apply(null, all_visible_y_values);
        var max_value = Math.max.apply(null, all_visible_y_values);

        // Set y-range with some padding
        p.y_range.start = min_value - 100;
        p.y_range.end = max_value + 100;
    }
"""

# Create a JavaScript callback for the legend to dynamically rescale the chart
callback = CustomJS(args=dict(lines=list(lines.values()), p=p), code=js_callback)
p.js_on_event('legend_click', callback)  # Use legend_click event

Please see this discussion, re: only_visible

Hi @Bryan Thanks for suggestion. No matter how I create a Button, even if it just

button = Button(label='change line')
show(column(p, button))

with no additional settings I always get microscopic broken chart in top left corner of web-page (both in Chrome and Fiferox):

@AlexGoody please provide a complete script of what your have tried so that I can actually try running it myself.

Data is pretty much simplified, but that not important since issue reproduced

import random
import sys

import pandas as pd
from bokeh import events
from bokeh.io import output_file
from bokeh.models import HoverTool, Line, ColumnDataSource, TapTool, CustomJS, CrosshairTool
from bokeh.palettes import Turbo256
from bokeh.plotting import figure, show, curdoc

OUTPUT_HTML_FILE = sys.argv[1] if len(sys.argv) > 1 else 'chart.html'
if not OUTPUT_HTML_FILE.endswith('.html'):
    OUTPUT_HTML_FILE += '.html'

data = [{'time': 1738947484.271536, 'value': 151, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.29157, 'value': 151, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.31152, 'value': 153, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.332541, 'value': 154, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.351486, 'value': 154, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.372012, 'value': 155, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.391883, 'value': 155, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.41132, 'value': 155, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.431446, 'value': 156, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.450649, 'value': 157, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.471536, 'value': 157, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.490712, 'value': 158, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.51077, 'value': 158, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.531372, 'value': 158, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.550755, 'value': 159, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.571007, 'value': 159, 'type': 'vs', 'name': 'Parameter1'}, 
{'time': 1738947484.591335, 'value': 161, 'type': 'vs', 'name': 'Parameter1'}]

if not data:
    sys.exit()

# Convert the data into a DataFrame
df = pd.DataFrame(data)

# Convert the 'time' from EpochTime to datetime
df['time'] = pd.to_datetime(df['time'], unit='s')

# Set up the output file (HTML)
output_file(OUTPUT_HTML_FILE)

# Create a color palette using Turbo256 for as many lines as you need
list_Turbo256 = list(Turbo256)
random.shuffle(list_Turbo256)
colors = list_Turbo256[:len(df['name'].unique())]  # Slice the first N colors from Turbo256

# Create a figure for the plot
curdoc().theme = 'caliber'
p = figure(title="Values Changes Over Time",
           x_axis_label='Time', y_axis_label='Value', x_axis_type='datetime',
           sizing_mode='stretch_both', width=800, height=600, tools="pan,wheel_zoom,box_zoom,reset,save")
p.border_fill_color = "whitesmoke"
p.min_border_left = 10
p.outline_line_width = 2
p.outline_line_alpha = 0.3
p.outline_line_color = "navy"

# Add a line for each unique parameter 'name' with a unique color
sources = []
lines = []
for name, color in zip(df['name'].unique(), colors):
    # Filter the data for each parameter
    parameter_data = df[df['name'] == name]
    if not len(parameter_data.values):
        continue
    source = ColumnDataSource(parameter_data)
    sources.append(source)  # Save for callback
    # Add line
    p.circle(x='time', y='value', source=source, size=4.9, color=color, alpha=0, hover_alpha=0)
    renderer = p.line(x='time', y='value', source=source, legend_label=name,
                      line_width=5, line_color=color, line_alpha=0.4, hover_alpha=0.7)
    lines.append(renderer)

    # Set selection and non-selection styles
    renderer.selection_glyph = Line(line_width=7, line_color=color, line_alpha=1)
    renderer.nonselection_glyph = Line(line_width=3, line_color=color, line_alpha=0.2)
    renderer.glyph.line_join = "round"  # Makes thin lines look better
    renderer.glyph.line_cap = "round"

# Add hover tool to display time and value
hover = HoverTool(tooltips=[("Time", "@time{%H:%M:%S}"), ("Value", "@value"), ("Name", "@name")],
                  formatters={"@time": "datetime"})
p.add_tools(hover, TapTool())  # Add TapTool for selection
hover.renderers = lines

# Add CrosshairTool
crosshair = CrosshairTool()
p.add_tools(crosshair)

# Make only Hover inspect tool enabled by default, but not Crosshair
p.toolbar.active_inspect = [hover]

# JavaScript Callback to switch hover mode based on crosshair activation
callback = CustomJS(args=dict(hover=hover, crosshair=crosshair, p=p), code="""
    // Check if crosshair tool is active
    let isActive = crosshair.active;
    console.log("Crosshair active:", isActive);

    if (isActive) {
        hover.mode = 'vline';  // Enable vline mode
    } else {
        hover.mode = 'mouse';  // Default back to mouse mode
    }
    p.change.emit();
""")

# Attach callback to figure tool change event
p.js_on_event(events.Tap, callback)

# Customize the legend
p.legend.location = "top_right"
p.legend.click_policy = "hide"  # Click to hide/show lines
p.legend.border_line_alpha = 1
p.legend.background_fill_alpha = 1
p.legend.background_fill_color = "whitesmoke"

# JavaScript Callback for Auto-Scaling on Selection
callback = CustomJS(args=dict(p=p, sources=sources), code="""
    console.log("Selection triggered!");  // Check if the callback runs

    let x_min = Infinity, x_max = -Infinity, y_min = Infinity, y_max = -Infinity;
    let found_selection = false;

    // Loop through each data source
    sources.forEach((source, index) => {
        const selected_indices = source.selected.indices;
        console.log(`Source ${index} selected indices:`, selected_indices);  // Log selected indices

        if (selected_indices.length > 0) {
            found_selection = true;  // A line has been selected

            let data = source.data;
            console.log(`Data for Source ${index}:`, data);  // Log entire dataset

            x_min = Math.min(x_min, ...data['time']);
            x_max = Math.max(x_max, ...data['time']);
            y_min = Math.min(y_min, ...data['value']);
            y_max = Math.max(y_max, ...data['value']);

            console.log(`New Ranges for Source ${index}:`);
            console.log(`x_min: ${x_min}, x_max: ${x_max}`);
            console.log(`y_min: ${y_min}, y_max: ${y_max}`);
        }
    });

    // If a line is selected, update the plot range
    if (found_selection) {
        console.log("Updating ranges...");
        p.x_range.start = x_min;
        p.x_range.end = x_max;
        p.y_range.start = y_min - 5;  // Add some padding
        p.y_range.end = y_max + 5;
        p.change.emit();  // 🚀 This forces Bokeh to redraw
    } else {
        console.log("No selection detected.");
    }
""")

# Attach the callback to selection event
for source in sources:
    source.selected.js_on_change("indices", callback)

# Show the plot
# show(p)

from bokeh.models import ColumnDataSource, CustomJS, Button
from bokeh.plotting import figure, column, show
button = Button(label='change line')

show(column(p, button))

@AlexGoody My suggestion was to follow the post I linked and let the detault auto-ranges do all this work, with only_visible set to True. It does not appear that you have tried that approach? I don’t think you would need any CustomJS at all that way. Please give the approach in the post I linked a try.

Oh… I misunderstood your point. I thought I need to apply the exact solution with the button… Do I need to simply add

p.x_range.only_visible = True
p.y_range.only_visible = True

to script? To some specific place? Cause if to add it simply after p definition then it doesn’t work

Cause if to add it simply after p definition then it doesn’t work

That’s because you add a circle glyph with alpha=0 that is not controlled by the legend, so never gets visible set to false. The only_visible property of data ranges corresponds to the visible property of glyph renderers. If a glyph’s visible property is True then data-ranges will continue to factor it in (regardless of the alpha value, .e.g.)

Below is a much shorter working example. @AlexGoody please strive to make Minimal Reproducers be mimimal. All the extra code for theming, styling, palette shuffling, hover tools, and selection is completely irrelevant to this question. Extra junk like that makes it more work for OSS maintainers to help you in a timely manner.

Note that I had to change some of the data to be “Parameter2” so that there were actually multiple lines to test this with.

import pandas as pd
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show

data = [{'time': 1738947484.271536, 'value': 151, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.29157, 'value': 151, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.31152, 'value': 153, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.332541, 'value': 154, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.351486, 'value': 154, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.372012, 'value': 155, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.391883, 'value': 155, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.41132, 'value': 155, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.431446, 'value': 156, 'type': 'vs', 'name': 'Parameter1'},
{'time': 1738947484.450649, 'value': 157, 'type': 'vs', 'name': 'Parameter2'},
{'time': 1738947484.471536, 'value': 157, 'type': 'vs', 'name': 'Parameter2'},
{'time': 1738947484.490712, 'value': 158, 'type': 'vs', 'name': 'Parameter2'},
{'time': 1738947484.51077, 'value': 158, 'type': 'vs', 'name': 'Parameter2'},
{'time': 1738947484.531372, 'value': 158, 'type': 'vs', 'name': 'Parameter2'},
{'time': 1738947484.550755, 'value': 159, 'type': 'vs', 'name': 'Parameter2'},
{'time': 1738947484.571007, 'value': 159, 'type': 'vs', 'name': 'Parameter2'},
{'time': 1738947484.591335, 'value': 161, 'type': 'vs', 'name': 'Parameter2'}]

df = pd.DataFrame(data)
df['time'] = pd.to_datetime(df['time'], unit='s')

colors = ["red", "blue"]

p = figure(title="Values Changes Over Time",
           x_axis_label='Time', y_axis_label='Value', x_axis_type='datetime',
           sizing_mode='stretch_both', tools="")
p.x_range.only_visible = True
p.y_range.only_visible = True

for name, color in zip(df['name'].unique(), colors):
    parameter_data = df[df['name'] == name]
    source = ColumnDataSource(parameter_data)
    p.line(x='time', y='value', source=source, legend_label=name,
           line_width=5, line_color=color, line_alpha=0.4, hover_alpha=0.7)

p.legend.location = "top_right"
p.legend.click_policy = "hide"

show(p)

ScreenFlow

And if you need that “invisible” (i.e. alpha=0) circle, for hit testing or whatever, then you can add it to the legend:

p.circle(..., legend_label=name)

It won’t “show up” there since it has alpha=0 but it will actually get its visible property updated by the interactive legend in a way that the data ranges will respond to.

@Brian added the complete script cause was not sure what exactly can affect… :man_shrugging: sorry for that :slight_smile:
Now it’s working. Thank you!

1 Like