This was a ride.
Note for anybody finding this thread later while looking for a way to something similar I’ve described: This is not an official solution, this is an extremely hacky workaround using some functionality for purposes they weren’t designed for, so it comes without any warranty, and also with some caveats, described at the end. However, it creates the intended result, so there’s that.
- Yes, you can use a custom formatter to export every variable to a global pool.
- Yes, the tooltips get generated in the same order they have been defined, so by the time the last tooltip gets generated, all the previous tooltips ran, so their variables can be already exported.
- Yes, you can use CSS to hide every tooltip except the last one.
The biggest roadblock was for me that you can not use not-existing variables with custom formatters in your final tooltip. Imagine my original example above: we have two images, Population named population_density
and Elevation named elevation
. So my first strategy was to save the population value in a custom formatter of the Population image tooltip, then define the custom tooltips for the Elevation image like this:
custom_tooltips = [
('Population', '@population_density{custom} p/km2'),
('Elevation', '@elevation{0.00}m')
]
import_population_density = """
// Some JS code returning the previously saved population value
"""
custom_formatters = {
'@population_density': bk.models.CustomJSHover(code=import_population_density),
}
The problem is that since the population_density
variable is not available in the Elevation image, the Bokeh engine doesn’t even run the import_population_density
custom formatter I could use to import the value. There could be a workaround for two images if you highjack some other special variables like $x and force their custom formatter to give back the value of the other data, but then you would have a strong limit on the number of images you can overlay, and I needed this to work with any number of images.
Fortunately, instead of defining a list of tuples for the tooltip, you can define the exact HTML code you want to use. This means, that in the Elevation image I can use the always available @image
variable (that holds the current value of elevation) to run a custom formatter that takes all the variables the other tooltips exported, imports them, and creates the full HTML code in Javascript with the added benefit of being able to customize everything. This is a way more involved process, but here it is:
# Step 1: The Population tooltip
# This tooltip will be invisible
# We only use this to export the current population data
# JS custom formatter code that takes the current value at the tooltip location
# and saves it into a global variable
export_tooltip_value = """
var label = "%s";
if (typeof(window['saved_tooltips'])=="undefined")
{
window['saved_tooltips'] = {};
}
window['saved_tooltips'][label] = value;
console.log('export', label, window['saved_tooltips']);
return value;
"""
# Filling in the JS label variable by replacing %s to 'Population'
# So the value will be saved to window['saved_tooltips']['Population']
export_population = export_tooltip_value % 'Population'
# Making sure the default dataset of the image will be displayed in this tooltip
# with a custom formatter
custom_population_tooltip = [
('Population', '@image{custom}'),
]
# Letting Bokeh know to run the custom formatter JS code when displaying this
# tooltip
custom_population_formatters = {
'@image': bk.models.CustomJSHover(code=export_population),
}
# creating a custom hover tool from all the ingredients above
custom_population_hover = bk.models.HoverTool(
tooltips=custom_population_tooltip,
formatters=custom_population_formatters
)
# Step 2: The Elevation tooltip
# This is the tooltip that will be visible in the end.
# So in this we have to import the previously exported values and display them.
elevation_tooltip_formatter = """
// The actual label of the value this tooltip has access to.
// Percent-s gets replaced to the value of the python variable last_label,
// that will be "Elevation" in this case.
var last_label = "%s";
// The actual value this tooltip has access to, that is the value of the
// elevation data in this case.
var last_value = value;
// get a copy of the previously saved tooltips
var tooltips = {...window['saved_tooltips']};
// add the current label:value pair to the tooltips copy (elevation in this case)
tooltips[last_label] = last_value;
// The html code is copied straight from the tooltip html code normally generated by Bokeh
var ret = '<div class="bk" style="display: table; border-spacing: 2px;">';
for (const [label, value] of Object.entries(tooltips))
{
// formatting each value however we want
var display_value='';
switch (label)
{
case 'Population':
display_value = value+' p/km<sup>2</sup>';
break;
case 'Elevation':
display_value = value+' m';
break;
}
// displaying the labels and values
ret += '\
<div class="bk" style="display: table-row;">\
<div class="bk bk-tooltip-row-label" style="display: table-cell;">'+label+': </div>\
<div class="bk bk-tooltip-row-value" style="display: table-cell;"><span class="bk" data-value="">'+display_value+'</span><span class="bk bk-tooltip-color-block" data-swatch="" style="display: none;"> </span></div>\
</div>\
';
}
ret += '</div>'; // display: table
return ret;
"""
elevation_tooltip_formatter = elevation_tooltip_formatter % 'Elevation'
custom_elevation_tooltip = """
<div class="bk bk-custom">
@image{custom}
</div>
"""
custom_elevation_formatters = {
'@image': bk.models.CustomJSHover(code=elevation_tooltip_formatter),
}
custom_elevation_hover = bk.models.HoverTool(tooltips=custom_elevation_tooltip, formatters=custom_elevation_formatters)
map = (tiles * image_population.options(tools=[custom_population_hover]) * image_elevation.options(tools=[custom_elevation_hover])).options(title='Custom Hover Tool')
css = pn.pane.HTML("""
<style>
.bk-tooltip.bk-tooltip-arrow:not(:last-child)
{
display: none!important;
}
</style>
""")
pn.Column(
map,
css
)
I updated the original Colab with this code:
https://colab.research.google.com/drive/1nACPvXNk-gnygTyzwUaNlnn-O1Y9i_7U?usp=sharing
Note that as you can see on the gif, the workaround is not perfect. If the coverage of the images is not exactly the same:
- The tooltip only shows up when the pointer hovers the area covered by the last image. Areas where the last image is not present but previous images are don’t have a tooltip. This could be mitigated by enlarging the last image with NaN values to cover all other images.
- When hovering an area where only the last image is present, the opposite problem appears: the tooltip shows the last known data of the previous images instead of not displaying those rows or showing NaN. This could be solved in multiple ways. Either by saving the special_vars.x and special_vars.y values while exporting and on import only using the saved values if the current special_vars.x and special_vars.y are close enough to the saved x and y values. Or the images could be enlarged and filled with NaN values.
However, these are not a problem for me as my images have only minor differences in their covered areas, so I’m not going to bother with this for now.
This solution has the benefit of being able to handle multiple images: you just have to use the same JS formatter to export the data for all the images except for the last one, and the custom formatter of the last one will be able to display them without any change.
Thanks to @ahuang11 for the tips.
Until a better solution comes up, I mark this comment as the solution.