Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add popup position and anchor #6414

Merged
merged 21 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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