Skip to content

Commit d753397

Browse files
authored
Add popup position and anchor (#6414)
1 parent 2e4979b commit d753397

File tree

4 files changed

+484
-350
lines changed

4 files changed

+484
-350
lines changed

examples/user_guide/13-Custom_Interactivity.ipynb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,55 @@
483483
")"
484484
]
485485
},
486+
{
487+
"cell_type": "markdown",
488+
"metadata": {},
489+
"source": [
490+
"The `popup_position` can be set to one of the following options:\n",
491+
"\n",
492+
"- `top_right` (the default)\n",
493+
"- `top_left`\n",
494+
"- `bottom_left`\n",
495+
"- `bottom_right`\n",
496+
"- `right`\n",
497+
"- `left`\n",
498+
"- `top`\n",
499+
"- `bottom`\n",
500+
"\n",
501+
"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",
502+
"\n",
503+
"- `top_left`, `top_center`, `top_right`\n",
504+
"- `center_left`, `center_center`, `center_right`\n",
505+
"- `bottom_left`, `bottom_center`, `bottom_right`\n",
506+
"- `top`, `left`, `center`, `right`, `bottom`\n",
507+
"\n",
508+
"Alternatively, the `popup_anchor` can be specified as a tuple, using a mix of `start`, `center`, `end`, like `(\"start\", \"center\")`."
509+
]
510+
},
511+
{
512+
"cell_type": "code",
513+
"execution_count": null,
514+
"metadata": {},
515+
"outputs": [],
516+
"source": [
517+
"hv.streams.Selection1D(\n",
518+
" source=points,\n",
519+
" popup=popup_stats,\n",
520+
" popup_position=\"left\",\n",
521+
" popup_anchor=\"right\"\n",
522+
")\n",
523+
"\n",
524+
"points.opts(\n",
525+
" tools=[\"box_select\", \"lasso_select\", \"tap\"],\n",
526+
" active_tools=[\"lasso_select\"],\n",
527+
" size=6,\n",
528+
" color=\"black\",\n",
529+
" fill_color=None,\n",
530+
" width=500,\n",
531+
" height=500\n",
532+
")"
533+
]
534+
},
486535
{
487536
"cell_type": "markdown",
488537
"metadata": {},

holoviews/plotting/bokeh/callbacks.py

Lines changed: 158 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@
7575
from ...util.warnings import warn
7676
from .util import BOKEH_GE_3_3_0, convert_timestamp
7777

78+
POPUP_POSITION_ANCHOR = {
79+
"top_right": "bottom_left",
80+
"top_left": "bottom_right",
81+
"bottom_left": "top_right",
82+
"bottom_right": "top_left",
83+
"right": "top_left",
84+
"left": "top_right",
85+
"top": "bottom",
86+
"bottom": "top",
87+
}
88+
7889

7990
class Callback:
8091
"""
@@ -611,9 +622,10 @@ def initialize(self, plot_id=None):
611622
}
612623
"""],
613624
css_classes=["popup-close-btn"])
625+
self._popup_position = stream.popup_position
614626
self._panel = Panel(
615627
position=XY(x=np.nan, y=np.nan),
616-
anchor="top_left",
628+
anchor=stream.popup_anchor or POPUP_POSITION_ANCHOR[self._popup_position],
617629
elements=[close_button],
618630
visible=False,
619631
styles={"zIndex": "1000"},
@@ -627,24 +639,56 @@ def _watch_position(self):
627639
geom_type = self.geom_type
628640
self.plot.state.on_event('selectiongeometry', self._update_selection_event)
629641
self.plot.state.js_on_event('selectiongeometry', CustomJS(
630-
args=dict(panel=self._panel),
642+
args=dict(panel=self._panel, popup_position=self._popup_position),
631643
code=f"""
632-
export default ({{panel}}, cb_obj, _) => {{
633-
const el = panel.elements[1]
634-
if ((el && !el.visible) || !cb_obj.final || ({geom_type!r} !== 'any' && cb_obj.geometry.type !== {geom_type!r})) {{
635-
return
636-
}}
637-
let pos;
638-
if (cb_obj.geometry.type === 'point') {{
639-
pos = {{x: cb_obj.geometry.x, y: cb_obj.geometry.y}}
640-
}} else if (cb_obj.geometry.type === 'rect') {{
641-
pos = {{x: cb_obj.geometry.x1, y: cb_obj.geometry.y1}}
642-
}} else if (cb_obj.geometry.type === 'poly') {{
643-
pos = {{x: Math.max(...cb_obj.geometry.x), y: Math.max(...cb_obj.geometry.y)}}
644-
}}
645-
if (pos) {{
646-
panel.position.setv(pos)
647-
}}
644+
export default ({{panel, popup_position}}, cb_obj, _) => {{
645+
const el = panel.elements[1];
646+
if ((el && !el.visible) || !cb_obj.final || ({geom_type!r} !== 'any' && cb_obj.geometry.type !== {geom_type!r})) {{
647+
return;
648+
}}
649+
650+
let pos;
651+
if (cb_obj.geometry.type === 'point') {{
652+
pos = {{x: cb_obj.geometry.x, y: cb_obj.geometry.y}};
653+
}} else if (cb_obj.geometry.type === 'rect') {{
654+
let x, y;
655+
if (popup_position.includes('left')) {{
656+
x = cb_obj.geometry.x0;
657+
}} else if (popup_position.includes('right')) {{
658+
x = cb_obj.geometry.x1;
659+
}} else {{
660+
x = (cb_obj.geometry.x0 + cb_obj.geometry.x1) / 2;
661+
}}
662+
if (popup_position.includes('top')) {{
663+
y = cb_obj.geometry.y1;
664+
}} else if (popup_position.includes('bottom')) {{
665+
y = cb_obj.geometry.y0;
666+
}} else {{
667+
y = (cb_obj.geometry.y0 + cb_obj.geometry.y1) / 2;
668+
}}
669+
pos = {{x: x, y: y}};
670+
}} else if (cb_obj.geometry.type === 'poly') {{
671+
let x, y;
672+
if (popup_position.includes('left')) {{
673+
x = Math.min(...cb_obj.geometry.x);
674+
}} else if (popup_position.includes('right')) {{
675+
x = Math.max(...cb_obj.geometry.x);
676+
}} else {{
677+
x = (Math.min(...cb_obj.geometry.x) + Math.max(...cb_obj.geometry.x)) / 2;
678+
}}
679+
if (popup_position.includes('top')) {{
680+
y = Math.max(...cb_obj.geometry.y);
681+
}} else if (popup_position.includes('bottom')) {{
682+
y = Math.min(...cb_obj.geometry.y);
683+
}} else {{
684+
y = (Math.min(...cb_obj.geometry.y) + Math.max(...cb_obj.geometry.y)) / 2;
685+
}}
686+
pos = {{x: x, y: y}};
687+
}}
688+
689+
if (pos) {{
690+
panel.position.setv(pos);
691+
}}
648692
}}""",
649693
))
650694

@@ -734,7 +778,7 @@ async def _process_selection_event(self):
734778
position = self._get_position(event) if event else None
735779
if position:
736780
self._panel.position = XY(**position)
737-
if self.plot.comm: # update Jupyter Notebook
781+
if self.plot.comm: # update Jupyter Notebooks
738782
push_on_root(self.plot.root.ref['id'])
739783
return
740784

@@ -1173,59 +1217,106 @@ def _watch_position(self):
11731217
source = self.plot.handles['source']
11741218
renderer = self.plot.handles['glyph_renderer']
11751219
selected = self.plot.handles['selected']
1220+
11761221
self.plot.state.js_on_event('selectiongeometry', CustomJS(
1177-
args=dict(panel=self._panel, renderer=renderer, source=source, selected=selected),
1222+
args=dict(panel=self._panel, renderer=renderer, source=source, selected=selected, popup_position=self._popup_position),
11781223
code="""
1179-
export default ({panel, renderer, source, selected}, cb_obj, _) => {
1180-
const el = panel.elements[1]
1181-
if ((el && !el.visible) || !cb_obj.final) {
1182-
return
1183-
}
1184-
let x, y, xs, ys;
1185-
let indices = selected.indices;
1186-
if (cb_obj.geometry.type == 'point') {
1187-
indices = indices.slice(-1)
1188-
}
1189-
if (renderer.glyph.x && renderer.glyph.y) {
1190-
xs = source.get_column(renderer.glyph.x.field)
1191-
ys = source.get_column(renderer.glyph.y.field)
1192-
} else if (renderer.glyph.right && renderer.glyph.top) {
1193-
xs = source.get_column(renderer.glyph.right.field)
1194-
ys = source.get_column(renderer.glyph.top.field)
1195-
} else if (renderer.glyph.x1 && renderer.glyph.y1) {
1196-
xs = source.get_column(renderer.glyph.x1.field)
1197-
ys = source.get_column(renderer.glyph.y1.field)
1198-
} else if (renderer.glyph.xs && renderer.glyph.ys) {
1199-
xs = source.get_column(renderer.glyph.xs.field)
1200-
ys = source.get_column(renderer.glyph.ys.field)
1201-
}
1202-
if (!xs || !ys) { return }
1203-
for (const i of indices) {
1204-
let ix = xs[i]
1205-
let iy = ys[i]
1206-
let tx, ty
1207-
if (typeof ix === 'number') {
1208-
tx = ix
1209-
ty = iy
1210-
} else {
1211-
while (ix.length && (typeof ix[0] !== 'number')) {
1212-
ix = ix[0]
1213-
iy = iy[0]
1214-
}
1215-
tx = Math.max(...ix)
1216-
ty = Math.max(...iy)
1224+
export default ({panel, renderer, source, selected, popup_position}, cb_obj, _) => {
1225+
panel.visible = false; // Hide the popup panel so it doesn't show in previous location
1226+
const el = panel.elements[1];
1227+
if ((el && !el.visible) || !cb_obj.final) {
1228+
return;
12171229
}
1218-
if (!x || (tx > x)) {
1219-
x = tx
1230+
let x, y, xs, ys;
1231+
let indices = selected.indices;
1232+
if (cb_obj.geometry.type == 'point') {
1233+
indices = indices.slice(-1);
12201234
}
1221-
if (!y || (ty > y)) {
1222-
y = ty
1235+
1236+
if (renderer.glyph.x && renderer.glyph.y) {
1237+
xs = source.get_column(renderer.glyph.x.field);
1238+
ys = source.get_column(renderer.glyph.y.field);
1239+
} else if (renderer.glyph.right && renderer.glyph.top) {
1240+
xs = source.get_column(renderer.glyph.right.field);
1241+
ys = source.get_column(renderer.glyph.top.field);
1242+
} else if (renderer.glyph.x1 && renderer.glyph.y1) {
1243+
xs = source.get_column(renderer.glyph.x1.field);
1244+
ys = source.get_column(renderer.glyph.y1.field);
1245+
} else if (renderer.glyph.xs && renderer.glyph.ys) {
1246+
xs = source.get_column(renderer.glyph.xs.field);
1247+
ys = source.get_column(renderer.glyph.ys.field);
12231248
}
1224-
}
1225-
if (x && y) {
1226-
panel.position.setv({x, y})
1227-
}
1228-
}""",
1249+
1250+
if (!xs || !ys || !indices.length) {
1251+
return;
1252+
}
1253+
1254+
let minX, maxX, minY, maxY;
1255+
1256+
// Loop over each index in the selection and find the corresponding polygon coordinates
1257+
for (const i of indices) {
1258+
let ix = xs[i];
1259+
let iy = ys[i];
1260+
let tx, ty;
1261+
1262+
// Check if the values are numbers or nested arrays
1263+
if (typeof ix === 'number') {
1264+
tx = ix;
1265+
ty = iy;
1266+
} else {
1267+
// Drill down into nested arrays until we find the number values
1268+
while (ix.length && typeof ix[0] !== 'number') {
1269+
ix = ix[0];
1270+
iy = iy[0];
1271+
}
1272+
1273+
// Set tx and ty based on the popup position preferences
1274+
if (popup_position.includes('left')) {
1275+
tx = Math.min(...ix);
1276+
} else if (popup_position.includes('right')) {
1277+
tx = Math.max(...ix);
1278+
} else {
1279+
tx = (Math.min(...ix) + Math.max(...ix)) / 2;
1280+
}
1281+
1282+
if (popup_position.includes('top')) {
1283+
ty = Math.max(...iy);
1284+
} else if (popup_position.includes('bottom')) {
1285+
ty = Math.min(...iy);
1286+
} else {
1287+
ty = (Math.min(...iy) + Math.max(...iy)) / 2;
1288+
}
1289+
}
1290+
1291+
// Update the min/max values for x and y
1292+
if (minX === undefined || tx < minX) { minX = tx; }
1293+
if (maxX === undefined || tx > maxX) { maxX = tx; }
1294+
if (minY === undefined || ty < minY) { minY = ty; }
1295+
if (maxY === undefined || ty > maxY) { maxY = ty; }
1296+
}
1297+
1298+
// Set x and y based on popup_position preference
1299+
if (popup_position.includes('left')) {
1300+
x = minX;
1301+
} else if (popup_position.includes('right')) {
1302+
x = maxX;
1303+
} else {
1304+
x = (minX + maxX) / 2;
1305+
}
1306+
1307+
if (popup_position.includes('top')) {
1308+
y = maxY;
1309+
} else if (popup_position.includes('bottom')) {
1310+
y = minY;
1311+
} else {
1312+
y = (minY + maxY) / 2;
1313+
}
1314+
1315+
// Set the popup position and make it visible
1316+
panel.position.setv({x, y});
1317+
panel.visible = true;
1318+
}
1319+
""",
12291320
))
12301321

12311322
def _get_position(self, event):

holoviews/streams.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
# Types supported by Pointer derived streams
2323
pointer_types = (Number, str, tuple)+util.datetime_types
2424

25+
POPUP_POSITIONS = [
26+
"top_right",
27+
"top_left",
28+
"bottom_left",
29+
"bottom_right",
30+
"right",
31+
"left",
32+
"top",
33+
"bottom",
34+
]
35+
2536
class _SkipTrigger: pass
2637

2738

@@ -1255,9 +1266,17 @@ class LinkedStream(Stream):
12551266
supplying stream data.
12561267
"""
12571268

1258-
def __init__(self, linked=True, popup=None, **params):
1269+
def __init__(self, linked=True, popup=None, popup_position="top_right", popup_anchor=None, **params):
1270+
if popup_position not in POPUP_POSITIONS:
1271+
raise ValueError(
1272+
f"Invalid popup_position: {popup_position!r}; "
1273+
f"expect one of {POPUP_POSITIONS}"
1274+
)
1275+
12591276
super().__init__(linked=linked, **params)
12601277
self.popup = popup
1278+
self.popup_position = popup_position
1279+
self.popup_anchor = popup_anchor
12611280

12621281

12631282
class PointerX(LinkedStream):

0 commit comments

Comments
 (0)