Skip to content

Commit

Permalink
add popup position and anchor
Browse files Browse the repository at this point in the history
  • Loading branch information
ahuang11 committed Oct 16, 2024
1 parent 31ee32d commit f4cabe6
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 67 deletions.
49 changes: 49 additions & 0 deletions examples/user_guide/13-Custom_Interactivity.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down
186 changes: 120 additions & 66 deletions holoviews/plotting/bokeh/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@
from ...util.warnings import warn
from .util import BOKEH_GE_3_3_0, 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:
"""
Expand Down Expand Up @@ -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"},
Expand All @@ -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);
}}
}}""",
))

Expand Down Expand Up @@ -1163,61 +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) {
let ix = xs[i]
let iy = ys[i]
let tx, ty
if (typeof ix === 'number') {
tx = ix
ty = iy
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 {
while (ix.length && (typeof ix[0] !== 'number')) {
ix = ix[0]
iy = iy[0]
}
tx = Math.max(...ix)
ty = Math.max(...iy)
}
if (!x || (tx > x)) {
x = tx
x = (minX + maxX) / 2;
}
if (!y || (ty > y)) {
y = ty
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):
Expand Down
21 changes: 20 additions & 1 deletion holoviews/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down
74 changes: 74 additions & 0 deletions holoviews/tests/ui/bokeh/test_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,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):
Expand Down

0 comments on commit f4cabe6

Please sign in to comment.