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

ReactiveHTML table issue with param.Array() and callbacks #3456

Closed
CmpCtrl opened this issue Apr 27, 2022 · 7 comments
Closed

ReactiveHTML table issue with param.Array() and callbacks #3456

CmpCtrl opened this issue Apr 27, 2022 · 7 comments
Labels
type: bug Something isn't correct or isn't working

Comments

@CmpCtrl
Copy link

CmpCtrl commented Apr 27, 2022

I've been working on a reactiveHTML table implementation and have gotten a lot of help on discord here.

But i think i've now run into some real bugs. It seems like there are some problems with callbacks using param.Array() parameters.

I want to be able to move data between python and js, i was able to get data out of js once i realized that i must assign a new object to a param in the data. object, modifying one in place didnt seem to trigger the magic that updates the param on the python side (some more documentation on how this works would be great).

Now, going the other way, trying to assign a new value to a param.Array() object from python triggers the following error.

File "...\.venv310\lib\site-packages\bokeh\document\events.py", line 443, in generate
    if self.model != value:
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

This feels like an issue with the implementation of param.Array(), but i really dont know enough about whats going on under the hood to have a real sense.

if i am able to get around this issue, will updating the data. object in js magically update my table? it seems like probably not since i am using jinja templating instead of the ${} syntax. The user guide is very vague on what is actually different in that case, but says "The difference between Jinja2 literal templating and the JS templating syntax is important to note. While literal values are inserted during the initial rendering step they are not dynamically linked.".

I have had no luck using the ${} syntax with a param.Array it causes the following
ValueError: Array parameter 'axis0' value must be an instance of ndarray, not Str(ndarray, sizing_mode='stretch_width').

Is there a way to trigger a js callback from python explicitly without all the magic around params?

ALL software version info

panel     0.13.0
param    1.12.1
python   3.10.2

Description of expected behavior and the observed behavior

I expect to be able to assign a new array to a param.Array() object in python to pass the new data to the js data. object.

Complete, minimal, self-contained example code that reproduces the issue

This example is not as minimal as id like, but should demonstrate.
If you edit a value in the top row, it should trigger the update_axes callback

the first several lines of which work, returning

axis0 edited to :[   0.  100.  200.  300.  400.  500.    0.  700.  800.  900. 1000.]
data edited in place to: [1000.    1.    2.    3.    4.    5.    6.    7.    8.    9.   10.]
data updated to :[1000.    1.    2.    3.    4.    5.    6.    7.    8.    9.   10.]

I think this shows that editing the data param in place does not trigger the update_data callback, but then does call it when re-assigning the whole parameter. That does update the data param on the python side, but is not passed to js. Then, trying to assign random data to the data param appears to actually trigger updating the js which fails with the value error.

ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
data update to :[0.37946985 0.69968089 0.25976111 0.74877305 0.38919826 0.82425161
 0.92448139 0.70760514 0.1614588  0.37518864 0.17476377]

That does however trigger the update_data callback again, but again hasn't passed any data to .js.

"""Minimal Example"""


import panel as pn
import param
import numpy as np

CSS = """
table {
    padding: 8px;
    text-align: center;
    border-collapse: collapse;
}
td {
    padding: 8px;
}
tr {border-top: 1px solid}

.cal-data {
    border:2px solid;
    border-radius:8px;
    background-color:#7c7e85;
}
.cal-data-name {
    background-color:#676d81;
}

.cal-data-axis0name, .cal-data-axis0{
    background-color:#678174;
}
.cal-data-axis0name:first-child ,
.cal-data-axis0:first-child{
    border-left:2px solid;
}
.cal-data-axis0name:last-child ,
.cal-data-axis0:last-child{
    border-right:2px solid;
}
th.cal-data-axis0name {
    border-top:2px solid;
}
td.cal-data-axis0 {
    border-bottom:2px solid;
}

.active-cell{
    background-color:#6a4897;
    border:1px solid #6f00ff
}

"""
pn.extension(
    raw_css=[CSS],
    sizing_mode="stretch_width",
)


