Repository: bokeh/bokeh

Issue:
Improve mappers' inheritance structure
Currently we have:

```
categorical_color_mapper.coffee:class CategoricalColorMapper extends ColorMapper.Model
categorical_mapper.coffee:class CategoricalMapper extends LinearMapper.Model
color_mapper.coffee:class ColorMapper extends Model
grid_mapper.coffee:class GridMapper extends Model
linear_color_mapper.coffee:class LinearColorMapper extends ColorMapper.Model
linear_mapper.coffee:class LinearMapper extends Model
log_color_mapper.coffee:class LogColorMapper extends ColorMapper.Model
log_mapper.coffee:class LogMapper extends Model
```
1. `GridMapper` seems unused, so I'm going to remove it (related to PR #5260).
2. `Mapper` abstract base class is needed.
3.  `CategoricalColorMapper` should be a `LinearColorMapper`, because `CategoricalMapper` is a `LinearMapper`, unless `CategoricalMapper` is wrongly defined.

@bryevdv, any feedback would be useful, especially about (3).



Discussion:
1) The idea (never implemented) was to have "2d" ranges that could trigger single events when either _or_ both ranges changed. Then have 2d mappers on top of these. It's not clear to me that the former requires the latter, and as you say it's not in use, so I'm fine removing it and we can possibly revisit composite ranges later.

2) Agreed

3) Less clear on. It's probably true that you could implement a categorical color mapper with a linear color mapper but then that seems like it should be an implementation detail. It is not the right conceptual organization. A categorical mapper should never interpolate continuous values. 

Edit: for 3) it's also the case that the categorial might not always be "uniform" in which case a linear color mapper would probably fail? An example of this: imagine a "nesting" in the grammar of graphics sense, where the outer category has different numbers of subcategories. When we get to nested coordinates, this might most easily be done by picking unevenly spaced "synthetic" coordinates up front (because the categories are of different "sizes"). 

I will do this. I have it planned out already as a part of exposing mappers a public models, just waiting on layout PR to finish to start. 

Roughly:

* Transforms represent JS computations with `compute` and `v_compute` methods
* Color mappers are a special case of transform that compute colors
* Current (non-public) "data" mappers will become `Scale` models, which are invertible transforms that also have `invert` and `v_invert` methods. 

Resolving patch:
diff --git a/bokeh/models/plots.py b/bokeh/models/plots.py
--- a/bokeh/models/plots.py
+++ b/bokeh/models/plots.py
@@ -6,13 +6,14 @@
 from six import string_types
 
 from ..core.enums import Location
-from ..core.properties import Auto, Bool, Dict, Either, Enum, Include, Instance, Int, List, Override, String
+from ..core.properties import Bool, Dict, Enum, Include, Instance, Int, List, Override, String
 from ..core.property_mixins import LineProps, FillProps
 from ..core.query import find
 from ..core.validation import error, warning
 from ..core.validation.errors import REQUIRED_RANGE
 from ..core.validation.warnings import (MISSING_RENDERERS, NO_DATA_RENDERERS,
                                         MALFORMED_CATEGORY_LABEL, SNAPPED_TOOLBAR_ANNOTATIONS)
+from ..util.deprecation import deprecated
 from ..util.plot_utils import _list_attr_splat, _select_helper
 from ..util.string import nice_join
 
@@ -23,6 +24,7 @@
 from .layouts import LayoutDOM
 from .ranges import Range, FactorRange
 from .renderers import DataRenderer, DynamicImageRenderer, GlyphRenderer, Renderer, TileRenderer
+from .scales import Scale, CategoricalScale, LinearScale, LogScale
 from .sources import DataSource, ColumnDataSource
 from .tools import Tool, Toolbar, ToolEvents
 
@@ -36,10 +38,10 @@ def __init__(self, **kwargs):
             kwargs["tool_events"] = ToolEvents()
 
         if "toolbar" in kwargs and "logo" in kwargs:
-            raise ValueError("Conflicing properties set on plot: toolbar, logo.")
+            raise ValueError("Conflicting properties set on plot: toolbar, logo.")
 
         if "toolbar" in kwargs and "tools" in kwargs:
-            raise ValueError("Conflicing properties set on plot: toolbar, tools.")
+            raise ValueError("Conflicting properties set on plot: toolbar, tools.")
 
         if "toolbar" not in kwargs:
             tools = kwargs.pop('tools', [])
@@ -47,6 +49,18 @@ def __init__(self, **kwargs):
 
             kwargs["toolbar"] = Toolbar(tools=tools, logo=logo)
 
+        if "x_mapper_type" in kwargs and "x_scale" in kwargs:
+            raise ValueError("Conflicting properties set on plot: x_mapper_type, x_scale.")
+
+        elif "x_mapper_type" in kwargs:
+            kwargs["x_scale"] = self._scale(kwargs.pop("x_mapper_type"))
+
+        if "y_mapper_type" in kwargs and "y_scale" in kwargs:
+            raise ValueError("Conflicting properties set on plot: y_mapper_type, y_scale")
+
+        elif "y_mapper_type" in kwargs:
+            kwargs["y_scale"] = self._scale(kwargs.pop("y_mapper_type"))
+
         super(LayoutDOM, self).__init__(**kwargs)
 
     def select(self, *args, **kwargs):
