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 56b0491202..f58489be50 100644 --- a/holoviews/plotting/bokeh/callbacks.py +++ b/holoviews/plotting/bokeh/callbacks.py @@ -75,6 +75,17 @@ from ...util.warnings import warn from .util import BOKEH_GE_3_3_0, convert_timestamp +POPUP_POSITION_ANCHOR = { + "top_right": "bottom_left", + "top_left": "bottom_right", + "bottom_left": "top_right", + "bottom_right": "top_left", + "right": "top_left", + "left": "top_right", + "top": "bottom", + "bottom": "top", +} + class Callback: """ @@ -611,9 +622,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[self._popup_position], elements=[close_button], visible=False, styles={"zIndex": "1000"}, @@ -627,24 +639,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); + }} }}""", )) @@ -734,7 +778,7 @@ async def _process_selection_event(self): position = self._get_position(event) if event else None if position: self._panel.position = XY(**position) - if self.plot.comm: # update Jupyter Notebook + if self.plot.comm: # update Jupyter Notebooks push_on_root(self.plot.root.ref['id']) return @@ -1173,59 +1217,106 @@ 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) { - let ix = xs[i] - let iy = ys[i] - let tx, ty - if (typeof ix === 'number') { - tx = ix - ty = iy - } else { - while (ix.length && (typeof ix[0] !== 'number')) { - ix = ix[0] - iy = iy[0] - } - tx = Math.max(...ix) - ty = Math.max(...iy) + export default ({panel, renderer, source, selected, popup_position}, cb_obj, _) => { + panel.visible = false; // Hide the popup panel so it doesn't show in previous location + const el = panel.elements[1]; + if ((el && !el.visible) || !cb_obj.final) { + return; } - if (!x || (tx > x)) { - x = tx + let x, y, xs, ys; + let indices = selected.indices; + if (cb_obj.geometry.type == 'point') { + indices = indices.slice(-1); } - if (!y || (ty > y)) { - y = ty + + 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 (x && y) { - panel.position.setv({x, y}) - } - }""", + + if (!xs || !ys || !indices.length) { + return; + } + + let minX, maxX, minY, maxY; + + // Loop over each index in the selection and find the corresponding polygon coordinates + for (const i of indices) { + let ix = xs[i]; + let iy = ys[i]; + let tx, ty; + + // Check if the values are numbers or nested arrays + if (typeof ix === 'number') { + tx = ix; + ty = iy; + } else { + // Drill down into nested arrays until we find the number values + while (ix.length && typeof ix[0] !== 'number') { + ix = ix[0]; + iy = iy[0]; + } + + // Set tx and ty based on the popup position preferences + if (popup_position.includes('left')) { + tx = Math.min(...ix); + } else if (popup_position.includes('right')) { + tx = Math.max(...ix); + } else { + tx = (Math.min(...ix) + Math.max(...ix)) / 2; + } + + if (popup_position.includes('top')) { + ty = Math.max(...iy); + } else if (popup_position.includes('bottom')) { + ty = Math.min(...iy); + } else { + ty = (Math.min(...iy) + Math.max(...iy)) / 2; + } + } + + // Update the min/max values for x and y + if (minX === undefined || tx < minX) { minX = tx; } + if (maxX === undefined || tx > maxX) { maxX = tx; } + if (minY === undefined || ty < minY) { minY = ty; } + if (maxY === undefined || ty > maxY) { maxY = ty; } + } + + // Set x and y based on popup_position preference + if (popup_position.includes('left')) { + x = minX; + } else if (popup_position.includes('right')) { + x = maxX; + } else { + x = (minX + maxX) / 2; + } + + if (popup_position.includes('top')) { + y = maxY; + } else if (popup_position.includes('bottom')) { + y = minY; + } else { + y = (minY + maxY) / 2; + } + + // Set the popup position and make it visible + panel.position.setv({x, y}); + panel.visible = true; + } + """, )) def _get_position(self, event): diff --git a/holoviews/streams.py b/holoviews/streams.py index e129c8b431..5cbbe41f7c 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="top_right", 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 3d37f115ec..befa045480 100644 --- a/holoviews/tests/ui/bokeh/test_callback.py +++ b/holoviews/tests/ui/bokeh/test_callback.py @@ -6,7 +6,15 @@ import holoviews as hv from holoviews import Curve, DynamicMap, Scatter from holoviews.plotting.bokeh.util import BOKEH_GE_3_4_0 -from holoviews.streams import BoundsX, BoundsXY, BoundsY, Lasso, MultiAxisTap, RangeXY +from holoviews.streams import ( + BoundsX, + BoundsXY, + BoundsY, + Lasso, + MultiAxisTap, + RangeXY, + Tap, +) from .. import expect, wait_until @@ -184,7 +192,9 @@ def test_multi_axis_tap(serve_hv): def test(): assert s.xs == {'x': 11.560240963855422} - assert s.ys == {'y1': 18.642857142857146, 'y2': 78.71428571428572} + assert len(s.ys) == 2 + assert np.isclose(s.ys["y1"], 18.642857142857146) + assert np.isclose(s.ys["y2"], 78.71428571428572) wait_until(test, page) @@ -208,7 +218,10 @@ def test_multi_axis_tap_datetime(serve_hv): def test(): assert s.xs == {'x': np.datetime64('2024-01-12T13:26:44.819277')} - assert s.ys == {'y1': 18.13070539419087, 'y2': 76.551867219917} + assert s.xs == {'x': np.datetime64('2024-01-12T13:26:44.819277')} + assert len(s.ys) == 2 + assert np.isclose(s.ys["y1"], 18.130705394191) + assert np.isclose(s.ys["y2"], 76.551867219917) wait_until(test, page) @@ -248,285 +261,6 @@ def range_function(x_range, y_range): assert BOUND_COUNT[0] == 1 -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup(serve_hv): - def popup_form(name): - return f"# {name}" - - points = hv.Points(np.random.randn(10, 2)).opts(tools=["tap"]) - hv.streams.Tap(source=points, popup=popup_form("Tap")) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - hv_plot.click() - expect(hv_plot).to_have_count(1) - - locator = page.locator(".markdown") - expect(locator).to_have_count(1) - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_polygons_tap(serve_hv): - def popup_form(name): - return f"# {name}" - - points = hv.Polygons([(0, 0), (0, 1), (1, 1), (1, 0)]).opts(tools=["tap"]) - hv.streams.Tap(source=points, popup=popup_form("Tap")) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - hv_plot.click() - expect(hv_plot).to_have_count(1) - - locator = page.locator(".markdown") - expect(locator).to_have_count(1) - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_polygons_selection1d(serve_hv): - def popup_form(name): - return f"# {name}" - - points = hv.Polygons([(0, 0), (0, 1), (1, 1), (1, 0)]).opts(tools=["tap"]) - hv.streams.Selection1D(source=points, popup=popup_form("Tap")) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - hv_plot.click() - expect(hv_plot).to_have_count(1) - - locator = page.locator(".markdown") - expect(locator).to_have_count(1) - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_none(serve_hv, points): - def popup_form(name): - return - - hv.streams.Tap(source=points, popup=popup_form("Tap")) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - expect(hv_plot).to_have_count(1) - - bbox = hv_plot.bounding_box() - hv_plot.click() - - page.mouse.move(bbox['x']+100, bbox['y']+100) - page.mouse.down() - page.mouse.move(bbox['x']+150, bbox['y']+150, steps=5) - page.mouse.up() - - locator = page.locator("#tap") - expect(locator).to_have_count(0) - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_callbacks(serve_hv): - def popup_form(x, y): - return pn.widgets.Button(name=f"{x},{y}") - - points = hv.Points(np.random.randn(10, 2)).opts(tools=["tap"]) - hv.streams.Tap(source=points, popup=popup_form) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - hv_plot.click() - expect(hv_plot).to_have_count(1) - - locator = page.locator(".bk-btn") - expect(locator).to_have_count(2) - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_async_callbacks(serve_hv): - async def popup_form(x, y): - return pn.widgets.Button(name=f"{x},{y}") - - points = hv.Points(np.random.randn(10, 2)).opts(tools=["tap"]) - hv.streams.Tap(source=points, popup=popup_form) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - hv_plot.click() - expect(hv_plot).to_have_count(1) - - locator = page.locator(".bk-btn") - expect(locator).to_have_count(2) - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_visible(serve_hv, points): - def popup_form(x, y): - def hide(_): - col.visible = False - button = pn.widgets.Button( - name=f"{x},{y}", - on_click=hide, - css_classes=["custom-button"] - ) - col = pn.Column(button) - return col - - points = points.opts(tools=["tap"]) - hv.streams.Tap(source=points, popup=popup_form) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - hv_plot.click() - expect(hv_plot).to_have_count(1) - - # initial appearance - locator = page.locator(".bk-btn") - expect(locator).to_have_count(2) - expect(locator.first).to_be_visible() - - # click custom button to hide - locator = page.locator(".custom-button") - locator.click() - locator = page.locator(".bk-btn") - expect(locator.first).not_to_be_visible() - - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_close_button(serve_hv, points): - def popup_form(x, y): - return "Hello" - - points = points.opts(tools=["tap", "box_select"]) - hv.streams.Tap(source=points, popup=popup_form) - hv.streams.BoundsXY(source=points, popup=popup_form) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - expect(hv_plot).to_have_count(1) - hv_plot.click() - - locator = page.locator(".bk-btn.bk-btn-default") - expect(locator).to_have_count(1) - expect(locator).to_be_visible() - page.click(".bk-btn.bk-btn-default") - expect(locator).not_to_be_visible() - - hv_plot.click() - locator = page.locator(".bk-btn.bk-btn-default") - expect(locator).to_have_count(1) - expect(locator).to_be_visible() - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_selection1d_undefined(serve_hv, points): - hv.streams.Selection1D(source=points) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - expect(hv_plot).to_have_count(1) - hv_plot.click() # should not raise any error; properly guarded - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_selection1d_tap(serve_hv, points): - def popup_form(index): - return "# Tap" - - points = points.opts(hit_dilation=5) - hv.streams.Selection1D(source=points, popup=popup_form) - points.opts(tools=["tap"], active_tools=["tap"]) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - expect(hv_plot).to_have_count(1) - hv_plot.click() - - locator = page.locator("#tap") - expect(locator).to_have_count(1) - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_noncallable_reappear(serve_hv, points): - def popup_form(name): - text_input = pn.widgets.TextInput(name='Description') - button = pn.widgets.Button( - name='Save', - on_click=lambda _: layout.param.update(visible=False), - button_type="primary" - ) - layout = pn.Column(f'# {name}', text_input, button) - return layout - - points = points.opts(hit_dilation=5) - hv.streams.Tap(source=points, popup=popup_form('Tap')) - points.opts(tools=["tap"], active_tools=["tap"]) - - page = serve_hv(points) - hv_plot = page.locator('.bk-events') - expect(hv_plot).to_have_count(1) - - hv_plot.click() - - locator = page.locator("#tap") - expect(locator).to_have_count(1) - locator = page.locator(".bk-btn.bk-btn-primary") - expect(locator).to_have_count(1) - expect(locator).to_be_visible() - - page.click(".bk-btn.bk-btn-primary") - expect(locator).not_to_be_visible() - - hv_plot.click() - - locator = page.locator("#tap") - expect(locator).to_have_count(1) - locator = page.locator(".bk-btn.bk-btn-primary") - expect(locator).to_have_count(1) - expect(locator).to_be_visible() - - -@skip_popup -@pytest.mark.usefixtures("bokeh_backend") -def test_stream_popup_selection1d_lasso_select(serve_hv, points): - def popup_form(index): - if index: - return f"# lasso\n{len(index)}" - - hv.streams.Selection1D(source=points, popup=popup_form) - points.opts(tools=["tap", "lasso_select"], active_tools=["lasso_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'] + 1, box['y'] + box['height'] - 1 - mid_x, mid_y = box['x'] + 1, box['y'] + 1 - end_x, end_y = box['x'] + box['width'] - 1, box['y'] + 1 - - 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_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") - - @pytest.mark.usefixtures("bokeh_backend") def test_stream_subcoordinate_y_range(serve_hv, points): def cb(x_range, y_range): @@ -555,3 +289,244 @@ def cb(x_range, y_range): expected_xrange = (7.008849557522124, 63.95575221238938) expected_yrange = (0.030612244897959183, 1.0918367346938775) wait_until(lambda: stream.x_range == expected_xrange and stream.y_range == expected_yrange, page) + + +@pytest.mark.usefixtures("bokeh_backend") +@skip_popup +class TestPopup: + def _select_points_based_on_tool(self, tool, page, plot): + """Helper method to perform point selection based on tool type.""" + box = plot.bounding_box() + + if tool == "box_select": + start_x, start_y = box['x'] + 90, box['y'] + 90 + end_x, end_y = box['x'] + 170, box['y'] + 125 + page.mouse.move(start_x, start_y) + plot.click() + page.mouse.down() + page.mouse.move(end_x, end_y) + page.mouse.up() + elif tool == "lasso_select": + start_x, start_y = box['x'] + 1, box['y'] + box['height'] - 1 + mid_x, mid_y = box['x'] + 1, box['y'] + 1 + end_x, end_y = box['x'] + box['width'] - 1, box['y'] + 1 + page.mouse.move(start_x, start_y) + plot.click() + page.mouse.down() + page.mouse.move(mid_x, mid_y) + page.mouse.move(end_x, end_y) + page.mouse.up() + elif tool == "tap": + plot.click() + + def _get_popup_distances_relative_to_bbox(self, popup_box, plot_box): + return { + 'left': abs(popup_box['x'] - plot_box['x']), + 'right': abs((popup_box['x'] + popup_box['width']) - (plot_box['x'] + plot_box['width'])), + 'top': abs(popup_box['y'] - plot_box['y']), + 'bottom': abs((popup_box['y'] + popup_box['height']) - (plot_box['y'] + plot_box['height'])) + } + + def _verify_popup_position(self, distances, popup_position): + if "right" in popup_position: + assert distances['right'] <= distances['left'] + elif "left" in popup_position: + assert distances['left'] <= distances['right'] + + if "top" in popup_position: + assert distances['top'] <= distances['bottom'] + elif "bottom" in popup_position: + assert distances['bottom'] <= distances['top'] + + def _serve_plot(self, serve_hv, plot): + page = serve_hv(plot) + hv_plot = page.locator('.bk-events') + expect(hv_plot).to_have_count(1) + return page, hv_plot + + def _locate_popup(self, page, count=1): + locator = page.locator(".markdown") + expect(locator).to_have_count(count) + return locator + + def test_basic(self, serve_hv, points): + def popup_form(name): + return f"# {name}" + + points.opts(tools=["tap"]) + hv.streams.Tap(source=points, popup=popup_form("Tap")) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool("tap", page, hv_plot) + self._locate_popup(page) + + @pytest.mark.parametrize("popup_position", [ + "top_right", "top_left", "bottom_left", "bottom_right", + "right", "left", "top", "bottom" + ]) + def test_polygons_tap(self, serve_hv, popup_position): + def popup_form(name): + return "# selection" + + points = hv.Polygons([(0, 0), (0, 1), (1, 1), (1, 0)]).opts(tools=["tap"]) + hv.streams.Tap(source=points, popup=popup_form("Tap"), popup_position=popup_position) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool("tap", page, hv_plot) + locator = self._locate_popup(page) + + box = hv_plot.bounding_box() + popup_box = locator.bounding_box() + distances = self._get_popup_distances_relative_to_bbox(popup_box, box) + self._verify_popup_position(distances, popup_position) + + def test_return_none(self, serve_hv, points): + def popup_form(name): + return None + + hv.streams.Tap(source=points, popup=popup_form("Tap")) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool("tap", page, hv_plot) + self._locate_popup(page, count=0) + + def test_callbacks(self, serve_hv, points): + def popup_form(x, y): + return pn.widgets.Button(name=f"{x},{y}") + + points.opts(tools=["tap"]) + hv.streams.Tap(source=points, popup=popup_form) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool("tap", page, hv_plot) + locator = page.locator(".bk-btn") + expect(locator).to_have_count(2) + + def test_async_callbacks(self, serve_hv, points): + async def popup_form(x, y): + return pn.widgets.Button(name=f"{x},{y}") + + points.opts(tools=["tap"]) + hv.streams.Tap(source=points, popup=popup_form) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool("tap", page, hv_plot) + locator = page.locator(".bk-btn") + expect(locator).to_have_count(2) + + def test_callback_visible(self, serve_hv, points): + def popup_form(x, y): + def hide(_): + col.visible = False + button = pn.widgets.Button( + name=f"{x},{y}", + on_click=hide, + css_classes=["custom-button"] + ) + col = pn.Column(button) + return col + + points.opts(tools=["tap"]) + hv.streams.Tap(source=points, popup=popup_form) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool("tap", page, hv_plot) + + locator = page.locator(".bk-btn") + expect(locator).to_have_count(2) + expect(locator.first).to_be_visible() + + locator = page.locator(".custom-button") + locator.click() + locator = page.locator(".bk-btn") + expect(locator.first).not_to_be_visible() + + @pytest.mark.parametrize("tool", ["box_select", "lasso_select", "tap"]) + @pytest.mark.parametrize("popup_position", [ + "top_right", "top_left", "bottom_left", "bottom_right", + "right", "left", "top", "bottom" + ]) + def test_position_selection1d(self, serve_hv, points, tool, popup_position): + def popup_form(index): + if index: + return f"# selection\n{len(index)} {popup_position}" + + hv.streams.Selection1D(source=points, popup=popup_form, popup_position=popup_position) + points.opts(tools=[tool], active_tools=[tool]) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool(tool, page, hv_plot) + + locator = self._locate_popup(page) + expect(locator).not_to_have_text("selection\n0") + + box = hv_plot.bounding_box() + popup_box = locator.bounding_box() + distances = self._get_popup_distances_relative_to_bbox(popup_box, box) + self._verify_popup_position(distances, popup_position) + + def test_anchor_selection1d(self, serve_hv, points): + def popup_form(index): + if index: + return f"# selection\n{len(index)}" + + hv.streams.Selection1D(source=points, popup=popup_form, popup_position="top", popup_anchor="top_right") + points.opts(tools=["tap"], active_tools=["tap"]) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool("tap", page, hv_plot) + locator = self._locate_popup(page) + expect(locator).not_to_have_text("selection\n0") + + box = hv_plot.bounding_box() + popup_box = locator.bounding_box() + distances = self._get_popup_distances_relative_to_bbox(popup_box, box) + + assert distances['right'] >= distances['left'] + + + @pytest.mark.parametrize("tool, tool_type", [ + ("box_select", BoundsXY), + ("lasso_select", Lasso), + ("tap", Tap) + ]) + def test_anchor_tools(self, serve_hv, points, tool, tool_type): + def popup_form(*args, **kwargs): + return "# selection" + + points = points.opts(tools=[tool], active_tools=[tool]) + tool_type(source=points, popup=popup_form, popup_position="bottom", popup_anchor="bottom_right") + page, hv_plot = self._serve_plot(serve_hv, points) + + self._select_points_based_on_tool(tool, page, hv_plot) + locator = self._locate_popup(page) + + box = hv_plot.bounding_box() + popup_box = locator.bounding_box() + distances = self._get_popup_distances_relative_to_bbox(popup_box, box) + + assert distances['right'] >= distances['left'] + assert distances['bottom'] >= distances['top'] + + + @pytest.mark.parametrize("tool, tool_type", [("box_select", BoundsXY), ("lasso_select", Lasso)]) + @pytest.mark.parametrize("popup_position", [ + "top_right", "top_left", "bottom_left", "bottom_right", + "right", "left", "top", "bottom" + ]) + def test_position_tools(self, serve_hv, points, tool, tool_type, popup_position): + def popup_form(*args, **kwargs): + return "# selection" + + points = points.opts(tools=[tool], active_tools=[tool]) + tool_type(source=points, popup=popup_form, popup_position=popup_position) + + page, hv_plot = self._serve_plot(serve_hv, points) + self._select_points_based_on_tool(tool, page, hv_plot) + locator = self._locate_popup(page) + + box = hv_plot.bounding_box() + popup_box = locator.bounding_box() + distances = self._get_popup_distances_relative_to_bbox(popup_box, box) + + self._verify_popup_position(distances, popup_position)