Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request: HoverTool tooltips stick to a point on click. #5724

Open
draperjames opened this issue Jan 13, 2017 · 15 comments
Open

Feature Request: HoverTool tooltips stick to a point on click. #5724

draperjames opened this issue Jan 13, 2017 · 15 comments

Comments

@draperjames
Copy link

From my stack overflow question:

The code below comes from a jupyter notebook:

from bokeh.io import show, output_notebook
from bokeh.plotting import ColumnDataSource, figure
from bokeh.models import HoverTool, Range1d

output_notebook()
fig = figure(tools=[HoverTool(tooltips=[("html", '@html{safe}')])])

fig.quad(left="left", top="top", bottom="bottom", right="right",
         source=ColumnDataSource({"left": [1,3], "bottom": [1,3],
                                  "right": [2,4], "top": [2,4],
                                  "html":["<b>I'm bold</b>", "<span 

style='color:red;font-size:32px;'>BIG RED TEXT</span>"]}))
    show(fig)

I need to make the HoverTool tooltips stick to exactly where they are on a clicking the point, so if a user wanted to highlight the and copy the text in the tooltip they could. This codepen has an example of the type of behavior I would like to see. I know that this must be possible by either injecting some type of CustomJS or altering BokehJS coffescript and building BokehJS from scratch but I haven't been able to figure it out. Does anybody out there have any idea how to do this?

@draperjames draperjames changed the title HoverTool tooltips don't stick to points on click. Feature Request: HoverTool tooltips stick to a point on click. Jun 23, 2017
@draperjames draperjames reopened this Jun 23, 2017
@julioasotodv
Copy link

+1 for this feature. Also, it would be great if the selected (either circle or point in a line) could take callbacks.

@draperjames
Copy link
Author

Word. @mattpap @bryevdv if a PR was made with this feature would y'all consider merging it?

@mattpap
Copy link
Contributor

mattpap commented Aug 4, 2017

@draperjames, sure. The idea to be able to interact with contents of a tooltip has been around for awhile, though in various forms. One thing to keep in mind is that whatever is implemented here, it should intersect smoothly with other bokeh's features. In particular, hover tool's functionality shouldn't interfere with tap tool. There may be some discussion needed to determine the best course of action.

@draperjames
Copy link
Author

@mattpap +1 what would be nice is some kind of 'onclick_behavior' kwarg in HoverTool that could could be loaded with a couple options. But I have no idea what that would look like on the back end. What do you think?

@inrix-yi-gu
Copy link

+1

@lgasnier
Copy link

lgasnier commented Dec 9, 2017

+1 for this feature. Use case : click on some links in the HoverTool tooltip.

An idea about the risk of interference between HoverTool and TapTool: It could be also a new model openToolTip, used with TapTool as openURL, that opens a tooltip created as the one for HoverTool (with a cross to close it).

@clairetang6
Copy link
Contributor

I've been thinking about improving inspections and tooltips, and have an idea for an implementation that would solve this issue.

First, tooltips would owned by GlyphRenderers instead of the HoverTool. Currently, tooltips depend on the HoverTool, so they only appear when a hover tool is actively hovering. Moving the tooltips to the GlyphRenderers would make it possible for them to depend only the data source's inspected property. So the tooltips would appear however inspected is set, through the hover tool, programmatically, or through the TapTool with behavior == "inspect". That last case would mean having the tooltips stick would just require creating an inspecting TapTool and clicking on a glyph.

@bryevdv
Copy link
Member

bryevdv commented Jul 27, 2018

copying comment from #8113:

I will say one of the reasons this has probably not been acted on yet is that making it truly useful seems problematic. Tooltips are HTML divs display on top of plot canvases. So:

  • the SaveTool only capture whats on canvas, so it would not capture the tooltip, no-go
  • SVG export also only captures the canvas, no DOM elements, also no-go

The PNG export can get a screenshot that captures the tooltip on top of a plot, but in the only way that could be done in response to a tooltip added interactively after the doc loads is in the context of a Bokeh server application.

I can' t think of any way to add a current interactive tooltip that could be exported or saved from standalone document (short of taking a screenshot manually yourself)

A possible options might be to have a mode for tooltips to render on to the canvas, but the issue there is that some current tooltip capabilities, e.g. custom HTML templates, or certain kinds of formatting, are 100% incompatible with rendering directly to canvas.

A possible solution is to expose an HTMLTooltip and a CanvasTooltip and have the HoverTool mediate the capabilities that can't be used with CanvasTooltip at a higher level, and have a tool for "dropping" interactive tooltips randomly only use the CanvasTooltip (which would not support custom templates, etc)

@bryevdv
Copy link
Member

bryevdv commented Jul 27, 2018

In case its useful as workaround for now, a simplistic example was developed in the other issue LabelSet and tap tool that could be expanded upon:

from bokeh.plotting import figure, show
from bokeh.models.sources import ColumnDataSource
from bokeh.models import CustomJS, LabelSet, TapTool

p = figure()

points = ColumnDataSource(data=dict(x=[1,2,3,4,5], y=[2,6,3,9,7], extra=list("abcde")))
p.circle(x='x',  y='y', source=points, size=20, alpha=0.5)


labels = ColumnDataSource(data=dict(x=[], y=[], t=[], ind=[]))
p.add_layout(LabelSet(x='x', y='y', text='t', y_offset=10, x_offset=10, source=labels))

code = """
const data = labels.data
for (i=0; i<points.selected.indices.length; i++) {
    const ind = points.selected.indices[i]
    data.x.push(points.data.x[ind])
    data.y.push(points.data.y[ind])
    data.t.push(points.data.extra[ind])
    data.ind.push(ind)
}
labels.change.emit()
"""

callback=CustomJS(args=dict(points=points, labels=labels), code=code)
p.add_tools(TapTool(callback=callback))

show(p)

Initial:

screen shot 2018-07-27 at 10 58 59

After some taps:

screen shot 2018-07-27 at 10 59 08

this could be expanded to remove old labels on a second click, not change the selection highlights, etc. as desired.

@bryevdv bryevdv modified the milestones: 0.13.x, short-term Sep 11, 2018
@p-himik
Copy link
Contributor

p-himik commented Jul 16, 2019

I'm sorry for this abomination, but it works quite well, at least with Bokeh 1.2.0.
Since I did it for my own project, there's no pinning. But the tooltip does not disappear if you keep your mouse cursor within 10px of the tooltip.
I may be easy enough to adapt it so that tooltips stick when you click on the corresponding points, although I can't tell for sure.

sticky_hover_tool.py:

from bokeh.models import HoverTool


class StickyHoverTool(HoverTool):
    __implementation__ = "sticky_hover_tool.ts"

sticky_hover_tool.ts:

import {HoverTool, HoverToolView} from "models/tools/inspectors/hover_tool";
import {GlyphRenderer, GraphRenderer} from "models/renderers";
import {Tooltip} from "models/annotations";
import {isFunction, isString} from "core/util/types";
import {build_views} from "core/build_views";
import {values} from "core/util/object";
import {TooltipView} from "models/annotations/tooltip";

export class StickyTooltipView extends TooltipView {
    protected _mouse_over = false;
    protected _have_drawn = false;
    protected _bound_mouse_moved: EventListener | null = null;
    protected _cursor_margin = 10;

    protected _createElement(): HTMLElement {
        const el = super._createElement();
        el.style.pointerEvents = "initial";
        el.style.cursor = "initial";
        return el;
    }

    protected _mouse_moved(event: MouseEvent): void {
        const old_value = this._mouse_over;
        if (this._have_drawn) {
            const br = this.el.getBoundingClientRect();
            const x = event.clientX;
            const y = event.clientY;
            this._mouse_over = (
                x >= (br.left - this._cursor_margin)
                && x <= (br.right + this._cursor_margin)
                && y >= (br.top - this._cursor_margin)
                && y <= (br.bottom + this._cursor_margin)
            );
        } else {
            this._mouse_over = false;
        }
        if (old_value && !this._mouse_over) {
            this._draw_tips();
        }
    }

    initialize(): void {
        super.initialize();
        // Using `document.addEventListener` instead of
        // `this.el.addEventListener` since we also want to detect
        // the mouse within some margin of the tooltip. It would
        // be good to know if there are other solutions that
        // don't require any cleanup.
        this._bound_mouse_moved = this._mouse_moved.bind(this);
        document.addEventListener("mousemove", this._bound_mouse_moved!);
    }

    remove(): void {
        super.remove();
        document.removeEventListener("mousemove", this._bound_mouse_moved!);
    }

    protected _redraw_if_not_interacting(): void {
        if (!this._mouse_over) {
            super._draw_tips();
            this._have_drawn = this.el.childNodes.length > 0;
        }
    }

    protected _draw_tips(): void {
        if (this._have_drawn) {
            setTimeout(this._redraw_if_not_interacting.bind(this), 500);
        } else {
            this._redraw_if_not_interacting();
        }
    }
}

export class StickyTooltip extends Tooltip {
    static initClass(): void {
        this.prototype.default_view = StickyTooltipView;
    }
}

StickyTooltip.initClass();

export class StickyHoverToolView extends HoverToolView {
    protected _compute_ttmodels(): { [key: string]: StickyTooltip } {
        // The only thing this override of `HoverToolView._compute_ttmodels`
        // does is replace the usages of `Tooltip` with `StickyTooltip`.
        const ttmodels: { [key: string]: StickyTooltip } = {};
        const tooltips = this.model.tooltips;

        if (tooltips != null) {
            for (const r of this.computed_renderers) {
                if (r instanceof GlyphRenderer) {
                    ttmodels[r.id] = new StickyTooltip({
                        custom: isString(tooltips) || isFunction(tooltips),
                        attachment: this.model.attachment,
                        show_arrow: this.model.show_arrow,
                    });
                } else if (r instanceof GraphRenderer) {
                    const tooltip = new StickyTooltip({
                        custom: isString(tooltips) || isFunction(tooltips),
                        attachment: this.model.attachment,
                        show_arrow: this.model.show_arrow,
                    });
                    ttmodels[r.node_renderer.id] = tooltip;
                    ttmodels[r.edge_renderer.id] = tooltip;
                }
            }
        }

        build_views(this.ttviews, values(ttmodels), {parent: this.plot_view});

        return ttmodels
    }
}

export class StickyHoverTool extends HoverTool {
    static initClass(): void {
        this.prototype.default_view = StickyHoverToolView;
    }
}

StickyHoverTool.initClass();

Let me know how it works for you.

@vgorde
Copy link

vgorde commented Jul 22, 2019

I'm sorry for this abomination, but it works quite well, at least with Bokeh 1.2.0.
Since I did it for my own project, there's no pinning. But the tooltip does not disappear if you keep your mouse cursor within 10px of the tooltip.
I may be easy enough to adapt it so that tooltips stick when you click on the corresponding points, although I can't tell for sure.

sticky_hover_tool.py:

from bokeh.models import HoverTool


class StickyHoverTool(HoverTool):
    __implementation__ = "sticky_hover_tool.ts"

sticky_hover_tool.ts:

import {HoverTool, HoverToolView} from "models/tools/inspectors/hover_tool";
import {GlyphRenderer, GraphRenderer} from "models/renderers";
import {Tooltip} from "models/annotations";
import {isFunction, isString} from "core/util/types";
import {build_views} from "core/build_views";
import {values} from "core/util/object";
import {TooltipView} from "models/annotations/tooltip";

export class StickyTooltipView extends TooltipView {
    protected _mouse_over = false;
    protected _have_drawn = false;
    protected _bound_mouse_moved: EventListener | null = null;
    protected _cursor_margin = 10;

    protected _createElement(): HTMLElement {
        const el = super._createElement();
        el.style.pointerEvents = "initial";
        el.style.cursor = "initial";
        return el;
    }

    protected _mouse_moved(event: MouseEvent): void {
        const old_value = this._mouse_over;
        if (this._have_drawn) {
            const br = this.el.getBoundingClientRect();
            const x = event.clientX;
            const y = event.clientY;
            this._mouse_over = (
                x >= (br.left - this._cursor_margin)
                && x <= (br.right + this._cursor_margin)
                && y >= (br.top - this._cursor_margin)
                && y <= (br.bottom + this._cursor_margin)
            );
        } else {
            this._mouse_over = false;
        }
        if (old_value && !this._mouse_over) {
            this._draw_tips();
        }
    }

    initialize(): void {
        super.initialize();
        // Using `document.addEventListener` instead of
        // `this.el.addEventListener` since we also want to detect
        // the mouse within some margin of the tooltip. It would
        // be good to know if there are other solutions that
        // don't require any cleanup.
        this._bound_mouse_moved = this._mouse_moved.bind(this);
        document.addEventListener("mousemove", this._bound_mouse_moved!);
    }

    remove(): void {
        super.remove();
        document.removeEventListener("mousemove", this._bound_mouse_moved!);
    }

    protected _redraw_if_not_interacting(): void {
        if (!this._mouse_over) {
            super._draw_tips();
            this._have_drawn = this.el.childNodes.length > 0;
        }
    }

    protected _draw_tips(): void {
        if (this._have_drawn) {
            setTimeout(this._redraw_if_not_interacting.bind(this), 500);
        } else {
            this._redraw_if_not_interacting();
        }
    }
}

export class StickyTooltip extends Tooltip {
    static initClass(): void {
        this.prototype.default_view = StickyTooltipView;
    }
}

StickyTooltip.initClass();

export class StickyHoverToolView extends HoverToolView {
    protected _compute_ttmodels(): { [key: string]: StickyTooltip } {
        // The only thing this override of `HoverToolView._compute_ttmodels`
        // does is replace the usages of `Tooltip` with `StickyTooltip`.
        const ttmodels: { [key: string]: StickyTooltip } = {};
        const tooltips = this.model.tooltips;

        if (tooltips != null) {
            for (const r of this.computed_renderers) {
                if (r instanceof GlyphRenderer) {
                    ttmodels[r.id] = new StickyTooltip({
                        custom: isString(tooltips) || isFunction(tooltips),
                        attachment: this.model.attachment,
                        show_arrow: this.model.show_arrow,
                    });
                } else if (r instanceof GraphRenderer) {
                    const tooltip = new StickyTooltip({
                        custom: isString(tooltips) || isFunction(tooltips),
                        attachment: this.model.attachment,
                        show_arrow: this.model.show_arrow,
                    });
                    ttmodels[r.node_renderer.id] = tooltip;
                    ttmodels[r.edge_renderer.id] = tooltip;
                }
            }
        }

        build_views(this.ttviews, values(ttmodels), {parent: this.plot_view});

        return ttmodels
    }
}

export class StickyHoverTool extends HoverTool {
    static initClass(): void {
        this.prototype.default_view = StickyHoverToolView;
    }
}

StickyHoverTool.initClass();

Let me know how it works for you.

Verified Eugene(p-himik)'s solution works

@rsdenijs
Copy link

This would be a really cool feature!
It would allow to display multiple URLs in the hover, and allowing users to jump to the desired resource. Currently it is only possible assign single url destination through OpenUrl.
Or are there maybe other workarounds for this use case?

@p-himik
Copy link
Contributor

p-himik commented Oct 14, 2019

@rsdenijs Have you tried my code in my previous message?

@rsdenijs
Copy link

@p-himik I tried but I need node.js v6.10.0 for building custom models and my environment currently does not allow adding dependencies.

@p-himik
Copy link
Contributor

p-himik commented Oct 14, 2019

@rsdenijs I see. It should be possible to implement the same approach but avoid any custom model building. You'll have to rewrite the code to the target JS version, not add __implementation__ to the Python model, and somehow make the JS code available to your system when Bokeh is available. I have never done it and it's possible that such approach would require some Bokeh code modification/monkey-patching.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

10 participants