Repository: bokeh/bokeh

Issue:
[FEATURE] add a LineEditTool
I would like to have a **LineEditTool** for editing line glyphs, similar to the PolyEditTool.

For my specific need I have time-series data and I want to be able to edit the y-values only, leaving the x-values in place. 

I have had a go to see if I could create the tool, mainly to learn how bokeh works and to learn how to use JS/Typescript.

I created both what I will call **TimeSeries** mode where the x-axis values are frozen and a normal **LineEdit** mode where each point is free to move anywhere. 

If you think it would be worthwhile I would be more than happy to work on getting this feature included.

As mentioned above I am extremely new to TypeScript/JS. While I could get the code to work by looking at the PolyEditTool code,  I will likely need some help to make sure the code is up to scratch.

Attached are some gifs showing the results. 

The only difference between the two is that ```freeze_x=True``` is passed to the LineEditTool constructor on the second example

![line_edit_tool](https://user-images.githubusercontent.com/2906750/80563960-e814e180-8a2f-11ea-9963-6f5520f176b3.gif)

![timeseries_edit_tool](https://user-images.githubusercontent.com/2906750/80563936-da5f5c00-8a2f-11ea-8469-8940f81cc503.gif)

``` python
from bokeh.models import LineEditTool
from bokeh.plotting import figure, output_file, show
from bokeh.io import curdoc, show


p = figure(
    x_range=(0, 10), y_range=(0, 10), width=400, height=400, title="TimeSeries Edit Tool"
)

l1 = p.line(
    [1, 2, 3, 4, 5, 6, 7, 8, 9],
    [1, 2, 3, 4, 5, 4, 3, 2, 1],
    color="green",
    alpha=1.0,
    line_width=5,
)
l2 = p.line(
    [1, 2, 3, 4, 5, 6, 7, 8, 9],
    [8, 8, 8, 8, 8, 8, 8, 8, 8],
    color="blue",
    alpha=1.0,
    line_width=5,
)
c1 = p.circle([], [], size=10, color="red")

ledit_tool = LineEditTool(renderers=[l1, l2], intersection_renderer=c1, freeze_x=True)
p.add_tools(ledit_tool)

curdoc().add_root(p)
show(p)
```



Discussion:

Resolving patch:
diff --git a/bokeh/core/validation/errors.py b/bokeh/core/validation/errors.py
--- a/bokeh/core/validation/errors.py
+++ b/bokeh/core/validation/errors.py
@@ -89,6 +89,12 @@
 1024 *(CDSVIEW_FILTERS_WITH_CONNECTED)*
     ``CDSView`` filters are not compatible with glyphs with connected topology such as Line or Patch.
 
+1025 *(INCOMPATIBLE_LINE_EDIT_RENDERER)*
+    The ``LineEditTool`` renderers may only reference ``MultiLine`` and ``Line`` glyph models.
+
+1026 *(INCOMPATIBLE_LINE_EDIT_INTERSECTION_RENDERER)*
+    The ``LineEditTool`` intersection_enderer may only reference ``LineGlyph`` models.
+
 9999 *(EXT)*
     Indicates that a custom error check has failed.
 
@@ -133,6 +139,8 @@
     1022: ("MIN_PREFERRED_MAX_WIDTH",                            "Expected min_width <= width <= max_width"),
     1023: ("MIN_PREFERRED_MAX_HEIGHT",                           "Expected min_height <= height <= max_height"),
     1024: ("CDSVIEW_FILTERS_WITH_CONNECTED",                     "CDSView filters are not compatible with glyphs with connected topology such as Line or Patch"),
+    1025: ("INCOMPATIBLE_LINE_EDIT_RENDERER",                    "LineEditTool renderers may only reference MultiLine and Line glyph models"),
+    1026: ("INCOMPATIBLE_LINE_EDIT_INTERSECTION_RENDERER",       "LineEditTool intersection_renderer may only reference LineGlyph models"),
     9999: ("EXT",                                                "Custom extension reports error"),
 }
 
diff --git a/bokeh/models/tools.py b/bokeh/models/tools.py
--- a/bokeh/models/tools.py
+++ b/bokeh/models/tools.py
@@ -73,6 +73,8 @@
 from ..core.validation import error
 from ..core.validation.errors import (
     INCOMPATIBLE_BOX_EDIT_RENDERER,
+    INCOMPATIBLE_LINE_EDIT_INTERSECTION_RENDERER,
+    INCOMPATIBLE_LINE_EDIT_RENDERER,
     INCOMPATIBLE_POINT_DRAW_RENDERER,
     INCOMPATIBLE_POLY_DRAW_RENDERER,
     INCOMPATIBLE_POLY_EDIT_RENDERER,
@@ -83,7 +85,7 @@
 from ..util.string import nice_join
 from .annotations import BoxAnnotation, PolyAnnotation
 from .callbacks import Callback
-from .glyphs import MultiLine, Patches, Rect, XYGlyph
+from .glyphs import Line, LineGlyph, MultiLine, Patches, Rect, XYGlyph
 from .layouts import LayoutDOM
 from .ranges import Range1d
 from .renderers import GlyphRenderer, Renderer
@@ -108,6 +110,7 @@
     'Inspection',
     'Gesture',
     'LassoSelectTool',
+    'LineEditTool',
     'PanTool',
     'PointDrawTool',
     'PolyDrawTool',
@@ -1506,6 +1509,60 @@ def _check_compatible_renderers(self):
                                     for renderer in incompatible_renderers)
             return "%s glyph type(s) found." % glyph_types
 
+
+class LineEditTool(EditTool, Drag, Tap):
+    ''' *toolbar icon*: |line_edit_icon|
+
+    The LineEditTool allows editing the intersection points of one or more ``Line`` glyphs.
+    Glyphs to be edited are defined via the ``renderers``
+    property and a renderer for the intersections is set via the ``intersection_renderer``
+    property (must render a point-like Glyph (a subclass of ``XYGlyph``).
+
+    The tool will modify the columns on the data source corresponding to the
+    ``x`` and ``y`` values of the glyph. Any additional columns in the data
+    source will be padded with the declared``empty_value``, when adding a new
+    point.
+
+    The supported actions include:
+
+    * Show intersections: Double tap an existing line
+
+    * Move point: Drag an existing point and let go of the mouse button to
+      release it.
+
+    .. |line_edit_icon| image:: /_images/icons/LineEdit.png
+        :height: 24px
+     '''
+
+    intersection_renderer = Instance(GlyphRenderer, help="""
+    The renderer used to render the intersections of a selected line
+    """)
+
+    dimensions = Enum(Dimensions, default="both", help="""
+    Which dimensions this edit tool is constrained to act in. By default
+    the line edit tool allows moving points in any dimension, but can be
+    configured to only allow horizontal movement across the width of the
+    plot, or vertical across the height of the plot.
+    """)
+
+    @error(INCOMPATIBLE_LINE_EDIT_INTERSECTION_RENDERER)
+    def _check_compatible_intersection_renderer(self):
+        glyph = self.intersection_renderer.glyph
+        if not isinstance(glyph, LineGlyph):
+            return "glyph type %s found." % type(glyph).__name__
+
+    @error(INCOMPATIBLE_LINE_EDIT_RENDERER)
+    def _check_compatible_renderers(self):
+        incompatible_renderers = []
+        for renderer in self.renderers:
+            if not isinstance(renderer.glyph, (Line,)):
+                incompatible_renderers.append(renderer)
+        if incompatible_renderers:
+            glyph_types = ', '.join(type(renderer.glyph).__name__
+                                    for renderer in incompatible_renderers)
+            return "%s glyph type(s) found." % glyph_types
+
+#
 #-----------------------------------------------------------------------------
 # Dev API
 #-----------------------------------------------------------------------------
@@ -1549,6 +1606,7 @@ def _check_compatible_renderers(self):
 Tool.register_alias("reset", lambda: ResetTool())
 Tool.register_alias("help", lambda: HelpTool())
 Tool.register_alias("box_edit", lambda: BoxEditTool())
+Tool.register_alias("line_edit", lambda: LineEditTool())
 Tool.register_alias("point_draw", lambda: PointDrawTool())
 Tool.register_alias("poly_draw", lambda: PolyDrawTool())
 Tool.register_alias("poly_edit", lambda: PolyEditTool())


Test patch:
diff --git a/bokehjs/test/codebase/size.ts b/bokehjs/test/codebase/size.ts
--- a/bokehjs/test/codebase/size.ts
+++ b/bokehjs/test/codebase/size.ts
@@ -7,13 +7,13 @@ const build_dir = path.normalize(`${__dirname}/../..`) // build/test/codebase ->
 
 const LIMITS: {[key: string]: number} = {
   // es2017
-  "js/bokeh.min.js":          670,
+  "js/bokeh.min.js":          681,
   "js/bokeh-widgets.min.js":  250,
   "js/bokeh-tables.min.js":   270,
   "js/bokeh-api.min.js":      90,
   "js/bokeh-gl.min.js":       70,
   // legacy (es5)
-  "js/bokeh.legacy.min.js":         800,
+  "js/bokeh.legacy.min.js":         812,
   "js/bokeh-widgets.legacy.min.js": 260,
   "js/bokeh-tables.legacy.min.js":  270,
   "js/bokeh-api.legacy.min.js":      90,
diff --git a/tests/unit/bokeh/models/test_tools.py b/tests/unit/bokeh/models/test_tools.py
--- a/tests/unit/bokeh/models/test_tools.py
+++ b/tests/unit/bokeh/models/test_tools.py
@@ -57,6 +57,7 @@ def test_Tool_from_string() -> None:
     assert isinstance(Tool.from_string("ybox_select"), t.BoxSelectTool)
     assert isinstance(Tool.from_string("poly_select"), t.PolySelectTool)
     assert isinstance(Tool.from_string("lasso_select"), t.LassoSelectTool)
+    assert isinstance(Tool.from_string("line_edit"), t.LineEditTool)
     assert isinstance(Tool.from_string("box_zoom"), t.BoxZoomTool)
     assert isinstance(Tool.from_string("xbox_zoom"), t.BoxZoomTool)
     assert isinstance(Tool.from_string("ybox_zoom"), t.BoxZoomTool)