class CalData1d(pn.reactive.ReactiveHTML):
    """Class for custom table data control."""

    table_name = param.String(doc="Param Name.")
    axis0_name = param.String(doc="Axis0 Name.")
    axis0 = param.Array(doc="Axis0 Breakpoints.")
    data = param.Array(doc="Param Data.")

    _template = """
    <table id="cal-data" class="cal-data">
    <tbody>
        <tr id="cal-data-row-0">
            <th id="cal-data-0-0" class="cal-data-name"
                colspan="{{axis0.size}}">{{table_name}}</th>
        </tr>
        <tr id="cal-data-row-1">
            <th id="cal-data-1-1" class="cal-data-axis0name"
                colspan="{{axis0.size}}">{{axis0_name}}</th>
        </tr>
        <tr id="cal-data-row-2">
            {% for val in axis0 %}
            <td id="cal-data-2-{{loop.index0}}" class="cal-data-axis0"
                contenteditable="true"
                onfocus="${script('focus')}"
                onblur="${script('blur')}"
                onkeydown="${script('axis0_keydown')}"
                onkeypress="${script('keypress')}"
            >
                {{val}}
            </td>
            {% endfor %}
        </tr>
        <tr>
            {% for val in data %}
            <td id="cal-data-3-{{loop.index0}}"
                contenteditable="true" class="cal-data-data"
                onfocus="${script('focus')}"
                onblur="${script('blur')}"
                onkeydown="${script('data_keydown')}"
                onkeypress="${script('keypress')}"
            >
                {{val}}
            </td>
            {% endfor %}
        </tr>
    </tbody>
</table>
    """

    _scripts = {
        "render": """
            //console.log(data.data);
            // console.log(data.data[0]);
            state.maxrow = 2;
            state.maxcol = data.data.length;

        """,
        "block_def": """
            event.preventDefault();
        """,
        "getRowColID": """
            let regex = new RegExp(/cal-data-(?<row>[0-9]*)-(?<col>[0-9]*)-(?<id>[0-9]*)/);
            let match = regex.exec(event.target.id);
            return [parseInt(match[1]), parseInt(match[2]),parseInt(match[3])];
        """,
        "focus": """
            event.target.classList.add("active-cell");
            var cell=event.target;
            console.log('focus event, target',cell)
            var range,selection;
            if (document.body.createTextRange) {
                range = document.body.createTextRange();
                range.moveToElementText(cell);
                range.select();
            } else if (window.getSelection) {
                selection = window.getSelection();
                range = document.createRange();
                range.selectNodeContents(cell);
                selection.removeAllRanges();
                selection.addRange(range);
            }
            console.log('range',range)
            event.stopImmediatePropagation()
        """,
        "blur": """
            event.target.classList.remove("active-cell");
            console.log('blur',event.target);
        """,
        "keypress": """
            //console.log('keypress',event)
            switch(event.key) {
                case 'Enter':
                case 'ArrowUp':
                case 'ArrowDown':
                case 'ArrowLeft':
                case 'ArrowRight':
                    event.preventDefault();
                    break;
                }
        """,
        "data_keydown": """
            console.log('data keydown',event)
            var [row, col, id] = self.getRowColID(event);
            let new_row
            let new_col
            let new_val
            let new_array
            let orig = parseFloat(data.data[col]);
            const clone = (items) => items.map(item => Array.isArray(item) ? clone(item) : item);
            console.log('loc',[row, col, id])
            switch(event.key) {
                case 'Enter':
                    event.stopImmediatePropagation();
                    console.log('txt',event.target.innerHTML)
                    new_val = parseFloat(event.target.innerHTML);
                    console.log('new_val',new_val)
                    if (isNaN(new_val)||new_val==null) {
                        new_val = orig;
                    } else {
                        new_array = clone(data.data)
                        new_array[col] = new_val;
                        data.data = new_array
                        console.log('updated data to',data.data[col])
                    }
                    event.target.innerHTML=new_val;
                    event.target.blur();
                    event.target.focus();

                    break;
                case 'ArrowUp':
                    // set data back to orig
                    event.preventDefault();
                    event.target.innerHTML = orig;
                    new_row = 3;
                    console.log('target_id',`cal-data-${new_row}-${col}-${id}`)
                    event.target.blur(); // in case of being at an edge
                    document.getElementById(`cal-data-${new_row}-${col}-${id}`).focus()
                    break;
                case 'ArrowDown':
                    // set data back to orig
                    event.preventDefault();
                    event.target.innerHTML = orig;
                    new_row = 3;
                    console.log('target_id',`cal-data-${new_row}-${col}-${id}`)
                    event.target.blur(); // in case of being at an edge
                    document.getElementById(`cal-data-${new_row}-${col}-${id}`).focus()
                    break;
                case 'ArrowLeft':
                    // set data back to orig
                    event.preventDefault();
                    event.target.innerHTML = orig;
                    new_col = Math.min(Math.max(0,col-1),state.maxcol);
                    console.log('target_id',`cal-data-${row}-${new_col}-${id}`)
                    event.target.blur(); // in case of being at an edge
                    document.getElementById(`cal-data-${row}-${new_col}-${id}`).focus()
                    break;
                case 'ArrowRight':
                    // set data back to orig
                    event.preventDefault();
                    event.target.innerHTML = orig;
                    new_col = Math.min(Math.max(0,col+1),state.maxcol);
                    console.log('target_id',`cal-data-${row}-${new_col}-${id}`)
                    event.target.blur(); // in case of being at an edge
                    document.getElementById(`cal-data-${row}-${new_col}-${id}`).focus()
                    break;
                case 'Escape':
                    event.target.innerHTML = orig;
                    event.target.blur();
            }
        """,
        "axis0_keydown": """
            console.log('axis0 keydown',event)
            var [row, col, id] = self.getRowColID(event);
            let new_row
            let new_col
            let new_val
            let new_array
            let orig = parseFloat(data.axis0[col]);
            const clone = (items) => items.map(item => Array.isArray(item) ? clone(item) : item);
            console.log('loc',[row, col, id])
            switch(event.key) {
                case 'Enter':
                    event.stopImmediatePropagation();
                    //console.log('txt',event.target.innerHTML)
                    new_val = parseFloat(event.target.innerHTML);
                    console.log('new_val',new_val)
                    if (isNaN(new_val)||new_val==null) {
                        new_val = orig;
                    } else {
                        new_array = clone(data.axis0)
                        new_array[col] = new_val;
                        state.is_monoton_arg = new_array
                        data.axis0 = new_array
                        console.log('updated data to',data.axis0[col])

                    }
                    event.target.innerHTML=new_val;
                    event.target.blur();
                    event.target.focus();

                    break;
                case 'ArrowUp':
                    // set data back to orig
                    event.preventDefault();
                    event.target.innerHTML = orig;
                    new_row = 2;
                    console.log('target_id',`cal-data-${new_row}-${col}-${id}`)
                    event.target.blur(); // in case of being at an edge
                    document.getElementById(`cal-data-${new_row}-${col}-${id}`).focus()
                    break;
                case 'ArrowDown':
                    // set data back to orig
                    event.preventDefault();
                    event.target.innerHTML = orig;
                    new_row = 2;
                    console.log('target_id',`cal-data-${new_row}-${col}-${id}`)
                    event.target.blur(); // in case of being at an edge
                    document.getElementById(`cal-data-${new_row}-${col}-${id}`).focus()
                    break;
                case 'ArrowLeft':
                    // set data back to orig
                    event.preventDefault();
                    event.target.innerHTML = orig;
                    new_col = Math.min(Math.max(0,col-1),state.maxcol);
                    console.log('target_id',`cal-data-${row}-${new_col}-${id}`)
                    event.target.blur(); // in case of being at an edge
                    document.getElementById(`cal-data-${row}-${new_col}-${id}`).focus()
                    break;
                case 'ArrowRight':
                    // set data back to orig
                    event.preventDefault();
                    event.target.innerHTML = orig;
                    new_col = Math.min(Math.max(0,col+1),state.maxcol);
                    console.log('target_id',`cal-data-${row}-${new_col}-${id}`)
                    event.target.blur(); // in case of being at an edge
                    document.getElementById(`cal-data-${row}-${new_col}-${id}`).focus()
                    break;
                case 'Escape':
                    event.target.innerHTML = orig;
                    event.target.blur();
            }
        """,
    }


