Repository: bokeh/bokeh

Issue:
Generalize the configuration of axes
One of my most common use cases in the visualization is to present _an overview of some system's dynamics, plotting ~10-20 signals on the vstacked graphs (from 2 to 5 signals per plot)_. Usually I use as many extra axes as possible in order to keep the good perception ratio (up to 4 y-axes and 2 x-axes at the same time). For sure, there are other use cases, but, please, consider this as an example.

I was using `matplotlib` a lot, but it has some issues with sharing the plots for further analysis. At the same time, `bokeh` has very nice capabilities to zoom the shared plots, easily select data / data intervals, etc.

To facilitate the visual style configuration (labels, limits, scales, colors, markers, etc) I usually use either nested dict or DataFrame. This helps to keep the same visual style / axes config across the plots. This is the way it works for me:
- Create `signal -> visual style` mapping;
- Allocate grid of plots (`axis[0], axis_right[0], axis_right2[0], axis_upper[0], ..., axis[n], ...`);
- Create `subplot, axis -> signal` mapping;
- Iterate over mapping to plot the signals and apply the visual styles. 

Looking at `bokeh`, I see that the regular plotting pipeline slightly differs, but still is more or less the same. The only thing that is really missing (at least as I'm aware of), is the possibility to iterate over base x/y-axes and extra x/y-axes in the same way.

Here is the example how it works:

```
import numpy as np
import pandas as pd

from bokeh.io import gridplot, output_file, show
from bokeh.plotting import figure
from bokeh.models import LinearAxis, Range1d

output_file("test.html")

df = pd.DataFrame([])
df['time'] = [1, 2, 3, 4, 5]
df['temperature'] = [30, 31, 32.5, 33, 31]
df['humidity'] = [10, 30, 50, 20, 40]
df['sensor'] = [0.22, 0.12, 0.14, 0.08, 0.04]

s1 = figure(title=None)
s1.x_range = Range1d(start=-1, end=6)
s1.y_range = Range1d(start=25, end=35)

s1.extra_y_ranges = {
    'humidity': Range1d(start=0, end=50),
    'sensor': Range1d(start=0, end=0.25)
}

s1.line(x=df['time'], y=df['temperature'],
        color='orange', legend='temperature')
s1.line(x=df['time'], y=df['humidity'], y_range_name="humidity",
        color="blue", alpha=0.5, legend='humidity')
s1.line(x=df['time'], y=df['sensor'], y_range_name="sensor",
        color="red", legend='sensory data')

s1.xaxis.axis_label = 'Reference Units'
s1.yaxis.axis_label = 'Temperature'
s1.add_layout(LinearAxis(y_range_name='humidity', axis_label='Humidity'), 'left')
s1.add_layout(LinearAxis(y_range_name='sensor', axis_label='Sensor'), 'right')

show(s1)
```

And example how I'd appreciate it to work (just to show the concept):

```
import numpy as np
import pandas as pd

from bokeh.io import gridplot, output_file, show
from bokeh.plotting import figure
from bokeh.models import LinearAxis, Range1d

output_file("test.html")

df = pd.DataFrame([])
df['time'] = [1, 2, 3, 4, 5]
df['temperature'] = [30, 31, 32.5, 33, 31]
df['humidity'] = [10, 30, 50, 20, 40]
df['sensor'] = [0.22, 0.12, 0.14, 0.08, 0.04]

s1 = figure(title=None)

s1.x_ranges = {
    'reference units': Range1d(start=-1, end=6),
}
s1.y_ranges = {
    'temperature': Range1d(start=0, end=50),
    'humidity': Range1d(start=0, end=50),
    'sensor': Range1d(start=0, end=0.25)
}

s1.line(x=df['time'], y=df['temperature'], 
        x_range_name='reference_units', y_range_name="temperature",
        color='orange', legend='temperature')
s1.line(x=df['time'], y=df['humidity'], 
        x_range_name='reference_units', y_range_name="humidity",
        color="blue", alpha=0.5, legend='humidity')
s1.line(x=df['time'], y=df['sensor'], 
        x_range_name='reference_units', y_range_name="sensor",
        color="red", legend='sensory data')

s1.add_layout_x(LinearAxis(x_range_name='reference units', axis_label='Reference Units'), 'bottom')
s1.add_layout_y(LinearAxis(y_range_name='temperature', axis_label='Temperature'), 'left')
s1.add_layout_y(LinearAxis(y_range_name='humidity', axis_label='Humidity'), 'left')
s1.add_layout_y(LinearAxis(y_range_name='sensor', axis_label='Sensor'), 'right')

show(s1)
```



Discussion:
Hi @soupault Thanks for the detailed information. From your quick comment on gitter I thought you were proposing a new feature, but seeing the details, I think this is actually just a support issue. IIUC you just don't want the default axes created, because you want to control things more closely? If so there's two approaches I could suggest:
- definitely possible using `bokeh.models` since you have to put everything together from scratch. But there are some utils in `bokeh.plotting.helpers` that you could use to help put things together 
- staying with `bokeh.plotting.figure`, you can just instruct `figure` not to create any default axes, by passing, e.g., `x_axis_location=None` as an argument, and then add all the axes you want explicitly with `add_layout`

(side note: looking at the docs, I think this option is not documented well enough, if you'd be interested in contributing a PR to document it better, please let us know)

I'm going to close this issue, just because we are reserving the GH issue tracker for bug reports and feature requests to have some hope of keeping it manageable. If the above info is not enough to get started (or does not work) I'd encourage you to take the discussion to the public mailing list at:

https://groups.google.com/a/continuum.io/forum/#!forum/bokeh

In addition to devs, there's a larger community of users who can potentially help there. 


Resolving patch:
diff --git a/bokeh/plotting/figure.py b/bokeh/plotting/figure.py
--- a/bokeh/plotting/figure.py
+++ b/bokeh/plotting/figure.py
@@ -4,10 +4,10 @@
 logger = logging.getLogger(__name__)
 
 from ..io import curdoc, curstate
-from ..models import Axis, Grid, Legend, LogAxis, Plot
+from ..models import Axis, Grid, Legend, Plot
 from ..models import glyphs, markers
 from .helpers import (
-    _list_attr_splat, _get_range, _get_axis_class, _get_num_minor_ticks,
+    _list_attr_splat, _get_range, _process_axis_and_grid,
     _process_tools_arg, _glyph_function
 )
 
@@ -46,37 +46,8 @@ def __init__(self, *arg, **kw):
         self.x_range = _get_range(x_range)
         self.y_range = _get_range(y_range)
 
-        x_axiscls = _get_axis_class(x_axis_type, self.x_range)
-        if x_axiscls:
-            if x_axiscls is LogAxis:
-                self.x_mapper_type = 'log'
-            xaxis = x_axiscls(plot=self)
-            if hasattr(xaxis.ticker, 'num_minor_ticks'):
-                xaxis.ticker.num_minor_ticks = _get_num_minor_ticks(x_axiscls, x_minor_ticks)
-            axis_label = x_axis_label
-            if axis_label:
-                xaxis.axis_label = axis_label
-            xgrid = Grid(plot=self, dimension=0, ticker=xaxis.ticker); xgrid
-            if x_axis_location == "above":
-                self.above.append(xaxis)
-            elif x_axis_location == "below":
-                self.below.append(xaxis)
-
-        y_axiscls = _get_axis_class(y_axis_type, self.y_range)
-        if y_axiscls:
-            if y_axiscls is LogAxis:
-                self.y_mapper_type = 'log'
-            yaxis = y_axiscls(plot=self)
-            if hasattr(yaxis.ticker, 'num_minor_ticks'):
-                yaxis.ticker.num_minor_ticks = _get_num_minor_ticks(y_axiscls, y_minor_ticks)
-            axis_label = y_axis_label
-            if axis_label:
-                yaxis.axis_label = axis_label
-            ygrid = Grid(plot=self, dimension=1, ticker=yaxis.ticker); ygrid
-            if y_axis_location == "left":
-                self.left.append(yaxis)
-            elif y_axis_location == "right":
-                self.right.append(yaxis)
+        _process_axis_and_grid(self, x_axis_type, x_axis_location, x_minor_ticks, x_axis_label, self.x_range, 0)
+        _process_axis_and_grid(self, y_axis_type, y_axis_location, y_minor_ticks, y_axis_label, self.y_range, 1)
 
         tool_objs = _process_tools_arg(self, tools)
         self.add_tools(*tool_objs)
diff --git a/bokeh/plotting/helpers.py b/bokeh/plotting/helpers.py
--- a/bokeh/plotting/helpers.py
+++ b/bokeh/plotting/helpers.py
@@ -13,7 +13,7 @@
     BoxSelectTool, BoxZoomTool, CategoricalAxis,
     TapTool, CrosshairTool, DataRange1d, DatetimeAxis,
     FactorRange, Grid, HelpTool, HoverTool, LassoSelectTool, Legend, LinearAxis,
-    LogAxis, PanTool, Plot, PolySelectTool,
+    LogAxis, PanTool, PolySelectTool, ContinuousTicker,
     PreviewSaveTool, Range, Range1d, UndoTool, RedoTool, ResetTool, ResizeTool, Tool,
     WheelZoomTool, ColumnDataSource, GlyphRenderer)
 
@@ -247,6 +247,36 @@ def _tool_from_string(name):
 
         raise ValueError("unexpected tool name '%s', %s tools are %s" % (name, text, nice_join(matches)))
 
+
+def _process_axis_and_grid(plot, axis_type, axis_location, minor_ticks, axis_label, rng, dim):
+    axiscls = _get_axis_class(axis_type, rng)
+    if axiscls:
+
+        if axiscls is LogAxis:
+            # TODO (bev) this mapper type hinting is ugly
+            if dim == 0:
+                plot.x_mapper_type = 'log'
+            elif dim == 1:
+                plot.y_mapper_type = 'log'
+            else:
+                raise ValueError("received invalid dimension value: %r" % dim)
+
+        # this is so we can get a ticker off the axis, even if we discard it
+        axis = axiscls(plot=plot if axis_location else None)
+
+        if isinstance(axis.ticker, ContinuousTicker):
+            axis.ticker.num_minor_ticks = _get_num_minor_ticks(axiscls, minor_ticks)
+
+        axis_label = axis_label
+        if axis_label:
+            axis.axis_label = axis_label
+
+        grid = Grid(plot=plot, dimension=dim, ticker=axis.ticker); grid
+
+        if axis_location is not None:
+            getattr(plot, axis_location).append(axis)
+
+
 def _process_tools_arg(plot, tools):
     """ Adds tools to the plot object
 
@@ -291,110 +321,6 @@ def _process_tools_arg(plot, tools):
 
     return tool_objs
 
-def _new_xy_plot(x_range=None, y_range=None, plot_width=None, plot_height=None,
-                 x_axis_type="auto", y_axis_type="auto",
-                 x_axis_location="below", y_axis_location="left",
-                 x_minor_ticks='auto', y_minor_ticks='auto',
-                 tools="pan,wheel_zoom,box_zoom,save,resize,reset", **kw):
-    # Accept **kw to absorb other arguments which the actual factory functions
-    # might pass in, but that we don't care about
-
-    plot = Plot()
-    plot.title = kw.pop("title", "Plot")
-
-    plot.toolbar_location = kw.pop("toolbar_location", "above")
-
-    plot.x_range = _get_range(x_range)
-    plot.y_range = _get_range(y_range)
-
-    if plot_width: plot.plot_width = plot_width
-    if plot_height: plot.plot_height = plot_height
-
-    x_axiscls = _get_axis_class(x_axis_type, plot.x_range)
-    if x_axiscls:
-        if x_axiscls is LogAxis:
-            plot.x_mapper_type = 'log'
-        xaxis = x_axiscls(plot=plot)
-        xaxis.ticker.num_minor_ticks = _get_num_minor_ticks(x_axiscls, x_minor_ticks)
-        axis_label = kw.pop('x_axis_label', None)
-        if axis_label:
-            xaxis.axis_label = axis_label
-        xgrid = Grid(plot=plot, dimension=0, ticker=xaxis.ticker); xgrid
-        if x_axis_location == "above":
-            plot.above.append(xaxis)
-        elif x_axis_location == "below":
-            plot.below.append(xaxis)
-
-    y_axiscls = _get_axis_class(y_axis_type, plot.y_range)
-    if y_axiscls:
-        if y_axiscls is LogAxis:
-            plot.y_mapper_type = 'log'
-        yaxis = y_axiscls(plot=plot)
-        yaxis.ticker.num_minor_ticks = _get_num_minor_ticks(y_axiscls, y_minor_ticks)
-        axis_label = kw.pop('y_axis_label', None)
-        if axis_label:
-            yaxis.axis_label = axis_label
-        ygrid = Grid(plot=plot, dimension=1, ticker=yaxis.ticker); ygrid
-        if y_axis_location == "left":
-            plot.left.append(yaxis)
-        elif y_axis_location == "right":
-            plot.right.append(yaxis)
-
-    border_args = ["min_border", "min_border_top", "min_border_bottom", "min_border_left", "min_border_right"]
-    for arg in border_args:
-        if arg in kw:
-            setattr(plot, arg, kw.pop(arg))
-
-    fill_args = ["background_fill_color", "border_fill_color"]
-    for arg in fill_args:
-        if arg in kw:
-            setattr(plot, arg, kw.pop(arg))
-
-    style_arg_prefix = ["title", "outline"]
-    for prefix in style_arg_prefix:
-        for k in list(kw):
-            if k.startswith(prefix):
-                setattr(plot, k, kw.pop(k))
-
-    if 'toolbar_location' in list(kw):
-        plot.toolbar_location = kw.pop('toolbar_location')
-
-    tool_objs = []
-    temp_tool_str = str()
-
-    if isinstance(tools, list):
-        for tool in tools:
-            if isinstance(tool, Tool):
-                tool_objs.append(tool)
-            elif isinstance(tool, string_types):
-                temp_tool_str+=tool + ','
-            else:
-                raise ValueError("tool should be a string or an instance of Tool class")
-        tools = temp_tool_str
-
-    repeated_tools = []
-
-    for tool in re.split(r"\s*,\s*", tools.strip()):
-        # re.split will return empty strings; ignore them.
-        if tool == "":
-            continue
-
-        tool_obj = _tool_from_string(tool)
-        tool_obj.plot = plot
-
-        tool_objs.append(tool_obj)
-
-    plot.tools.extend(tool_objs)
-
-    for typename, group in itertools.groupby(sorted([ tool.__class__.__name__ for tool in plot.tools ])):
-        if len(list(group)) > 1:
-            repeated_tools.append(typename)
-
-    if repeated_tools:
-        warnings.warn("%s are being repeated" % ",".join(repeated_tools))
-
-    return plot
-
 class _list_attr_splat(list):
     def __setattr__(self, attr, value):
         for x in self:


Test patch:
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
@@ -98,6 +98,15 @@ def test_axis(self):
         p.right.append(ax4)
         self.assertEqual(set(p.axis), expected)
 
+    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')
+
+        p = plt.figure(y_axis_type='log')
+        p.circle([1, 2, 3], [1, 2, 3])
+        self.assertEqual(p.y_mapper_type, 'log')
+
     def test_xgrid(self):
         p = plt.figure()
         p.circle([1, 2, 3], [1, 2, 3])