How to move / detach single pane from a dashboard into a separate browser window?

Hi there,

I have a panel dashboard, which works fine so far.
Now I’d like to move one of the components into a separate browser window, so I can move it around or maximize it on a 2nd screen. Apart from this changed placement, it should still behave as before, i.e. as a part of the dashboard, communicating with the other parts, etc.

Thanks for any pointers in a useful direction!

This works, but don’t use panel serve to launch. Just run it as a script. The Javascript tied to the open_input_window button might get stopped by a pop-up blocker.

import panel as pn
import param

class AppClass(param.Parameterized):
    text_input = param.String(default="input_string", doc="A string")
    output = pn.pane.Markdown('test')
    open_input_window = param.Event()

    @param.depends('text_input', on_init = True, watch = True)
    def markdown(self):
        self.output.object = self.text_input

app = AppClass()

button =   pn.widgets.Button.from_param(app.param['open_input_window'])

button.jscallback(clicks="""
x = window.open("2_input_window", '_blank', 'left=500,height=400,width=400')
""", args={})

pn.serve({
    '1_start_here': pn.Row(app.output, button),
    '2_input_window': pn.Row(app.param['text_input'])
})
2 Likes

Hi @ansgar-t

I have an example implementation below. The key idea is to save the component in pn.state.cache under a unique_id and then use that unique_id when opening in another window.

script.py

import panel as pn
import uuid

pn.extension(template="fast")

def get_object_id():
    return pn.state.session_args.get("id", [b""])[0].decode(encoding="utf8")

def serve_no_object_id_app():
    unique_id = str(uuid.uuid4()) 

    js_pane = pn.pane.HTML(visible=False, height=0, width=0, sizing_mode="fixed", margin=0)
    open_button = pn.widgets.Button(name = "Open the component below in another window", button_type="primary")
    
    component = pn.widgets.TextInput(name="Some component")
    
    @pn.depends(open_button, watch=True)
    def open_new_window(_):
        pn.state.cache[unique_id]=component
        js_pane.object=f"""<script>
window.open("?id={unique_id}", '_blank', 'left=500,height=400,width=400')
</script>
"""
        js_pane.object = ""
    

    pn.Column("# Main Page", open_button, component, js_pane).servable()

def serve_object_id_app(object_id):
    component = pn.state.cache.get(object_id, pn.pane.Markdown("Not found"))
    pn.Column(f"# Page {object_id}", component).servable()

object_id = get_object_id()

if not object_id:
    serve_no_object_id_app()
else:
    serve_object_id_app(object_id)
panel serve script.py

There are some open questions though? How to delete these components. It might not matter for small use cases. But if you are sharing with many users over a longer period of time these objects will take up more and more memory.

4 Likes

Is there some reason the documented pn.serve functionality doesn’t work?

1 Like

Depends on what you want to achieve. The main issue is that all users will share the same app because it’s not instantiated inside a function provided to pn.serve

Ahh. Good point. I didn’t think about that.

1 Like

Thanks, guys! Will try it out soon!

Hi Marc

Thanks for the example, That is a really good one.

I am trying to understand how the text input value synced between sessions.

May I get some introduction about it?

Kind regards
Victor

Hi everyone.
Thank you @Marc and @riziles for your examples. They work perfectly fine. However I’m trying to do pretty much the same with some Bokeh plot where I have controls on one window and the actual figure on another one.

import uuid
from typing import Optional

import numpy as np
import panel as pn
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure


def get_object_id() -> Optional[str]:
    _id = pn.state.session_args.get("id")
    return None if _id is None else _id[0].decode(encoding='utf-8')


def serve_controls():
    source = ColumnDataSource(data={
        'x': np.random.randint(0, 10, 10),
        'y': np.random.randint(0, 10, 10),
    })

    fig = figure()
    fig.circle(x='x', y='y', source=source)

    object_id = str(uuid.uuid4())
    pn.state.cache[object_id] = fig

    randomize_btn = pn.widgets.Button(name='Randomize', button_type="success")

    @pn.depends(randomize_btn, watch=True)
    def randomize(_) -> None:
        source.data = {
            'x': np.random.randint(0, 10, 10),
            'y': np.random.randint(0, 10, 10),
        }

    new_window_btn = pn.widgets.Button(name='Open in new window', button_type="primary")
    new_window_btn.jscallback(clicks=f"window.open('?id={object_id}', '_blank', 'left=500,height=400,width=400')")

    pn.Column(randomize_btn, new_window_btn,).servable("Controls")


def serve_object(object_id: str) -> None:
    obj = pn.state.cache.get(object_id, pn.pane.Markdown("Not found"))
    pn.Column(obj).servable("Figure")