def update_axes(event: param.parameterized.Event):
    """handle updates to axes"""
    print(f"axis0 edited to :{event.new.astype(float)}")
    event.obj.data[0] = 1000
    print(f"data edited in place to: {event.obj.data}")
    event.obj.data = event.obj.data

    event.obj.data = np.random.random((11,))


def update_data(event: param.parameterized.Event):
    """handle data update."""
    new = event.new.astype(float)
    print(f"data updated to :{new}")


def build_panel_widget() -> CalData1d:
    """Build CalData widget for param."""

    data = np.linspace(0, 10, 11)
    axis0 = np.linspace(0, 1000, 11)

    cdata = CalData1d(
        table_name="Test Table", axis0_name=("test axis"), axis0=axis0, data=data
    )

    watcher = cdata.param.watch(update_axes, ["axis0"])
    watcher2 = cdata.param.watch(update_data, ["data"])
    return cdata


if __name__ == "__main__":
    pn.serve(
        {"demo": build_panel_widget},
        show=True,
        autoreload=False,
    )
@CmpCtrl
Copy link
Author

CmpCtrl commented Apr 27, 2022

Holy cow, i missed the part where naming an _script with a param name calls the script on a value change. i can easily add a param to trigger a script. For some reason i missed that completely, i think i was assuming you could only use params in the layout.

