Range-following only for particular glyph

Hi,

I’m streaming a heatmap as image into a figure, and at the same time stream a curve onto it as well. Now, while the auto-following feature works, it uses the bounds of all glyphs combined. So, instead of following just the curve, it follows all extents.

If the image range is huge, it doesn’t make sense to follow the global min/max range, but rather only the curve’s.

Is there a way to change this behavior, so that the auto-follow feature will only follow a particular glyph (the curve in this case)?

I have tried overriding the y-range with the curve’s data on each callback, but this is also not ideal, as I would like to be able to detach form the live-view once in a while to inspect historical data.

Minimal reproducible example:

from bokeh.plotting import figure
from bokeh.models import CDSView, ColumnDataSource, IndexFilter, Button, CustomJS, Div, ColorBar, LogColorMapper, HoverTool, FuncTickFormatter
from bokeh.plotting import figure, output_notebook, save, show, output_file
from bokeh.io import curdoc
from bokeh.layouts import gridplot, column, row
from bokeh import events
from bokeh.models.ranges import DataRange1d, Range1d
from bokeh.events import Event, Reset

import warnings, random, time, json, copy, colorcet
import panel as pn
import pandas as pd
import numpy as np
from engineering_notation import EngNumber
pn.extension()

warnings.filterwarnings('ignore')

# Format ticks
JS_Eng_units = """
\n    var space = ' ';\n    var space2 = '    ';\n\n    // Giga\n    if(tick >= 1e10)\n        return space2 + (tick / 1e9).toFixed(1) + space + 'B';\n    else if(tick >= 1e9)\n        return space2 + (tick / 1e8).toFixed(1) / 10 + space + 'B';\n\n    // Mega\n    else if(tick >= 1e7)\n        return space2 + (tick / 1e6).toFixed(1) + space + 'M';\n    else if(tick >= 1e6)\n        return space2 + (tick / 1e5).toFixed(1) / 10 + space + 'M';\n\n    // Kilo\n    else if(tick >= 1e4)\n        return space2 + (tick / 1e3).toFixed(1) + space + 'k';\n    else if(tick >= 1e3)\n        return space2 + (tick / 1e2).toFixed(1) / 10 + space + 'k';\n\n    // Unit\n    else if(tick >= 1e1)\n        return (tick / 1e0).toFixed(1) + space + '';\n    else\n        return (tick / 1e-1).toFixed(1) / 10 + space + '';  \n    
"""

# Generate fake data
start = pd.datetime.utcnow()-pd.Timedelta('1D')#'2020-11-01'
end = pd.datetime.utcnow()#'2021-01-03'

# Timestamps
a = pd.date_range(start=start, end=end, periods=2).to_frame().resample('5min').last().index-pd.Timedelta('5min')

# Heatmap
df = pd.DataFrame(
    np.random.randint(-10_000,10_000,size=(500, len(a))), 
    columns=a,
    index=np.linspace(-5_000,5_000,500)
).cumsum(axis=1)

# Line
df2= pd.DataFrame({
    'date':a,
    'price':np.random.randint(-100,100,size=(len(a))),
}).set_index('date').sort_index().cumsum()

def callback(event='test'):    
    global i
    i+=1
    patch = dict(
            image=[(0,df.iloc[:,:i].values)],
            x=[(0,df.iloc[:,:i].columns.min())],
            y=[(0,df.iloc[:,:i].index.min())],
            dh=[(0,df.iloc[:,:i].index.max()-df.iloc[:,:i].index.min())],
            dw=[(0,df.iloc[:,:i].columns.max()-df.iloc[:,:i].columns.min())]
        )
    # Update cmap values
    color_mapper.low=df.iloc[:,:i].replace(0,np.nan).quantile(0.70).max()
    color_mapper.high=df.iloc[:,:i].replace(0,np.nan).quantile(0.98).max()
    source.patch(patch)
    
    # update price data
    patch,stream = get_patch_and_stream(source2,df2.iloc[:i],index_col='date',multi_index=False)
    source2.patch(patch)
    source2.stream(stream)
    
    # Update y-range of plot to follow price
    p.y_range.start = df2.price.min()*0.98
    p.y_range.end = df2.price.max()*1.02

def get_patch_and_stream(cds,df,index_col='date',multi_index=False):
    """
    Finds indices of df values in cds, and zips them together for creating a patch for cds.patch(),
    as well as find all the new values and return them for updating the cds with cds.stream()
    """
    df = df.copy()
    if not multi_index:
        # Overlapping data
        overlap = pd.Series(cds.data[index_col])[pd.Series(cds.data[index_col]).isin(pd.Series(df.index.values))]
        # Indieces for patching
        cds_idxs = overlap.index
        # Values for patching
        df_values = df.loc[overlap.values]
        # Patch in form of 
        # patch = {
        #     'key1' : [ (idx0,val0), (idx1,val1) ],
        #     'key2' : [ (idx0,val0), (idx1,val1) ],
        # }
        patch = {key:list(zip(cds_idxs.tolist(),df_values.reset_index()[key].to_list())) for key in df.reset_index().columns}
        stream = df[~df.isin(df_values)].dropna(subset=(df.columns))
        return patch,stream
    # Ignore this - it's better to send the full picture every time. Otherwise a custom bokeh renderer is required.
    # Patches multi-index dataframe for sending only updated image data
    else:
        # Overlapping data
        overlap = pd.Series(cds.data['price_date'])[pd.Series(cds.data['price_date']).isin(pd.Series(df.index.values))]
        # Indices for patching
        cds_idxs = overlap.index
        # Values for patching
        df_values = df.loc[overlap.values]
        # Assign multi-index as column
        tmp = df_values.reset_index(drop=True).assign(price_date=df_values.index)
        # Patch
        patch = {key:list(zip(cds_idxs.tolist(),tmp[key].to_list())) for key in tmp.columns}
        # Stream
        stream = df[~df.isin(df_values)].dropna(subset=(df.columns))
        return patch,stream

