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()