From the user guide:
"If the key in the _scripts dictionary matches one of the parameters declared on the class the callback will automatically fire whenever the synced parameter value changes. As an example let’s say we have a class which declares a value parameter, linked to the value attribute of an HTML tag:' ...

Also, i was able to re-work the above to use param.List() instead of param.Array() which gets me a workaround. And i think confirms that this issue is a bug with the param.Array() and not from misuse on my part.

@jbednar
Copy link
Member

jbednar commented Apr 27, 2022

Can you please isolate the problem with param.Array and raise an issue on the Param repo about it? It sounds like it should be independent of ReactiveHTML and Panel.

@CmpCtrl
Copy link
Author

CmpCtrl commented Apr 27, 2022

I honestly don't know enough about how it works to isolate the issue. Also, it seems related to Panel since the error is raised in bokeh\document\events.py.

File "...\.venv310\lib\site-packages\bokeh\document\events.py", line 443, in generate
    if self.model != value:
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

Perhaps someone who has some more experience than I can point me in the right direction.

@jbednar
Copy link
Member

jbednar commented Apr 27, 2022

Ah, good point. That looks like Bokeh would need the fix, so it can properly handle array values.

@philippjfr
Copy link
Member

Bokeh has completely rewritten their serialization protocol so I don't believe this will be an issue in Bokeh 3.0. Unfortunately I think it will also be a little while until that is released and we have upgraded to that version so I'll see if there's a workaround.

@philippjfr
Copy link
Member

Turns out I had already identified this as a bug and created an issue on bokeh: bokeh/bokeh#11735

@philippjfr
Copy link
Member

Pushed up a temporary fix in Panel which I should be able to remove once the Bokeh 3.0 release is out.

@philippjfr philippjfr added this to the Version 0.13.1 milestone Apr 28, 2022
@philippjfr philippjfr added the type: bug Something isn't correct or isn't working label Apr 28, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: bug Something isn't correct or isn't working
Projects
None yet
Development

No branches or pull requests

3 participants