# Create CDS for bokeh stream, start with first column
i = 1
source = ColumnDataSource(
    dict(
        image=[df.iloc[:,:i].values],
        x=[df.iloc[:,:i].columns.min()],
        y=[df.iloc[:,:i].index.min()],
        dh=[df.iloc[:,:i].index.max()-df.iloc[:,:i].index.min()],
        dw=[df.iloc[:,:i].columns.max()-df.iloc[:,:i].columns.min()]
    )
)

# create CDSfor bokeh, start with first row (price data)
source2 = ColumnDataSource(df2.iloc[:i])

formatter = FuncTickFormatter(code=JS_Eng_units)

x_range = DataRange1d(range_padding=0.0,follow='end',only_visible=True)
y_range = DataRange1d(range_padding=0.0,follow='end',only_visible=True)

p = figure(x_range=x_range,y_range=y_range,tools="pan,box_zoom,wheel_zoom,reset,crosshair",
           sizing_mode='stretch_width',height=500,active_scroll='wheel_zoom',
           x_axis_type='datetime',y_axis_location="right")

# p.reset_policy = 'event_only'

# Figure out how to change cmap to Fire, as well as how to have a dynamic range
color_mapper = LogColorMapper(
#                                 palette='Inferno256',
                            palette=colorcet.fire,
                            low=df.iloc[:,:i].replace(0,np.nan).quantile(0.75).max(),
                            high=df.iloc[:,:i].replace(0,np.nan).quantile(0.99).max()
                             )

# Image with stream
img = p.image(
    image='image',x='x',y='y',
    dh='dh',dw='dw',
    source=source,color_mapper=color_mapper,visible=True
)

# Custom hovertools
p.add_tools(HoverTool(
    tooltips=[
        ( "date",  "$x{%F %T}"            ),
        ( "price", "$y{"+f"0.00"+" a}" ),
        ( "value", "@image{0,0.00}"      ),
    ],

    formatters={
        '$x'      : 'datetime', # use 'datetime' formatter for 'date' field
    },

    # display a tooltip whenever the cursor is vertically in line with a glyph
#     mode='vline'
))

# Formatting stuff
p.yaxis.formatter = formatter
p.yaxis.axis_label_text_font_style = 'normal'
p.yaxis.axis_label = 'yaxis'
# p.yaxis.axis_label_text_font = 'roboto'
p.yaxis.axis_label_text_font_size = '20px'
color_bar = ColorBar(title='Vol. [USDT]',title_standoff=10,width=25,color_mapper=color_mapper, label_standoff=7,formatter=formatter,location=(0,0))
color_bar.title_text_font_style = 'normal'
p.add_layout(color_bar, 'left')

# Add price data
p.line(x="date", y="price", source=source2, line_width=3,visible=True)

# Add button with callback
button = Button(label="Update Plot")
button.on_click(callback)

layout = column(button,p,sizing_mode='stretch_width')
# curdoc().add_root(layout)

# Use panel to display
pn.Column(layout,sizing_mode='stretch_width').servable()

OK, so I’m sure the bokeh devs won’t like my solution, but it works :slight_smile:

Since the renderer has the option to only consider visible glyphs, you can make the heatmap glyph invisible by setting visible=False.

However, you still want to render it. So, you can just change the render function in the boke.min.js file:

from

render(){this.model.visible&&this._render(),this._has_finished=!0}}

to:

render(){this._render(),this._has_finished=!0}}

This will not consider the glyph for the range calculations, but will still render it.
And since I don’t have glyphs I don’t want to render anyway, I don’t care.

But for the changes to take effect, you need to tell bokeh to load the local files, rather than the CDN.

You can do this by typing:

export BOKEH_RESOURCES='inline' (linux)
$Env:BOKEH_RESOURCES="inline" (windows)

before running your application.

Result:

However, as you can see, it breaks the hover tool. This should be able to be changed somewhere too, I guess.

This is only a workaround and bokeh should probably add functionality to only consider certain glyphs for the range-calculations.

Apparently, this does the trick! :smiley:

# Add price data
price = p.line(x="date", y="price", source=source2, line_width=3,visible=True)

p.x_range = DataRange1d(range_padding=0.0,follow='end',only_visible=True)
p.y_range = DataRange1d(range_padding=0.5,follow='end',only_visible=True,renderers=[price,])
1 Like