From 441d3dbc90131e4f00aedc5aad1d0f6a91041bbb Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Wed, 16 Oct 2024 16:22:13 -0700 Subject: [PATCH] add popup position and anchor --- .../user_guide/13-Custom_Interactivity.ipynb | 49 +++++ holoviews/plotting/bokeh/callbacks.py | 176 ++++++++++++------ holoviews/streams.py | 21 ++- holoviews/tests/ui/bokeh/test_callback.py | 74 ++++++++ 4 files changed, 264 insertions(+), 56 deletions(-) diff --git a/examples/user_guide/13-Custom_Interactivity.ipynb b/examples/user_guide/13-Custom_Interactivity.ipynb index a88758f47a..1c6133acbf 100644 --- a/examples/user_guide/13-Custom_Interactivity.ipynb +++ b/examples/user_guide/13-Custom_Interactivity.ipynb @@ -483,6 +483,55 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `popup_position` can be set to one of the following options:\n", + "\n", + "- `top_right` (the default)\n", + "- `top_left`\n", + "- `bottom_left`\n", + "- `bottom_right`\n", + "- `right`\n", + "- `left`\n", + "- `top`\n", + "- `bottom`\n", + "\n", + "The `popup_anchor` is automatically determined based on the `popup_position`, but can also be manually set to one of the following predefined positions:\n", + "\n", + "- `top_left`, `top_center`, `top_right`\n", + "- `center_left`, `center_center`, `center_right`\n", + "- `bottom_left`, `bottom_center`, `bottom_right`\n", + "- `top`, `left`, `center`, `right`, `bottom`\n", + "\n", + "Alternatively, the `popup_anchor` can be specified as a tuple, using a mix of `start`, `center`, `end`, like `(\"start\", \"center\")`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hv.streams.Selection1D(\n", + " source=points,\n", + " popup=popup_stats,\n", + " popup_position=\"left\",\n", + " popup_anchor=\"right\"\n", + ")\n", + "\n", + "points.opts(\n", + " tools=[\"box_select\", \"lasso_select\", \"tap\"],\n", + " active_tools=[\"lasso_select\"],\n", + " size=6,\n", + " color=\"black\",\n", + " fill_color=None,\n", + " width=500,\n", + " height=500\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/holoviews/plotting/bokeh/callbacks.py b/holoviews/plotting/bokeh/callbacks.py index 89541b5955..0684feaef0 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -74,6 +74,17 @@ from ...util.warnings import warn from .util import bokeh33, convert_timestamp +POPUP_POSITION_ANCHOR = { + "top_right": "top_left", + "top_left": "top_right", + "bottom_left": "bottom_right", + "bottom_right": "bottom_left", + "right": "top_left", + "left": "top_right", + "top": "bottom", + "bottom": "top", +} + class Callback: """ @@ -610,9 +621,10 @@ def initialize(self, plot_id=None): } """], css_classes=["popup-close-btn"]) + self._popup_position = stream.popup_position self._panel = Panel( position=XY(x=np.nan, y=np.nan), - anchor="top_left", + anchor=stream.popup_anchor or POPUP_POSITION_ANCHOR.get(self._popup_position, 'top_left'), elements=[close_button], visible=False, styles={"zIndex": "1000"}, @@ -626,24 +638,56 @@ def _watch_position(self): geom_type = self.geom_type self.plot.state.on_event('selectiongeometry', self._update_selection_event) self.plot.state.js_on_event('selectiongeometry', CustomJS( - args=dict(panel=self._panel), + args=dict(panel=self._panel, popup_position=self.popup_position), code=f""" - export default ({{panel}}, cb_obj, _) => {{ - const el = panel.elements[1] - if ((el && !el.visible) || !cb_obj.final || ({geom_type!r} !== 'any' && cb_obj.geometry.type !== {geom_type!r})) {{ - return - }} - let pos; - if (cb_obj.geometry.type === 'point') {{ - pos = {{x: cb_obj.geometry.x, y: cb_obj.geometry.y}} - }} else if (cb_obj.geometry.type === 'rect') {{ - pos = {{x: cb_obj.geometry.x1, y: cb_obj.geometry.y1}} - }} else if (cb_obj.geometry.type === 'poly') {{ - pos = {{x: Math.max(...cb_obj.geometry.x), y: Math.max(...cb_obj.geometry.y)}} - }} - if (pos) {{ - panel.position.setv(pos) - }} + export default ({{panel, popup_position}}, cb_obj, _) => {{ + const el = panel.elements[1]; + if ((el && !el.visible) || !cb_obj.final || ({geom_type!r} !== 'any' && cb_obj.geometry.type !== {geom_type!r})) {{ + return; + }} + + let pos; + if (cb_obj.geometry.type === 'point') {{ + pos = {{x: cb_obj.geometry.x, y: cb_obj.geometry.y}}; + }} else if (cb_obj.geometry.type === 'rect') {{ + let x, y; + if (popup_position.includes('left')) {{ + x = cb_obj.geometry.x0; + }} else if (popup_position.includes('right')) {{ + x = cb_obj.geometry.x1; + }} else {{ + x = (cb_obj.geometry.x0 + cb_obj.geometry.x1) / 2; + }} + if (popup_position.includes('top')) {{ + y = cb_obj.geometry.y1; + }} else if (popup_position.includes('bottom')) {{ + y = cb_obj.geometry.y0; + }} else {{ + y = (cb_obj.geometry.y0 + cb_obj.geometry.y1) / 2; + }} + pos = {{x: x, y: y}}; + }} else if (cb_obj.geometry.type === 'poly') {{ + let x, y; + if (popup_position.includes('left')) {{ + x = Math.min(...cb_obj.geometry.x); + }} else if (popup_position.includes('right')) {{ + x = Math.max(...cb_obj.geometry.x); + }} else {{ + x = (Math.min(...cb_obj.geometry.x) + Math.max(...cb_obj.geometry.x)) / 2; + }} + if (popup_position.includes('top')) {{ + y = Math.max(...cb_obj.geometry.y); + }} else if (popup_position.includes('bottom')) {{ + y = Math.min(...cb_obj.geometry.y); + }} else {{ + y = (Math.min(...cb_obj.geometry.y) + Math.max(...cb_obj.geometry.y)) / 2; + }} + pos = {{x: x, y: y}}; + }} + + if (pos) {{ + panel.position.setv(pos); + }} }}""", )) @@ -1163,49 +1207,71 @@ def _watch_position(self): source = self.plot.handles['source'] renderer = self.plot.handles['glyph_renderer'] selected = self.plot.handles['selected'] + self.plot.state.js_on_event('selectiongeometry', CustomJS( - args=dict(panel=self._panel, renderer=renderer, source=source, selected=selected), + args=dict(panel=self._panel, renderer=renderer, source=source, selected=selected, popup_position=self._popup_position), code=""" - export default ({panel, renderer, source, selected}, cb_obj, _) => { - const el = panel.elements[1] - if ((el && !el.visible) || !cb_obj.final) { - return - } - let x, y, xs, ys; - let indices = selected.indices; - if (cb_obj.geometry.type == 'point') { - indices = indices.slice(-1) - } - if (renderer.glyph.x && renderer.glyph.y) { - xs = source.get_column(renderer.glyph.x.field) - ys = source.get_column(renderer.glyph.y.field) - } else if (renderer.glyph.right && renderer.glyph.top) { - xs = source.get_column(renderer.glyph.right.field) - ys = source.get_column(renderer.glyph.top.field) - } else if (renderer.glyph.x1 && renderer.glyph.y1) { - xs = source.get_column(renderer.glyph.x1.field) - ys = source.get_column(renderer.glyph.y1.field) - } else if (renderer.glyph.xs && renderer.glyph.ys) { - xs = source.get_column(renderer.glyph.xs.field) - ys = source.get_column(renderer.glyph.ys.field) - } - if (!xs || !ys) { return } - for (const i of indices) { - const tx = xs[i] - if (!x || (tx > x)) { - x = xs[i] + export default ({panel, renderer, source, selected, popup_position}, cb_obj, _) => { + const el = panel.elements[1]; + if ((el && !el.visible) || !cb_obj.final) { + return; + } + let x, y, xs, ys; + let indices = selected.indices; + if (cb_obj.geometry.type == 'point') { + indices = indices.slice(-1); + } + if (renderer.glyph.x && renderer.glyph.y) { + xs = source.get_column(renderer.glyph.x.field); + ys = source.get_column(renderer.glyph.y.field); + } else if (renderer.glyph.right && renderer.glyph.top) { + xs = source.get_column(renderer.glyph.right.field); + ys = source.get_column(renderer.glyph.top.field); + } else if (renderer.glyph.x1 && renderer.glyph.y1) { + xs = source.get_column(renderer.glyph.x1.field); + ys = source.get_column(renderer.glyph.y1.field); + } else if (renderer.glyph.xs && renderer.glyph.ys) { + xs = source.get_column(renderer.glyph.xs.field); + ys = source.get_column(renderer.glyph.ys.field); + } + if (!xs || !ys) { return; } + + let minX = null, maxX = null, minY = null, maxY = null; + + for (const i of indices) { + const tx = xs[i]; + const ty = ys[i]; + + if (minX === null || tx < minX) { minX = tx; } + if (maxX === null || tx > maxX) { maxX = tx; } + if (minY === null || ty < minY) { minY = ty; } + if (maxY === null || ty > maxY) { maxY = ty; } + } + + if (minX !== null && maxX !== null && minY !== null && maxY !== null) { + if (popup_position.includes('left')) { + x = minX; + } else if (popup_position.includes('right')) { + x = maxX; + } else { + x = (minX + maxX) / 2; } - const ty = ys[i] - if (!y || (ty > y)) { - y = ys[i] + + if (popup_position.includes('top')) { + y = maxY; + } else if (popup_position.includes('bottom')) { + y = minY; + } else { + y = (minY + maxY) / 2; } - } - if (x && y) { - panel.position.setv({x, y}) - } - }""", + + panel.position.setv({x, y}); + } + } + """, )) + def _get_position(self, event): el = self.plot.current_frame if isinstance(el, Dataset): diff --git a/holoviews/streams.py b/holoviews/streams.py index e129c8b431..93e2c9bcaa 100644 --- a/holoviews/streams.py +++ b/holoviews/streams.py @@ -22,6 +22,17 @@ # Types supported by Pointer derived streams pointer_types = (Number, str, tuple)+util.datetime_types +POPUP_POSITIONS = [ + "top_right", + "top_left", + "bottom_left", + "bottom_right", + "right", + "left", + "top", + "bottom", +] + class _SkipTrigger: pass @@ -1255,9 +1266,17 @@ class LinkedStream(Stream): supplying stream data. """ - def __init__(self, linked=True, popup=None, **params): + def __init__(self, linked=True, popup=None, popup_position=POPUP_POSITIONS[0], popup_anchor=None, **params): + if popup_position not in POPUP_POSITIONS: + raise ValueError( + f"Invalid popup_position: {popup_position!r}; " + f"expect one of {POPUP_POSITIONS}" + ) + super().__init__(linked=linked, **params) self.popup = popup + self.popup_position = popup_position + self.popup_anchor = popup_anchor class PointerX(LinkedStream): diff --git a/holoviews/tests/ui/bokeh/test_callback.py b/holoviews/tests/ui/bokeh/test_callback.py index 19cd5f0c43..f7d0b0be4a 100644 --- a/holoviews/tests/ui/bokeh/test_callback.py +++ b/holoviews/tests/ui/bokeh/test_callback.py @@ -473,6 +473,80 @@ def popup_form(index): expect(locator).not_to_have_text("lasso\n0") +@skip_popup +@pytest.mark.usefixtures("bokeh_backend") +def test_stream_popup_selection1d_box_select_right(serve_hv, points): + def popup_form(index): + if index: + return f"# lasso\n{len(index)}" + + hv.streams.Selection1D(source=points, popup=popup_form, popup_position="right", popup_anchor="left") + points.opts(tools=["box_select"], active_tools=["box_select"]) + + page = serve_hv(points) + hv_plot = page.locator('.bk-events') + expect(hv_plot).to_have_count(1) + + box = hv_plot.bounding_box() + start_x, start_y = box['x'] + 10, box['y'] + box['height'] - 10 + mid_x, mid_y = box['x'], box['y'] + end_x, end_y = box['x'], box['y'] + + # Perform lasso selection + page.mouse.move(start_x, start_y) + hv_plot.click() + page.mouse.down() + page.mouse.move(mid_x, mid_y) + page.mouse.move(end_x, end_y) + page.mouse.up() + + # Wait for popup to show + wait_until(lambda: expect(page.locator("#lasso")).to_have_count(1), page) + locator = page.locator("#lasso") + expect(locator).to_have_count(1) + expect(locator).not_to_have_text("lasso\n0") + + popup = locator.bounding_box() + assert popup['x'] + popup["width"] > mid_x # Should be towards the right + + +@skip_popup +@pytest.mark.usefixtures("bokeh_backend") +def test_stream_popup_selection1d_box_select_left(serve_hv, points): + def popup_form(index): + if index: + return f"# lasso\n{len(index)}" + + hv.streams.Selection1D(source=points, popup=popup_form, popup_position="left", popup_anchor="right") + points.opts(tools=["box_select"], active_tools=["box_select"]) + + page = serve_hv(points) + hv_plot = page.locator('.bk-events') + expect(hv_plot).to_have_count(1) + + box = hv_plot.bounding_box() + start_x, start_y = box['x'] + 10, box['y'] + box['height'] - 10 + mid_x, mid_y = box['x'], box['y'] + end_x, end_y = box['x'], box['y'] + + # Perform lasso selection + page.mouse.move(start_x, start_y) + hv_plot.click() + page.mouse.down() + page.mouse.move(mid_x, mid_y) + page.mouse.move(end_x, end_y) + page.mouse.up() + + # Wait for popup to show + wait_until(lambda: expect(page.locator("#lasso")).to_have_count(1), page) + locator = page.locator("#lasso") + expect(locator).to_have_count(1) + expect(locator).not_to_have_text("lasso\n0") + + popup = locator.bounding_box() + assert popup['x'] < mid_x # Should be towards the left + + @pytest.mark.usefixtures("bokeh_backend") def test_stream_subcoordinate_y_range(serve_hv, points): def cb(x_range, y_range):