@@ -371,22 +385,55 @@ def _check_snapped_toolbar_and_axis(self):
     The (default) data range of the vertical dimension of the plot.
     """)
 
-    x_mapper_type = Either(Auto, String, help="""
-    What kind of mapper to use to convert x-coordinates in data space
+    @classmethod
+    def _scale(cls, scale):
+        if scale == "auto" or scale is None:
+            return None
+        if scale == "categorical":
+            return CategoricalScale()
+        elif scale == "linear":
+            return LinearScale()
+        elif scale == "log":
+            return LogScale()
+        else:
+            raise ValueError("Unknown mapper_type")
+
+    @property
+    def x_mapper_type(self):
+        deprecated((0, 12, 6), "x_mapper_type", "x_scale")
+        return self.x_scale
+
+    @x_mapper_type.setter
+    def x_mapper_type(self, mapper_type):
+        deprecated((0, 12, 6), "x_mapper_type", "x_scale")
+        self.x_scale = self._scale(mapper_type)
+
+    @property
+    def y_mapper_type(self):
+        deprecated((0, 12, 6), "y_mapper_type", "y_scale")
+        return self.y_scale
+
+    @y_mapper_type.setter
+    def y_mapper_type(self, mapper_type):
+        deprecated((0, 12, 6), "y_mapper_type", "y_scale")
+        self.y_scale = self._scale(mapper_type)
+
+    x_scale = Instance(Scale, help="""
+    What kind of scale to use to convert x-coordinates in data space
     into x-coordinates in screen space.
 
-    Typically this can be determined automatically, but this property
-    can be useful to, e.g., show datetime values as floating point
-    "seconds since epoch" instead of formatted dates.
+    If set to ``None``, but will infer an appropriate scale type based on
+    the default range in x-dimension. Numeric ranges will use a LinearScale
+    and FactorRanges will use a CategoricalScale.
     """)
 
-    y_mapper_type = Either(Auto, String, help="""
-    What kind of mapper to use to convert y-coordinates in data space
+    y_scale = Instance(Scale, help="""
+    What kind of scale to use to convert y-coordinates in data space
     into y-coordinates in screen space.
 
-    Typically this can be determined automatically, but this property
-    can be useful to, e.g., show datetime values as floating point
-    "seconds since epoch" instead of formatted dates
+    If set to ``None``, but will infer an appropriate scale type based on
+    the default range in x-dimension. Numeric ranges will use a LinearScale
+    and FactorRanges will use a CategoricalScale.
     """)
 
     extra_x_ranges = Dict(String, Instance(Range), help="""
diff --git a/bokeh/plotting/helpers.py b/bokeh/plotting/helpers.py
--- a/bokeh/plotting/helpers.py
+++ b/bokeh/plotting/helpers.py
@@ -16,7 +16,8 @@
     FactorRange, Grid, HelpTool, HoverTool, LassoSelectTool, Legend, LegendItem, LinearAxis,
     LogAxis, PanTool, ZoomInTool, ZoomOutTool, PolySelectTool, ContinuousTicker,
     SaveTool, Range, Range1d, UndoTool, RedoTool, ResetTool, ResizeTool, Tool,
-    WheelPanTool, WheelZoomTool, ColumnarDataSource, ColumnDataSource, GlyphRenderer)
+    WheelPanTool, WheelZoomTool, ColumnarDataSource, ColumnDataSource, GlyphRenderer,
+    LogScale)
 
 from ..core.properties import ColorSpec, Datetime, value, field
 from ..util.deprecation import deprecated
