Repository: bokeh/bokeh

Issue:
Pan and Zoom in categorical plots.
This seems a feature that people are asking for... related with #601 where a little discussion started.



Discussion:
So, under the covers categorical ranges use synthetic numerical ranges. It's probably sufficient to let let the pan and zoom tools have access to manipulate the synthetic ranges directly when a categorical range is detected. 


Resolving patch:
diff --git a/bokeh/plotting_helpers.py b/bokeh/plotting_helpers.py
--- a/bokeh/plotting_helpers.py
+++ b/bokeh/plotting_helpers.py
@@ -319,8 +319,7 @@ def _tool_from_string(name):
 
 
 def _process_tools_arg(plot, tools):
-    """ Adds tools to the plot object skipping those specified in
-    tools_to_remove.
+    """ Adds tools to the plot object
 
     Args:
         plot (Plot): instance of a plot object
@@ -334,12 +333,6 @@ def _process_tools_arg(plot, tools):
     tool_objs = []
     temp_tool_str = ""
     repeated_tools = []
-    removed_tools = []
-    tools_to_remove = []
-    # Remove pan/zoom tools in case of categorical axes
-    if isinstance(plot.x_range, FactorRange) or \
-            isinstance(plot.y_range, FactorRange):
-        tools_to_remove = ['pan', 'zoom']
 
     if isinstance(tools, (list, tuple)):
         for tool in tools:
@@ -356,10 +349,6 @@ def _process_tools_arg(plot, tools):
         if tool == "":
             continue
 
-        if any(x in tool for x in tools_to_remove):
-            removed_tools.append(tool)
-            continue
-
         tool_obj = _tool_from_string(tool)
         tool_objs.append(tool_obj)
 
@@ -371,10 +360,6 @@ def _process_tools_arg(plot, tools):
     if repeated_tools:
         warnings.warn("%s are being repeated" % ",".join(repeated_tools))
 
-    if removed_tools:
-        warnings.warn("categorical plots do not support pan and zoom operations.\n"
-                      "Removing tool(s): %s" % ', '.join(removed_tools))
-
     return tool_objs
 
 
@@ -459,12 +444,7 @@ def _new_xy_plot(x_range=None, y_range=None, plot_width=None, plot_height=None,
                 raise ValueError("tool should be a string or an instance of Tool class")
         tools = temp_tool_str
 
-    # Remove pan/zoom tools in case of categorical axes
-    remove_pan_zoom = (isinstance(plot.x_range, FactorRange) or
-                       isinstance(plot.y_range, FactorRange))
-
     repeated_tools = []
-    removed_tools = []
 
     for tool in re.split(r"\s*,\s*", tools.strip()):
         # re.split will return empty strings; ignore them.
@@ -474,10 +454,6 @@ def _new_xy_plot(x_range=None, y_range=None, plot_width=None, plot_height=None,
         tool_obj = _tool_from_string(tool)
         tool_obj.plot = plot
 
-        if remove_pan_zoom and ("pan" in tool or "zoom" in tool):
-            removed_tools.append(tool)
-            continue
-
         tool_objs.append(tool_obj)
 
     plot.tools.extend(tool_objs)
@@ -489,10 +465,6 @@ def _new_xy_plot(x_range=None, y_range=None, plot_width=None, plot_height=None,
     if repeated_tools:
         warnings.warn("%s are being repeated" % ",".join(repeated_tools))
 
-    if removed_tools:
-        warnings.warn("categorical plots do not support pan and zoom operations.\n"
-                      "Removing tool(s): %s" %', '.join(removed_tools))
-
     return plot
 
 
diff --git a/examples/plotting/file/unemployment.py b/examples/plotting/file/unemployment.py
--- a/examples/plotting/file/unemployment.py
+++ b/examples/plotting/file/unemployment.py
@@ -38,7 +38,7 @@
 
 output_file('unemployment.html')
 
-TOOLS = "resize,hover,save"
+TOOLS = "resize,hover,save,pan,box_zoom,wheel_zoom"
 
 p = figure(title="US Unemployment (1948 - 2013)",
     x_range=years, y_range=list(reversed(months)),


Test patch:
diff --git a/bokeh/charts/tests/test_chart.py b/bokeh/charts/tests/test_chart.py
--- a/bokeh/charts/tests/test_chart.py
+++ b/bokeh/charts/tests/test_chart.py
@@ -139,21 +139,7 @@ def check_tools_scenario(self, base_args, scenarios, categorical=False):
         for tools, expected_tools in scenarios:
             base_args['tools'] = tools
             chart = Chart(**base_args)
-
-            if categorical:
-                chart.x_range = FactorRange()
-                chart.tools = []
-                chart.create_tools(chart._options.tools)
-                self.compare_tools(chart.tools, expected_tools)
-
-                chart = Chart(**base_args)
-                chart.y_range = FactorRange()
-                chart.tools = []
-                chart.create_tools(chart._options.tools)
-                self.compare_tools(chart.tools, expected_tools)
-
-            else:
-                self.compare_tools(chart.tools, expected_tools)
+            self.compare_tools(chart.tools, expected_tools)
 
     def compare_tools(self, tools, expected_tools):
         self.assertEqual(len(tools), len(expected_tools))
@@ -178,22 +164,10 @@ def test_chart_tools_linear(self, mock_warn):
 
         self.check_tools_scenario(base_args, scenarios)
 
-        # need to change the expected tools because categorical scales
-        # automatically removes pan and zoom tools
-        expected = [
-            [PreviewSaveTool, ResizeTool, ResetTool], [],
-            [ResizeTool, ResetTool, LassoSelectTool],
-        ]
-        scenarios = zip(
-                [True, False, "resize,pan,box_zoom,reset,lasso_select"],
-                expected
-        )
         self.check_tools_scenario(base_args, scenarios, categorical=True)
 
         msg_repeat = "LassoSelectTool are being repeated"
-        msg_removed = "categorical plots do not support pan and zoom operations.\n" \
-                      "Removing tool(s): pan, box_zoom"
-        expected_tools = [ResizeTool, ResetTool, LassoSelectTool, LassoSelectTool]
+        expected_tools = [ResizeTool, PanTool, BoxZoomTool, ResetTool, LassoSelectTool, LassoSelectTool]
         mock_warn.reset_mock()
 
         # Finally check repeated tools
@@ -206,5 +180,4 @@ def test_chart_tools_linear(self, mock_warn):
 
         self.compare_tools(chart.tools, expected_tools)
         mock_warn.assert_any_call(msg_repeat)
-        mock_warn.assert_any_call(msg_removed)
 
diff --git a/bokeh/tests/test_plotting.py b/bokeh/tests/test_plotting.py
--- a/bokeh/tests/test_plotting.py
+++ b/bokeh/tests/test_plotting.py
@@ -138,15 +138,5 @@ def test_tools(self):
         for i, _type in enumerate(expected):
             self.assertIsInstance(fig.tools[i], _type)
 
-        # need to change the expected tools because categorical scales
-        # automatically removes pan and zoom tools
-        factors = ["a", "b", "c", "d", "e", "f", "g", "h"]
-        fig = plt.figure(tools=TOOLS, y_range=factors)
-        expected = [ResizeTool, ResetTool, LassoSelectTool]
-        self.assertEqual(len(fig.tools), len(expected))
-        for i, _type in enumerate(expected):
-            self.assertIsInstance(fig.tools[i], _type)
-
-
 if __name__ == "__main__":
     unittest.main()