Skip to content

Commit

Permalink
Add popup position and anchor (#6414)
Browse files Browse the repository at this point in the history
  • Loading branch information
ahuang11 authored Oct 23, 2024
1 parent 2e4979b commit d753397
Show file tree
Hide file tree
Showing 4 changed files with 484 additions and 350 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
225 changes: 158 additions & 67 deletions holoviews/plotting/bokeh/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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"},
Expand All @@ -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);
}}
}}""",
))

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
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="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):
Expand Down
Loading

0 comments on commit d753397

Please sign in to comment.