Repository: bokeh/bokeh

Issue:
allow custom formatter when using the hover tool
**Background**
The python library geoviews ([http://geoviews.org/](url)) uses bokeh as a backend when interactive plotting geographical data is required. 
Typically data on a latitude/longitude grid is converted to a Web Mercator projection and displayed using bokeh.

**The Issue**
When using the hover tool on data transformed in to Web Mercator format, the user is shown the transformed values for latitude/longitude rather than the original latitude/longitude values.
Attached is an image that shows this issue 

![geoviews_hover](https://user-images.githubusercontent.com/2906750/37561540-bba2b27c-2aa4-11e8-965f-a14b1b913ed6.png)

There have some discussions on how to solve this issue at https://github.com/ioam/geoviews/issues/100 and it seems the consensus would be that the nicest way to solve the problem would be if a custom formatter could be applied to the hover tool.
This would allow the Web Mercator transformed data could be transformed back to latitude/longitude values when displayed by the hover tool.

As this feature does not already exist I expect that it may be a niche issue relating to geoviews, however would adding a custom formatter to the hover tool be something you would be willing to consider? 



Discussion:
@scaine1 I think my immediate preference would be to : 

* add a generic `CustomJSFormatter` that is similar to several other "CustomJS" type models elswhere in Bokeh.

* Add an example that demonstrates how to use it to format mercator values

* Holoviews and other high level tools can use it to provide "built in" support at that level 
sounds ideal to me!
@scaine1 Are you interested in working up this feature as a contribution? (Just asking, I could not tell if that's what you meant above by willing to consider)
Unfortunately at this point I think it is beyond my abilities.
When I said  ".. willing to consider .." I meant would the bokeh team be willing to consider adding this feature for me and other geoviews users. Sorry for not being clear.
Having said that,  if you don't have anyone to work on it I could potentially dedicate some time over the next couple of months to learn how bokeh works and come up with a solution. 
@scaine1 no worries I just wanted to clarify. I think this is useful enough that I will probably try to get to it sooner rather than later, but if you are looking to get involved with a project don't let that stop you, there's plenty of other issues :)

Resolving patch:
diff --git a/bokeh/models/tools.py b/bokeh/models/tools.py
--- a/bokeh/models/tools.py
+++ b/bokeh/models/tools.py
@@ -22,6 +22,8 @@
 '''
 from __future__ import absolute_import
 
+from types import FunctionType
+
 from ..core.enums import (Anchor, Dimension, Dimensions, Location,
                           TooltipFieldFormatter, TooltipAttachment)
 from ..core.has_props import abstract
@@ -29,6 +31,9 @@
     Auto, Bool, Color, Date, Datetime, Dict, Either, Enum, Int, Float,
     Percent, Instance, List, Seq, String, Tuple
 )
+from ..util.compiler import nodejs_compile, CompilationError
+from ..util.dependencies import import_required
+from ..util.future import get_param_info, signature
 from ..core.validation import error
 from ..core.validation.errors import (
     INCOMPATIBLE_BOX_EDIT_RENDERER, INCOMPATIBLE_POINT_DRAW_RENDERER,
@@ -616,6 +621,175 @@ class PolySelectTool(Tap):
     A shaded annotation drawn to indicate the selection region.
     """)
 
+class CustomJSHover(Model):
+    ''' Define a custom formatter to apply to a hover tool field.
+
+    This model can be configured with JavaScript code to format hover tooltips.
+    The JavaScript code has access to the current value to format, some special
+    variables, and any format configured on the tooltip. The variable ``value``
+    will contain the untransformed value. The variable ``special_vars`` will
+    provide a dict with the following contents:
+
+    * ``x`` data-space x-coordinate of the mouse
+    * ``y`` data-space y-coordinate of the mouse
+    * ``sx`` screen-space x-coordinate of the mouse
+    * ``sy`` screen-space y-coordinate of the mouse
+    * ``data_x`` data-space x-coordinate of the hovered glyph
+    * ``data_y`` data-space y-coordinate of the hovered glyph
+    * ``indices`` column indices of all currently hovered glyphs
+
+    If the hover is over a "multi" glyph such as ``Patches`` or ``MultiLine``
+    then a ``segment_index`` key will also be present.
+
+    Finally, the value of the format passed in the tooltip specification is
+    available as the ``format`` variable.
+
+    Example:
+
+        As an example, the following code adds a custom formatter to format
+        WebMercator northing coordinates (in meters) as a latitude:
+
+        .. code-block:: python
+
+            lat_custom = CustomJSHover(code="""
+                var projections = require("core/util/projections");
+                var x = special_vars.x
+                var y = special_vars.y
+                var coords = projections.wgs84_mercator.inverse([x, y])
+                return "" + coords[1]
+            """)
+
+            p.add_tools(HoverTool(
+                tooltips=[( 'lat','@y{custom}' )],
+                formatter=dict(y=lat_custom)
+            ))
+
+    .. warning::
+        The explicit purpose of this Bokeh Model is to embed *raw JavaScript
+        code* for a browser to execute. If any part of the code is derived
+        from untrusted user inputs, then you must take appropriate care to
+        sanitize the user input prior to passing to Bokeh.
+
+    '''
+
+    @classmethod
+    def from_py_func(cls, code):
+        ''' Create a CustomJSHover instance from a Python functions. The
+        function is translated to JavaScript using PScript.
+
+        The python functions must have no positional arguments. It's
+        possible to pass Bokeh models (e.g. a ColumnDataSource) as keyword
+        arguments to the functions.
+
+        The ``code`` function namespace will contain the variable ``value``
+        (the untransformed value) at render time as well as ``format`` and
+        ``special_vars`` as described in the class description.
+
+        Args:
+            code (function) : a scalar function to transform a single ``value``
+
+        Returns:
+            CustomJSHover
+
+        '''
+        if not isinstance(code, FunctionType):
+            raise ValueError('CustomJSHover.from_py_func only accepts function objects.')
+
+        pscript = import_required('pscript',
+                                  'To use Python functions for CustomJSHover, you need PScript ' +
+                                  '("conda install -c conda-forge pscript" or "pip install pscript")')
+
+        def pscript_compile(code):
+            sig = signature(code)
+
+            all_names, default_values = get_param_info(sig)
+
+            if len(all_names) - len(default_values) != 0:
+                raise ValueError("Function may only contain keyword arguments.")
+
+            if default_values and not any([isinstance(value, Model) for value in default_values]):
+                raise ValueError("Default value must be a Bokeh Model.")
+
+            func_kwargs = dict(zip(all_names, default_values))
+
+            # Wrap the code attr in a function named `code` and call it
+            # with arguments that match the `args` attr
+            code = pscript.py2js(code, 'transformer') + 'return transformer(%s);\n' % ', '.join(all_names)
+            return code, func_kwargs
+
+        jsfunc, func_kwargs = pscript_compile(code)
+
+        return cls(code=jsfunc, args=func_kwargs)
+
+    @classmethod
+    def from_coffeescript(cls, code, args={}):
+        ''' Create a CustomJSHover instance from a CoffeeScript snippet.
+        The function bodies are translated to JavaScript functions using
+        node and therefore require return statements.
+
+        The ``code`` snippet namespace will contain the variable ``value``
+        (the untransformed value) at render time as well as ``format`` and
+        ``special_vars`` as described in the class description.
+
+        Example:
+
+        .. code-block:: coffeescript
+
+            formatter = CustomJSHover.from_coffeescript("return value + " total")
+
+        Args:
+            code (str) :
+                A coffeescript snippet to transform a single ``value`` value
+
+        Returns:
+            CustomJSHover
+
+        '''
+        compiled = nodejs_compile(code, lang="coffeescript", file="???")
+        if "error" in compiled:
+            raise CompilationError(compiled.error)
+
+        return cls(code=compiled.code, args=args)
+
+    args = Dict(String, Instance(Model), help="""
+    A mapping of names to Bokeh plot objects. These objects are made
+    available to the callback code snippet as the values of named
+    parameters to the callback.
+    """)
+
+    code = String(default="", help="""
+    A snippet of JavaScript code to transform a single value. The variable
+    ``value`` will contain the untransformed value and can be expected to be
+    present in the function namespace at render time. Additionally, the
+    variable ``special_vars`` will be available, and will provide a dict
+    with the following contents:
+
+    * ``x`` data-space x-coordinate of the mouse
+    * ``y`` data-space y-coordinate of the mouse
+    * ``sx`` screen-space x-coordinate of the mouse
+    * ``sy`` screen-space y-coordinate of the mouse
+    * ``data_x`` data-space x-coordinate of the hovered glyph
+    * ``data_y`` data-space y-coordinate of the hovered glyph
+    * ``indices`` column indices of all currently hovered glyphs
+
+    If the hover is over a "multi" glyph such as ``Patches`` or ``MultiLine``
+    then a ``segment_index`` key will also be present.
+
+    Finally, the value of the format passed in the tooltip specification is
+    available as the ``format`` variable.
+
+    The snippet will be made into the body of a function and therefore requires
+    a return statement.
+
+    Example:
+
+        .. code-block:: javascript
+
+            code = '''
+            return value + " total"
+            '''
+    """)
+
 class HoverTool(Inspection):
     ''' *toolbar icon*: |crosshair_icon|
 
@@ -760,7 +934,7 @@ class HoverTool(Inspection):
 
     """).accepts(Dict(String, String), lambda d: list(d.items()))
 
-    formatters = Dict(String, Enum(TooltipFieldFormatter), default=lambda: dict(), help="""
+    formatters = Dict(String, Either(Enum(TooltipFieldFormatter), Instance(CustomJSHover)), default=lambda: dict(), help="""
     Specify the formatting scheme for data source columns, e.g.
 
     .. code-block:: python
diff --git a/examples/plotting/file/customjs_hover.py b/examples/plotting/file/customjs_hover.py
new file mode 100644
--- /dev/null
+++ b/examples/plotting/file/customjs_hover.py
@@ -0,0 +1,35 @@
+from bokeh.io import output_file, show
+from bokeh.models import HoverTool, CustomJSHover
+from bokeh.plotting import figure
+from bokeh.tile_providers import CARTODBPOSITRON
+
+output_file("customjs_hover.html")
+
+# range bounds supplied in web mercator coordinates
+p = figure(x_range=(-2000000, 6000000), y_range=(-1000000, 7000000),
+           x_axis_type="mercator", y_axis_type="mercator")
+p.add_tile(CARTODBPOSITRON)
+
+p.circle(x=[0, 2000000, 4000000], y=[4000000, 2000000, 0], size=30)
+
+code = """
+    var projections = require("core/util/projections");
+    var x = special_vars.x
+    var y = special_vars.y
+    var coords = projections.wgs84_mercator.inverse([x, y])
+    return coords[%d].toFixed(2)
+"""
+
+p.add_tools(HoverTool(
+    tooltips=[
+        ( 'lon',   '@x{custom}' ),
+        ( 'lat',   '@y{custom}' ),
+    ],
+
+    formatters={
+        'x' : CustomJSHover(code=code % 0),
+        'y' : CustomJSHover(code=code % 1),
+    }
+))
+
+show(p)


Test patch:
diff --git a/bokehjs/test/core/util/templating.coffee b/bokehjs/test/core/util/templating.coffee
--- a/bokehjs/test/core/util/templating.coffee
+++ b/bokehjs/test/core/util/templating.coffee
@@ -4,6 +4,7 @@ utils = require "../../utils"
 {replace_placeholders} = utils.require "core/util/templating"
 
 {ColumnDataSource} = utils.require("models/sources/column_data_source")
+{CustomJSHover} = utils.require("models/tools/inspectors/customjs_hover")
 
 describe "templating module", ->
 
@@ -68,6 +69,11 @@ describe "templating module", ->
       s = replace_placeholders("stuff @foo{(0.000 %)}", source, 1, {"foo": "numeral"})
       expect(s).to.be.equal "stuff 100.200 %"
 
+    it "should use a customjs hover formatter if specified", ->
+      custom = new CustomJSHover({code:"return format + ' ' + special_vars.special + ' ' + value"})
+      s = replace_placeholders("stuff @foo{custom}", source, 0, {"foo": custom}, {special: "vars"})
+      expect(s).to.be.equal "stuff custom vars 10"
+
     it "should replace field names with tz formatted values with datetime formatter", ->
       # just picking a random and uniquely tz format to test with
       s = replace_placeholders("stuff @baz{%F %T}", source, 0, {"baz": "datetime"})
diff --git a/bokehjs/test/models/tools/inspectors/customjs_hover.coffee b/bokehjs/test/models/tools/inspectors/customjs_hover.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/tools/inspectors/customjs_hover.coffee
@@ -0,0 +1,80 @@
+{expect} = require "chai"
+utils = require "../../../utils"
+
+{CustomJSHover} = utils.require("models/tools/inspectors/customjs_hover")
+
+{Range1d} = utils.require("models/ranges/range1d")
+{Document} = utils.require "document"
+js_version = utils.require("version").version
+
+describe "customjs module", ->
+
+  afterEach ->
+    utils.unstub_canvas()
+    utils.unstub_solver()
+
+  beforeEach ->
+    utils.stub_canvas()
+    utils.stub_solver()
+
+  describe "default creation", ->
+    r = new CustomJSHover()
+
+    it "should have empty args", ->
+      expect(r.args).to.be.deep.equal {}
+
+    it "should have empty code", ->
+      expect(r.code).to.be.equal ""
+
+  describe "values property", ->
+    rng = new Range1d()
+    r = new CustomJSHover({args: {foo: rng }})
+
+    it "should contain the args values", ->
+      expect(r.values).to.be.deep.equal [rng]
+
+    it "should round-trip through document serialization", ->
+      d = new Document()
+      d.add_root(r)
+      json = d.to_json_string()
+      parsed = JSON.parse(json)
+      parsed['version'] = js_version
+      copy = Document.from_json_string(JSON.stringify(parsed))
+      r_copy = copy.get_model_by_id(r.id)
+      rng_copy = copy.get_model_by_id(rng.id)
+      expect(r.values).to.be.deep.equal [rng]
+      expect(r_copy.values).to.be.deep.equal [rng_copy]
+
+    it "should update when args changes", ->
+      rng2 = new Range1d()
+      r.args = {foo: rng2 }
+      expect(r.values).to.be.deep.equal [rng2]
+
+  describe "_make_code method", ->
+
+    it "should return a Function", ->
+      r = new CustomJSHover()
+      expect(r._make_code("value", "format", "special_vars", r.code)).to.be.an.instanceof Function
+
+    it "should have formatter property as function body", ->
+      r = new CustomJSHover({code: "return 10"})
+      f = new Function("value", "format", "special_vars", "require", "exports", "'use strict';\nreturn 10")
+      formatter = r._make_code("value", "format", "special_vars", r.code)
+      expect(formatter.toString()).to.be.equal f.toString()
+
+    it "should have values as function args", ->
+      rng = new Range1d()
+      r = new CustomJSHover({args: {foo: rng.ref()}, code: "return 10"})
+      f = new Function("foo", "value", "format", "special_vars", "require", "exports", "'use strict';\nreturn 10")
+      formatter = r._make_code("value", "format", "special_vars", r.code)
+      expect(formatter.toString()).to.be.equal f.toString()
+
+  describe "format method", ->
+
+    it "should execute the code and return the result", ->
+       r = new CustomJSHover({code: "return format + ' ' + value + ' ' + 10"})
+       expect(r.format(0, "custom", {})).to.be.equal "custom 0 10"
+
+    it "should execute the code with args parameters passed", ->
+      r = new CustomJSHover({args: {foo: 5}, code: "return format + ' ' + value + ' ' + (10 + foo)"})
+      expect(r.format(0, "custom", {})).to.be.equal "custom 0 15"
diff --git a/bokehjs/test/models/tools/inspectors/index.coffee b/bokehjs/test/models/tools/inspectors/index.coffee
--- a/bokehjs/test/models/tools/inspectors/index.coffee
+++ b/bokehjs/test/models/tools/inspectors/index.coffee
@@ -1 +1,2 @@
 require "./crosshair_tool"
+require "./customjs_hover"