Repository: holoviz/panel

Issue:
Serialization Error When Visualizing PyDeck TextLayer
#### ALL software version info
PyDeck 0.8, Panel 1.2

#### Description of expected behavior and the observed behavior
I recently coded up a variation of the PyDeck TextLayer example [from the PyDeck website](https://deckgl.readthedocs.io/en/latest/gallery/text_layer.html) and then attempted to integrate that into a Panel application. Panel is having a serialization issue with the PyDeck String type. 

#### Complete, minimal, self-contained example code that reproduces the issue
[This Google Colab notebook](https://colab.research.google.com/drive/13ziK2OrM7n6zmqQDKJQ1_fvrFCCj2oGf?usp=sharing) shows the visualization running without issue in PyDeck and then shows my attempt to render that same visualization via Panel.



Discussion:
I've proposed a solution on Discourse. Would like to know if it solves the issue?

https://discourse.holoviz.org/t/pydeck-textlayer-in-panel/5790/2
@MarcSkovMadsen Not yet. I replied on Discourse and shared a notebook for you to try on your end. 
@MarcSkovMadsen FYI I just tried realizing a TextLayer with a JSON spec fed directly to Panel and that seems to work. This provides an interim workaround. 
Could you share a json spec that works?
@MarcSkovMadsen Here's an example. 

```
json_spec = {
    "initialViewState": {
        "bearing": 0,
        "latitude": 37.7749,
        "longitude": -122.4194,
        "pitch": 0,
        "zoom": 10
    },
    "layers": [{
        "@@type": "TextLayer",
        "data": "https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/bart-stations.json",
        "getText": "@@=name",
        "getPosition": "@@=coordinates",
        "getSize": 160,
        "getColor": [0,0,0],
        "getAngle": 0,
        "sizeUnits": "meters",
        "id": "8a553b25-ef3a-489c-bbe2-e102d18a3211",
    }],
    "mapStyle": "mapbox://styles/mapbox/light-v9",
    "views": [
        {"@@type": "MapView", "controller": True}
    ]
}

import panel as pn
pn.extension('deckgl')
deck_gl = pn.pane.DeckGL(json_spec, mapbox_api_key=MAPBOX_KEY, sizing_mode='stretch_width', height=600)
deck_gl.show()
```
I'm realizing now that the JSON spec approach doesn't allow me to move forward with what I want to build. Not easily anyhow when I'm dynamically updating a stack of layers based on user interaction. If there's some possible pathway to get over this serialization hump @MarcSkovMadsen, I'm all ears. 
Hi @diehl 

You also put in https://gist.github.com/diehl/4a3af23416c2f5e6e65b3ed6bf00833f. Thanks. But its the same `json` as above. Does the `json` solve your problem?
Hi @MarcSkovMadsen, 

What I'm trying to avoid is having to actively manage / update a JSON specification as opposed to working with PyDeck layer objects as I update the DeckGL visualization based on user interaction. Right now, I'm blocked from doing that when a TextLayer is in the mix due to the serialization error that comes up when you pass the Deck object to `pn.pane.DeckGL`. I think I can get to where I need to go with a JSON spec if that's the only option, but that would be a shame since that will dramatically increase the amount of work I need to do to realize updates to the viz. 
Ok the serialization issue I proposed seems to fix one issue.

I've found another issue. Currently you have to set the mapbox_key on the PyDeck object because if not the code below will set the `mapbox_api_key` to "".

![image](https://github.com/holoviz/panel/assets/42288570/e24897c0-e824-4dfd-b029-8701b7612a00)

A third issue is that currently you will have to set the `min_zoom` and `max_zoom` in the `ViewState` otherwise Deckgl in Panels implementation will be totally zoomed out and not allow you to zoom.

```python
view_state = pdk.ViewState(latitude=37.7749295, longitude=-122.4194155, zoom=10, bearing=0, pitch=0, min_zoom=1, max_zoom=16)
```

Still finding the last issues.
@MarcSkovMadsen Awesome! I think I encountered that zoom issue when I was getting a world map with nothing visible on it. I had no ability to zoom in. 
The last thing to figure out (I believe) is why inlining the data in the TextLayer does not work with Panel

```python
import pandas as pd
import panel as pn
import pydeck as pdk
from bokeh.core.serialization import Serializer
from pydeck.types import String

MAPBOX_KEY = (
    "pk.eyJ1IjoicGFuZWxvcmciLCJhIjoiY2s1enA3ejhyMWhmZjNobjM1NXhtbWRrMyJ9.B_frQsAVepGIe-HiOJeqvQ"
)
data = {
    "initialViewState": {
        "bearing": 0,
        "longitude": -122.418466,
        "latitude": 37.752254,
        "pitch": 0,
        "zoom": 14
    },
    "layers": [
        {
            "@@type": "TextLayer",
            "data": [
                        {
                            "name": "Lafayette (LAFY)",
                            "code": "LF",
                            "address": "3601 Deer Hill Road, Lafayette CA 94549",
                            "entries": 3481,
                            "exits": 3616,
                            "coordinates": [-122.123801, 37.893394]
                        },
                        {
                            "name": "12th St. Oakland City Center (12TH)",
                            "code": "12",
                            "address": "1245 Broadway, Oakland CA 94612",
                            "entries": 13418,
                            "exits": 13547,
                            "coordinates": [-122.271604, 37.803664]
                        },
                        {
                            "name": "16th St. Mission (16TH)",
                            "code": "16",
                            "address": "2000 Mission Street, San Francisco CA 94110",
                            "entries": 12409,
                            "exits": 12351,
                            "coordinates": [-122.419694, 37.765062]
                        },
                        {
                            "name": "19th St. Oakland (19TH)",
                            "code": "19",
                            "address": "1900 Broadway, Oakland CA 94612",
                            "entries": 13108,
                            "exits": 13090,
                            "coordinates": [-122.269029, 37.80787]
                        },
                        {
                            "name": "24th St. Mission (24TH)",
                            "code": "24",
                            "address": "2800 Mission Street, San Francisco CA 94110",
                            "entries": 12817,
                            "exits": 12529,
                            "coordinates": [-122.418466, 37.752254]
                        }
            ],
            "getText": "@@=name",
            "getPosition": "@@=coordinates",
            "getSize": 160,
            "getColor": [0, 0, 0],
            "getAngle": 0,
            "sizeUnits": "meters",
            "id": "8a553b25-ef3a-489c-bbe2-e102d18a3211"
        }
    ],
    "mapProvider": "carto",
    "mapStyle": "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",
    "views": [{"@@type": "MapView", "controller": True}]
}

# data["layers"][0]["data"]="https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/bart-stations.json"

pn.extension("deckgl")
pn.pane.DeckGL(data, mapbox_api_key=MAPBOX_KEY, sizing_mode="stretch_both").servable()
```

![image](https://github.com/holoviz/panel/assets/42288570/4a898bbd-39a4-4627-aed6-e08b81fe914a)

If you don't inline by uncommenting 

```python
data["layers"][0]["data"]="https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/bart-stations.json"
```

![image](https://github.com/holoviz/panel/assets/42288570/636cbe11-2227-4ea7-9374-df526bc37504)

I've tried using the inlined json specification at. And it works. So I believe the issue is on the Panel side and not deckgl side.

![image](https://github.com/holoviz/panel/assets/42288570/115bc823-1e0e-42be-9b4a-1af7d7f49327)

```json
{
    "initialViewState": {
        "bearing": 0,
        "longitude": -122.418466,
        "latitude": 37.752254,
        "pitch": 0,
        "zoom": 14
    },
    "layers": [
        {
            "@@type": "TextLayer",
            "data": [
                        {
                            "name": "Lafayette (LAFY)",
                            "code": "LF",
                            "address": "3601 Deer Hill Road, Lafayette CA 94549",
                            "entries": 3481,
                            "exits": 3616,
                            "coordinates": [-122.123801, 37.893394]
                        },
                        {
                            "name": "12th St. Oakland City Center (12TH)",
                            "code": "12",
                            "address": "1245 Broadway, Oakland CA 94612",
                            "entries": 13418,
                            "exits": 13547,
                            "coordinates": [-122.271604, 37.803664]
                        },
                        {
                            "name": "16th St. Mission (16TH)",
                            "code": "16",
                            "address": "2000 Mission Street, San Francisco CA 94110",
                            "entries": 12409,
                            "exits": 12351,
                            "coordinates": [-122.419694, 37.765062]
                        },
                        {
                            "name": "19th St. Oakland (19TH)",
                            "code": "19",
                            "address": "1900 Broadway, Oakland CA 94612",
                            "entries": 13108,
                            "exits": 13090,
                            "coordinates": [-122.269029, 37.80787]
                        },
                        {
                            "name": "24th St. Mission (24TH)",
                            "code": "24",
                            "address": "2800 Mission Street, San Francisco CA 94110",
                            "entries": 12817,
                            "exits": 12529,
                            "coordinates": [-122.418466, 37.752254]
                        }
            ],
            "getText": "@@=name",
            "getPosition": "@@=coordinates",
            "getSize": 160,
            "getColor": [0, 0, 0],
            "getAngle": 0,
            "sizeUnits": "meters",
            "id": "8a553b25-ef3a-489c-bbe2-e102d18a3211"
        }
    ],
    "mapProvider": "carto",
    "mapStyle": "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",
    "views": [{"@@type": "MapView", "controller": true}]
}
```
Inspecting what Panel deckgl.ts does, I can see it throws away one of the coordinates when the data is inlined.

![image](https://github.com/holoviz/panel/assets/42288570/8a8f0d4b-cb66-4209-8b40-cda342753067)

This is where the issue is

![image](https://github.com/holoviz/panel/assets/42288570/cbb8d486-28bd-4460-9c4e-eba3dcd57e81)

![image](https://github.com/holoviz/panel/assets/42288570/c92af606-e2e3-4583-853f-3cae6fc7243d)

The issue is that Panel does not expect/ support array values in the data in the layers
You can workaround this issue by specifying `lat` and `long` seperately.

```python
import pandas as pd
import panel as pn
import pydeck as pdk
from bokeh.core.serialization import Serializer
from pydeck.types import String

MAPBOX_KEY = (
    "pk.eyJ1IjoicGFuZWxvcmciLCJhIjoiY2s1enA3ejhyMWhmZjNobjM1NXhtbWRrMyJ9.B_frQsAVepGIe-HiOJeqvQ"
)
data = {
    "initialViewState": {
        "bearing": 0,
        "longitude": -122.418466,
        "latitude": 37.752254,
        "pitch": 0,
        "zoom": 14
    },
    "layers": [
        {
            "@@type": "TextLayer",
            "data": [
                        {
                            "name": "24th St. Mission (24TH)",
                            "code": "24",
                            "address": "2800 Mission Street, San Francisco CA 94110",
                            "entries": 12817,
                            "exits": 12529,
                            "lat": -122.418466,
                            "lon": 37.752254
                        }
            ],
            "getText": "@@=name",
            "getPosition": "@@=[lat,lon]",
            "getSize": 160,
            "getColor": [0, 0, 0],
            "getAngle": 0,
            "sizeUnits": "meters",
            "id": "8a553b25-ef3a-489c-bbe2-e102d18a3211"
        }
    ],
    "mapProvider": "carto",
    "mapStyle": "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",
    "views": [{"@@type": "MapView", "controller": True}]
}

# data["layers"][0]["data"]="https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/bart-stations.json"

pn.extension("deckgl")
pn.pane.DeckGL(data, mapbox_api_key=MAPBOX_KEY, sizing_mode="stretch_both").servable()
```

![image](https://github.com/holoviz/panel/assets/42288570/a2c6a46b-dc6d-4ce7-ac6c-e2de2b5eb2fc)

@MarcSkovMadsen Awesome progress! 

I've got another example I've been experimenting with on my end where the Panel viz fails to come up as expected when the PyDeck Deck object is passed to `pn.pane.DeckGL` but it almost works when I extract the JSON spec from the Deck object and pass that to `pn.pane.DeckGL`. What is missing is the Mapbox basemap which is probably explained by the issue you found above regarding the API key.

```
vtiles = pdk.Deck(
    initial_view_state=INITIAL_VIEW_STATE,
    views=[view],
    layers=[geojson_layer,marker_layer,text_layer],
    map_provider='mapbox',
    map_style='mapbox://styles/diehl/cliwf0hy500m401pw9qxbeac3',
)

import json
spec = json.loads(vtiles.to_json())
spec["mapProvider"] = 'mapbox'
spec["mapStyle"] = 'mapbox://styles/diehl/cliwf0hy500m401pw9qxbeac3'

map = pn.pane.DeckGL(spec,  mapbox_api_key=ACCESS_TOKEN, sizing_mode='stretch_both')
pn.Row(map.controls(),map).show()
```

And sure enough, your code above held the answer. By inserting the API key into the `pn.pane.DeckGL` call, the layer is now appearing! So we've got an interim workaround with this added step of dumping the JSON spec to pass to Panel.
## Workaround

This example will workaround the issues with 1) mapbox key 2) min/ max zoom and 3) no support for array columns.

![image](https://github.com/holoviz/panel/assets/42288570/8681c9e5-090c-430c-8484-c48f27d11e66)

I've marked the points with `# Workaround` in the code below.

```python
import pandas as pd
import pydeck as pdk

from bokeh.core.serialization import Serializer
from pydeck.types import String


def pydeck_string_encoder(obj, serializer):
    return obj.value


Serializer._encoders[String] = pydeck_string_encoder

TEXT_LAYER_DATA = "https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/bart-stations.json"  # noqa
MAPBOX_KEY = "pk.eyJ1IjoicGFuZWxvcmciLCJhIjoiY2s1enA3ejhyMWhmZjNobjM1NXhtbWRrMyJ9.B_frQsAVepGIe-HiOJeqvQ"

df = pd.read_json(TEXT_LAYER_DATA)

# Workaround
lat_lon =  pd.DataFrame(df["coordinates"].to_list(), columns=["lat", "lon"])
del df["coordinates"]
df = pd.concat([df, lat_lon], axis=1)

# Define a layer to display on a map
layer = pdk.Layer(
    "TextLayer",
    df,
    pickable=True,
    get_position="[lat,lon]", # Workaround
    get_text="name",
    get_size=160,
    get_color=[0, 0, 0],
    get_angle=0,
    get_text_anchor=String("middle"),
    get_alignment_baseline=String("center"),
    size_units=String("meters"),
    api_keys={"mapbox": MAPBOX_KEY},  # Workaround
)

# Set the viewport location
view_state = pdk.ViewState(
    latitude=37.7749295,
    longitude=-122.4194155,
    zoom=11,
    bearing=0,
    pitch=0,
    min_zoom=0,  # Workaround
    max_zoom=16,  # Workaround
)

# Render
r = pdk.Deck(
    layers=[layer],
    initial_view_state=view_state,
    tooltip={"text": "{name}\n{address}"},
    map_style=pdk.map_styles.ROAD,
)

import panel as pn

pn.extension("deckgl")
pn.pane.DeckGL(
    r, mapbox_api_key=MAPBOX_KEY, sizing_mode="stretch_both"
).servable()
```
@MarcSkovMadsen Awesome! Just added the serialization code to my example and now the JSON spec step is not needed. Thank you sir for this workaround! Do you plan to roll the serialization code into Panel somehow so that it will not be necessary in the future when running code such as the above?

Resolving patch:
diff --git a/panel/pane/deckgl.py b/panel/pane/deckgl.py
--- a/panel/pane/deckgl.py
+++ b/panel/pane/deckgl.py
@@ -15,6 +15,7 @@
 import numpy as np
 import param
 
+from bokeh.core.serialization import Serializer
 from bokeh.models import ColumnDataSource
 from pyviz_comms import JupyterComm
 
@@ -123,6 +124,8 @@ class DeckGL(ModelPane):
         'view_state': 'viewState', 'tooltips': 'tooltip'
     }
 
+    _pydeck_encoders_are_added: ClassVar[bool] = False
+
     _updates: ClassVar[bool] = True
 
     priority: ClassVar[float | bool | None] = None
@@ -196,6 +199,36 @@ def _update_sources(cls, json_data, sources):
                 sources.append(cds)
             layer['data'] = sources.index(cds)
 
+    @classmethod
+    def _add_pydeck_encoders(cls):
+        if cls._pydeck_encoders_are_added or 'pydeck' not in sys.modules:
+            return
+
+        from pydeck.types import String
+        def pydeck_string_encoder(obj, serializer):
+            return obj.value
+
+        Serializer._encoders[String] = pydeck_string_encoder
+
+    def _transform_deck_object(self, obj):
+        data = dict(obj.__dict__)
+        mapbox_api_key = data.pop('mapbox_key', "") or self.mapbox_api_key
+        deck_widget = data.pop('deck_widget', None)
+        if isinstance(self.tooltips, dict) or deck_widget is None:
+            tooltip = self.tooltips
+        else:
+            tooltip = deck_widget.tooltip
+        data = {k: v for k, v in recurse_data(data).items() if v is not None}
+
+        if "initialViewState" in data:
+            data["initialViewState"]={
+                k:v for k, v in data["initialViewState"].items() if v is not None
+            }
+
+        self._add_pydeck_encoders()
+
+        return data, tooltip, mapbox_api_key
+
     def _transform_object(self, obj) -> Dict[str, Any]:
         if self.object is None:
             data, mapbox_api_key, tooltip = {}, self.mapbox_api_key, self.tooltips
@@ -208,14 +241,7 @@ def _transform_object(self, obj) -> Dict[str, Any]:
             mapbox_api_key = self.mapbox_api_key
             tooltip = self.tooltips
         else:
-            data = dict(self.object.__dict__)
-            mapbox_api_key = data.pop('mapbox_key', self.mapbox_api_key)
-            deck_widget = data.pop('deck_widget', None)
-            if isinstance(self.tooltips, dict) or deck_widget is None:
-                tooltip = self.tooltips
-            else:
-                tooltip = deck_widget.tooltip
-            data = {k: v for k, v in recurse_data(data).items() if v is not None}
+            data, tooltip, mapbox_api_key = self._transform_deck_object(self.object)
 
         # Delete undefined width and height
         for view in data.get('views', []):


Test patch:
diff --git a/panel/tests/pane/test_deckgl.py b/panel/tests/pane/test_deckgl.py
--- a/panel/tests/pane/test_deckgl.py
+++ b/panel/tests/pane/test_deckgl.py
@@ -8,6 +8,8 @@
 
 pydeck_available = pytest.mark.skipif(pydeck is None, reason="requires pydeck")
 
+from bokeh.core.serialization import Serializer
+
 from panel.models.deckgl import DeckGLPlot
 from panel.pane import DeckGL, PaneBase, panel
 
@@ -159,3 +161,56 @@ def test_deckgl_insert_layer(document, comm):
     assert cds1.data['b'] is b_vals
     assert np.array_equal(cds2.data['b'], np.array([3, 9]))
     assert np.array_equal(cds2.data['c'], np.array([1, 3]))
+
+@pydeck_available
+def test_pydeck_mapbox_api_key_issue_5790(document, comm):
+    deck_wo_key = pydeck.Deck()
+    pane_w_key = DeckGL(deck_wo_key, mapbox_api_key="ABC")
+
+    model = pane_w_key.get_root(document, comm=comm)
+    assert model.mapbox_api_key == "ABC"
+
+@pydeck_available
+def test_pydeck_no_min_max_zoom_issue_5790(document, comm):
+    state_w_no_min_max_zoom = {
+        "latitude": 37.7749,
+        "longitude": -122.4194,
+        "zoom": 10,
+        "bearing": 0,
+        "pitch": 0,
+    }
+    view_state = pydeck.ViewState(**state_w_no_min_max_zoom)
+    deck = pydeck.Deck(initial_view_state=view_state)
+    pane = DeckGL(deck)
+
+    model = pane.get_root(document, comm=comm)
+    assert model.initialViewState == state_w_no_min_max_zoom
+
+@pydeck_available
+def test_pydeck_type_string_can_be_serialized_issue_5790(document, comm):
+    serializer = Serializer(references=document.models.synced_references)
+    data = [
+                {
+                    "name": "24th St. Mission (24TH)",
+                    "code": "24",
+                    "address": "2800 Mission Street, San Francisco CA 94110",
+                    "entries": 12817,
+                    "exits": 12529,
+                    # "coordinates": [-122.418466, 37.752254]
+                }
+    ]
+
+
+    layer = pydeck.Layer(
+        "TextLayer",
+        data,
+        get_text_anchor=pydeck.types.String("middle"),
+        get_alignment_baseline=pydeck.types.String("center"),
+        size_units = pydeck.types.String("meters")   # <--- The key addition to switch to meters as the units.
+    )
+    deck = pydeck.Deck(layers=[layer])
+    pane = DeckGL(deck)
+
+    model = pane.get_root(document, comm=comm)
+    serializer.serialize(model)
+    assert Serializer._encoders.pop(pydeck.types.String)