panel_object_id: Optional[str] = get_object_id()
if panel_object_id:
    serve_object(panel_object_id)
else:
    serve_controls()

Unfortunately now I receive some error.

  ...
  File "example.py", line 31, in randomize
    source.data = {
  ...
  File ".venv/lib/python3.9/site-packages/bokeh/document/events.py", line 400, in dispatch
    super().dispatch(receiver)
  File ".venv/lib/python3.9/site-packages/bokeh/document/events.py", line 223, in dispatch
    cast(DocumentPatchedMixin, receiver)._document_patched(self)
  File ".venv/lib/python3.9/site-packages/bokeh/server/session.py", line 247, in _document_patched
    raise RuntimeError("_pending_writes should be non-None when we have a document lock, and we should have the lock when the document changes")
RuntimeError: _pending_writes should be non-None when we have a document lock, and we should have the lock when the document changes

Is there a possibility to get the proposed logic working with ColumnDataSources or is this actually a good approach for solving this specific issue?

I’m using:

  • Python version: 3.9
  • Bokeh version: 2.4.3
  • Panel version: 0.13.1

If Bokeh is not required, I would try using hvPlot/ HoloViews and see if that solves the issue.

@philippjfr would know if there is some general issue sharing Bokeh models between sessions.

Example below is a bit hacky, but it works. I couldn’t figure out how to programmatically trigger the button click.

import numpy as np
import panel as pn
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure


def serve_controls():
    source = ColumnDataSource(data={
        'x': np.random.randint(0, 10, 10),
        'y': np.random.randint(0, 10, 10),
    })

    fig = figure()
    fig.circle(x='x', y='y', source=source)

    randomize_btn = pn.widgets.Button(name='Randomize', button_type="success")
 
    count_clicks = pn.widgets.IntInput()
    randomize_btn.link(count_clicks, clicks = 'value', bidirectional = True)

    @pn.depends(randomize_btn, count_clicks, watch=True)
    def randomize(evt1,evt2) -> None:
        source.data = {
            'x': np.random.randint(0, 10, 10),
            'y': np.random.randint(0, 10, 10),
        }
        
    new_window_btn = pn.widgets.Button(name='Open controls in new window', button_type="primary")
    new_window_btn.jscallback(
        clicks="""
            var x = window.open("", "MsgWindow", "left=500,width=400,height=100");
            x.document.open()
            x.document.write("<button id = 'myBtn'>Randomize</button>");
            x.document.close()
            var element = x.document.getElementById("myBtn");
            element.addEventListener('click', myFunction)

            function myFunction() {
               var y = count_clicks.value
               count_clicks.value = y + 1;
            }
        """, args={'count_clicks':count_clicks}
    )

    return pn.Tabs(pn.Column(new_window_btn, fig, randomize_btn, count_clicks),)


pn.serve({'controls':serve_controls})
1 Like

Hi guys,
thank you for your fast replies. While trying to get the stuff working we came across the document.add_next_tick_callback method:

import uuid
from typing import Optional

import numpy as np
import panel as pn
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure


def get_object_id() -> Optional[str]:
    _id = pn.state.session_args.get("id")
    return None if _id is None else _id[0].decode(encoding='utf-8')


def serve_controls():
    source = ColumnDataSource(data={
        'x': np.random.randint(0, 10, 10),
        'y': np.random.randint(0, 10, 10),
    })

    fig = figure()
    fig.circle(x='x', y='y', source=source)

    object_id = str(uuid.uuid4())
    pn.state.cache[object_id] = fig

    randomize_btn = pn.widgets.Button(name='Randomize', button_type="success")

    @pn.depends(randomize_btn, watch=True)
    def randomize(_) -> None:
        def _update() -> None:
            source.data = {'x': np.random.randint(0, 10, 10), 'y': np.random.randint(0, 10, 10)}

        doc = pn.state.cache[object_id].document  # should return a bokeh Document object
        doc.add_next_tick_callback(_update)

    new_window_btn = pn.widgets.Button(name='Open in new window', button_type="primary")
    new_window_btn.jscallback(clicks=f"window.open('?id={object_id}', '_blank', 'left=500,height=400,width=400')")

    pn.Column(randomize_btn, new_window_btn).servable()


def serve_object(object_id: str) -> None:
    obj = pn.state.cache.get(object_id, pn.pane.Markdown("Not found"))
    pn.Column(obj).servable("Object")


panel_object_id: Optional[str] = get_object_id()
if panel_object_id:
    serve_object(panel_object_id)
else:
    serve_controls()

This now works perfectly fine. However I’m not sure if this is actually the best approach.

1 Like