@@ -302,11 +303,10 @@ def _process_axis_and_grid(plot, axis_type, axis_location, minor_ticks, axis_lab
     if axiscls:
 
         if axiscls is LogAxis:
-            # TODO (bev) this mapper type hinting is ugly
             if dim == 0:
-                plot.x_mapper_type = 'log'
+                plot.x_scale = LogScale()
             elif dim == 1:
-                plot.y_mapper_type = 'log'
+                plot.y_scale = LogScale()
             else:
                 raise ValueError("received invalid dimension value: %r" % dim)
 


Test patch:
diff --git a/bokeh/models/tests/test_plots.py b/bokeh/models/tests/test_plots.py
--- a/bokeh/models/tests/test_plots.py
+++ b/bokeh/models/tests/test_plots.py
@@ -14,6 +14,7 @@
 
 from bokeh.plotting import figure
 from bokeh.models import GlyphRenderer, Label, Range1d, FactorRange, Plot, LinearAxis
+from bokeh.models.scales import CategoricalScale, LinearScale, LogScale
 from bokeh.models.tools import PanTool, Toolbar
 
 
@@ -159,3 +160,33 @@ def test_plot_raises_error_if_toolbar_and_tools_are_set():
 def test_plot_with_no_title_specified_creates_an_empty_title():
     plot = Plot()
     assert plot.title.text == ""
+
+
+def test_plot__scale_classmethod():
+    assert Plot._scale("auto") == None
+    assert Plot._scale(None) == None
+    assert isinstance(Plot._scale("categorical"), CategoricalScale)
+    assert isinstance(Plot._scale("linear"), LinearScale)
+    assert isinstance(Plot._scale("log"), LogScale)
+    with pytest.raises(ValueError):
+        Plot._scale("malformed_type")
+
+
+def test_plot_x_mapper_type_kwarg_sets_x_scale():
+    plot = Plot(x_mapper_type="linear")
+    assert isinstance(plot.x_scale, LinearScale)
+
+
+def test_plot_y_mapper_type_kwarg_sets_y_scale():
+    plot = Plot(y_mapper_type="log")
+    assert isinstance(plot.y_scale, LogScale)
+
+
+def test_plot_raises_error_if_x_mapper_type_and_x_scale_are_set():
+    with pytest.raises(ValueError):
+        Plot(x_mapper_type="linear", x_scale=LinearScale())
+
+
+def test_plot_raises_error_if_y_mapper_type_and_y_scale_are_set():
+    with pytest.raises(ValueError):
+        Plot(y_mapper_type="log", y_scale=LogScale())
diff --git a/bokeh/plotting/tests/test_figure.py b/bokeh/plotting/tests/test_figure.py
--- a/bokeh/plotting/tests/test_figure.py
+++ b/bokeh/plotting/tests/test_figure.py
@@ -10,6 +10,7 @@
     LassoSelectTool,
     Legend,
     LinearAxis,
+    LogScale,
     PanTool,
     ResetTool,
     ResizeTool,
@@ -115,11 +116,11 @@ def test_axis(self):
     def test_log_axis(self):
         p = plt.figure(x_axis_type='log')
         p.circle([1, 2, 3], [1, 2, 3])
-        self.assertEqual(p.x_mapper_type, 'log')
+        self.assertIsInstance(p.x_scale, LogScale)
 
         p = plt.figure(y_axis_type='log')
         p.circle([1, 2, 3], [1, 2, 3])
-        self.assertEqual(p.y_mapper_type, 'log')
+        self.assertIsInstance(p.y_scale, LogScale)
 
     def test_xgrid(self):
         p = plt.figure()
diff --git a/bokehjs/test/models/canvas/cartesian_frame.coffee b/bokehjs/test/models/canvas/cartesian_frame.coffee
--- a/bokehjs/test/models/canvas/cartesian_frame.coffee
+++ b/bokehjs/test/models/canvas/cartesian_frame.coffee
@@ -1,7 +1,12 @@
 {expect} = require "chai"
 utils = require "../../utils"
 
+{CategoricalScale} = utils.require("models/scales/categorical_scale")
+{LinearScale} = utils.require("models/scales/linear_scale")
+{LogScale} = utils.require("models/scales/log_scale")
 {CartesianFrame} = utils.require("models/canvas/cartesian_frame")
+{DataRange1d} = utils.require("models/ranges/data_range1d")
+{FactorRange} = utils.require("models/ranges/factor_range")
 {Range1d} = utils.require("models/ranges/range1d")
 {Document} = utils.require "document"
 {Variable}  = utils.require("core/layout/solver")
@@ -33,3 +38,33 @@ describe "CartesianFrame", ->
 
     expect(c.x_mappers).to.be.deep.equal c.xscales
     expect(c.y_mappers).to.be.deep.equal c.yscales
+
+  describe "_get_scales method", ->
+
+    beforeEach ->
+      @frame = new CartesianFrame({x_range: Range1d(), y_range: Range1d()})
+      @frame_range = Range1d(0, 100)
+
+    it "should return scale if defined", ->
+      scale = new LogScale()
+      ranges = {"default": new Range1d()}
+      scales = @frame._get_scales(scale, ranges, @frame_range)
+      expect(scales["default"]).to.be.instanceof(LogScale)
+
+    it "should return LinearScale as default for Range1d", ->
+      scale = undefined
+      ranges = {"default": new Range1d()}
+      scales = @frame._get_scales(scale, ranges, @frame_range)
+      expect(scales["default"]).to.be.instanceof(LinearScale)
+
+    it "should return LinearScale as default for DataRange1d", ->
+      scale = undefined
+      ranges = {"default": new DataRange1d()}
+      scales = @frame._get_scales(scale, ranges, @frame_range)
+      expect(scales["default"]).to.be.instanceof(LinearScale)
+
+    it "should return CategoricalScale as default for FactorRange", ->
+      scale = undefined
+      ranges = {"default": new FactorRange()}
+      scales = @frame._get_scales(scale, ranges, @frame_range)
+      expect(scales["default"]).to.be.instanceof(CategoricalScale)