import json
import math
import os
from functools import partial
from pathlib import Path
from typing import List
from urllib.request import urlopen

import holoviews as hv
import panel as pn

import param
from panel.custom import JSComponent

from holoviz_utils.logger import apply_to_all, log_class_methods

@apply_to_all(log_class_methods)
class ColorSelection(param.Parameterized):
    json_dict = param.Dict(precedence=-1)

    color_true = param.String(default="green")
    color_false = param.String(default="red")

    selection_type = param.ObjectSelector(
        default="simple", objects=["simple", "contains"]
    )
    selection_prop = param.ObjectSelector()
    containing_prop = param.ObjectSelector()
    trigger_type = param.ObjectSelector(default="click", objects=["click", "hover"])

    auto_switch_trigger_type = param.Boolean(default=True)
    auto_zoom = param.Boolean(default=False)

    def __init__(self, **params):
        super().__init__(**params)

        self.param.watch(
            self.to_dict,
            [
                "selection_type",
                "selection_prop",
                "containing_prop",
                "trigger_type",
                "color_true",
                "color_false",
                "auto_switch_trigger_type",
                "auto_zoom",
            ],
        )
        self.to_dict(None)

    def to_dict(self, event):
        """Convert to a DeckGL-compatible dict with camelCase keys.

        Only includes properties that differ from their defaults,
        so the output is a minimal configuration dict.
        """
        result = {"@@function": "set_selection", "name": self.name}
        for name, p in self.param.objects("existing").items():
            if name in ["name", "json_dict"]:
                continue
            value = getattr(self, name)
            result[name] = value

        self.param.update(json_dict=result)


@apply_to_all(log_class_methods)
class ColorMap(param.Parameterized):
    json_dict = param.Dict(precedence=-1)

    cmap = param.ClassSelector(class_=hv.DynamicMap, precedence=-1)
    cmap_var_label: str = param.String(precedence=-1)

    cmap_var: str = param.ObjectSelector()
    cmap_qvar: str = param.ObjectSelector()
    cmap_qthreshold: float = param.Number(default=0.75)
    cmap_range: tuple = param.Range(default=(0, 1))
    cmap_palette: str = param.Selector(
        default="RdYlBu",
        objects=[
            "OrRd",
            "PuBu",
            "BuPu",
            "Oranges",
            "BuGn",
            "YlOrBr",
            "YlGn",
            "Reds",
            "RdPu",
            "Greens",
            "YlGnBu",
            "Purples",
            "GnBu",
            "Greys",
            "YlOrRd",
            "PuRd",
            "Blues",
            "PuBuGn",
            "Viridis",
            "Spectral",
            "RdYlGn",
            "RdBu",
            "PiYG",
            "PRGn",
            "RdYlBu",
            "BrBG",
            "RdGy",
            "PuOr",
            "Set2",
            "Accent",
            "Set1",
            "Set3",
            "Dark2",
            "Paired",
            "Pastel2",
            "Pastel1",
        ],
    )

    def __init__(self, **params):
        super().__init__(**params)

        self.cmap = hv.DynamicMap(
            self.make_colorbar,
            streams={
                "cmap_palette": self.param["cmap_palette"],
                "cmap_range": self.param["cmap_range"],
                "cmap_var": self.param["cmap_var"],
            },
        )

        self.param.watch(
            self.to_dict,
            ["cmap_palette", "cmap_range", "cmap_var", "cmap_qvar", "cmap_qthreshold"],
        )
        self.to_dict(None)

    def make_colorbar(self, cmap_range, cmap_palette, cmap_var):

        def hook_transparent_border(plot, element):
            plot.state.border_fill_alpha = 0.0
            plot.state.background_fill_alpha = 0.0
            plot.state.outline_line_alpha = 0.0

        hm = hv.HeatMap([(0, 0, cmap_range[0]), (0, 1, cmap_range[1])]).opts(
            colorbar=True,
            colorbar_opts={
                # "title": cmap_var,
                "background_fill_alpha": 0.0
            },
            clim=cmap_range,
            alpha=0,
            show_frame=True,
            xaxis=None,
            yaxis=None,
            frame_height=0,
            colorbar_position="top",
            toolbar="disable",
            hooks=[hook_transparent_border],
            margin=(-20, 0),
            cmap=cmap_palette,
        )

        return hm

    def to_dict(self, event):
        """Convert to a DeckGL-compatible dict with camelCase keys.

        Only includes properties that differ from their defaults,
        so the output is a minimal configuration dict.
        """
        result = {"@@function": "set_colorscale"}
        for name, p in self.param.objects("existing").items():
            if name in ["name", "cmap", "cmap_var_label", "json_dict"]:
                continue
            value = getattr(self, name)
            result[name] = value

        self.param.update(json_dict=result)


@apply_to_all(log_class_methods)
class GeoJsonLayer(param.Parameterized):
    """Configuration for a DeckGL GeoJsonLayer.

    Base class for GeoJSON-based deck.gl layers. Subclass and override
    ``_layer_type`` and ``_get_layer_properties`` for other layer types
    (e.g. MVTLayer).

    Call ``to_dict()`` to produce a DeckGL-compatible JSON-serializable dict.
    """

    _layer_type = "GeoJsonLayer"

    json_dict = param.Dict(allow_refs=True)

    layer_properties = param.Dict()

    # ---- GeoJsonLayer-specific ----
    data = param.Parameter(
        default=None,
        doc=(
            "URL string pointing to a GeoJSON file, an inline GeoJSON dict, "
            "or an array of GeoJSON Feature objects."
        ),
    )

    # ---- Base Layer (inherited) ----
    id = param.String(
        default=None,
        allow_None=True,
        doc=("Unique identifier for this layer instance."),
    )
    visible = param.Boolean(default=True, doc=("Whether the layer is visible."))
    opacity = param.Number(
        default=1.0,
        bounds=(0, 1),
        doc=("Layer opacity from 0 (transparent) to 1 (opaque)."),
    )
    pickable = param.Boolean(
        default=False, doc=("Whether the layer responds to pointer picking events.")
    )
    autoHighlight = param.Boolean(
        default=False, doc=("Highlight hovered object with highlightColor.")
    )
    highlightColor = param.Parameter(
        default=None, doc=("RGBA highlight color array. Default: [0, 0, 128, 128].")
    )
    highlightedObjectIndex = param.Integer(
        default=None, allow_None=True, doc=("Index of specific object to highlight.")
    )
    wrapLongitude = param.Boolean(
        default=False, doc=("Normalize geometry longitudes to [-180, 180].")
    )
    coordinateSystem = param.Parameter(
        default=None, doc=("How positions are geographically interpreted.")
    )
    coordinateOrigin = param.Parameter(
        default=None, doc=("Reference point [lng, lat, alt] for offset coordinates.")
    )
    modelMatrix = param.Parameter(
        default=None, doc=("4x4 column-major transformation matrix (16-element array).")
    )
    updateTriggers = param.Dict(
        default=None,
        allow_None=True,
        doc=("Triggers to re-evaluate accessors. Keys are accessor names."),
    )
    extensions = param.List(
        default=None,
        allow_None=True,
        doc=("List of LayerExtension instances for additional functionality."),
    )
    parameters = param.Dict(
        default=None, allow_None=True, doc=("GPU rendering parameters.")
    )

    # ---- GeoJSON Fill ----
    filled = param.Boolean(
        default=True, doc=("Whether to draw filled polygons and circle points.")
    )
    getFillColor = param.Parameter(
        default=None,
        doc=(
            "RGBA fill color. Accessor, constant, or function ref. "
            "Default: [0, 0, 0, 255]."
        ),
    )

    # ---- GeoJSON Stroke ----
    stroked = param.Boolean(
        default=True, doc=("Whether to draw outlines around polygons and points.")
    )
    getLineColor = param.Parameter(
        default=None,
        doc=(
            "RGBA stroke color. Accessor, constant, or function ref. "
            "Default: [0, 0, 0, 255]."
        ),
    )
    getLineWidth = param.Parameter(
        default=None,
        doc=("Line width in lineWidthUnits. Accessor or constant. Default: 1."),
    )
    lineWidthUnits = param.Selector(
        default="meters",
        objects=["meters", "common", "pixels"],
        doc="Units for line width values.",
    )
    lineWidthScale = param.Number(
        default=1, bounds=(0, None), doc=("Multiplier applied to all line widths.")
    )
    lineWidthMinPixels = param.Number(
        default=0, bounds=(0, None), doc=("Minimum line width in pixels.")
    )
    lineWidthMaxPixels = param.Number(
        default=None, allow_None=True, doc=("Maximum line width in pixels.")
    )
    lineCapRounded = param.Boolean(
        default=False, doc=("Use rounded line caps instead of square.")
    )
    lineJointRounded = param.Boolean(
        default=False, doc=("Use rounded line joints instead of miter.")
    )
    lineMiterLimit = param.Number(
        default=4, bounds=(0, None), doc=("Max ratio of joint extent to stroke width.")
    )
    lineBillboard = param.Boolean(
        default=False, doc=("Whether lines always face the camera in 3D.")
    )

    # ---- 3D Extrusion ----
    extruded = param.Boolean(
        default=False, doc=("Whether to extrude polygons along the z-axis.")
    )
    wireframe = param.Boolean(
        default=False, doc=("Whether to render wireframe for extruded geometry.")
    )
    getElevation = param.Parameter(
        default=None,
        doc=("Polygon extrusion height. Accessor or constant. Default: 1000."),
    )
    elevationScale = param.Number(
        default=1, bounds=(0, None), doc=("Multiplier for all elevation values.")
    )
    material = param.Parameter(
        default=None,
        doc=("Material properties for lighting. True for defaults, or a dict."),
    )

    # ---- Point Type ----
    pointType = param.String(
        default="circle",
        doc=("Point rendering: 'circle', 'icon', 'text', or combined with '+'."),
    )

    # ---- Circle Points ----
    getPointRadius = param.Parameter(
        default=None, doc=("Circle point radius. Accessor or constant. Default: 1.")
    )
    pointRadiusUnits = param.Selector(
        default="meters",
        objects=["meters", "common", "pixels"],
        doc="Units for point radius values.",
    )
    pointRadiusScale = param.Number(
        default=1, bounds=(0, None), doc=("Multiplier for all point radii.")
    )
    pointRadiusMinPixels = param.Number(
        default=0, bounds=(0, None), doc=("Minimum point radius in pixels.")
    )
    pointRadiusMaxPixels = param.Number(
        default=None, allow_None=True, doc=("Maximum point radius in pixels.")
    )
    pointAntialiasing = param.Boolean(
        default=True, doc=("Whether to antialias circle points.")
    )
    pointBillboard = param.Boolean(
        default=False, doc=("Whether circle points always face the camera.")
    )

    # ---- Icon Points ----
    iconAtlas = param.Parameter(
        default=None, doc=("URL or Texture2D for the icon atlas image.")
    )
    iconMapping = param.Parameter(
        default=None, doc=("Icon name to descriptor mapping. URL string or dict.")
    )
    getIcon = param.Parameter(
        default=None, doc=("Icon name per feature. Accessor or constant.")
    )
    getIconSize = param.Parameter(
        default=None, doc=("Icon size per feature. Accessor or constant. Default: 1.")
    )
    getIconColor = param.Parameter(
        default=None,
        doc=("RGBA icon color. Accessor or constant. Default: [0, 0, 0, 255]."),
    )
    getIconAngle = param.Parameter(
        default=None,
        doc=("Icon rotation in degrees. Accessor or constant. Default: 0."),
    )
    getIconPixelOffset = param.Parameter(
        default=None,
        doc=("Icon pixel offset [x, y]. Accessor or constant. Default: [0, 0]."),
    )
    iconSizeUnits = param.Selector(
        default="pixels",
        objects=["pixels", "meters", "common"],
        doc="Units for icon size.",
    )
    iconSizeScale = param.Number(
        default=1, bounds=(0, None), doc=("Multiplier for all icon sizes.")
    )
    iconSizeMinPixels = param.Number(
        default=0, bounds=(0, None), doc=("Minimum icon size in pixels.")
    )
    iconSizeMaxPixels = param.Number(
        default=None, allow_None=True, doc=("Maximum icon size in pixels.")
    )
    iconBillboard = param.Boolean(
        default=True, doc=("Whether icons always face the camera.")
    )
    iconAlphaCutoff = param.Number(
        default=0.05,
        bounds=(0, 1),
        doc=("Alpha value below which pixels are discarded."),
    )

    # ---- Text Points ----
    getText = param.Parameter(
        default=None, doc=("Text string per feature. Accessor or constant.")
    )
    getTextColor = param.Parameter(
        default=None,
        doc=("RGBA text color. Accessor or constant. Default: [0, 0, 0, 255]."),
    )
    getTextAngle = param.Parameter(
        default=None, doc=("Text rotation degrees. Accessor or constant. Default: 0.")
    )
    getTextSize = param.Parameter(
        default=None,
        doc=("Font size in textSizeUnits. Accessor or constant. Default: 32."),
    )
    getTextAnchor = param.Parameter(
        default=None,
        doc=("Horizontal anchor: 'start', 'middle', 'end'. Default: 'middle'."),
    )
    getTextAlignmentBaseline = param.Parameter(
        default=None,
        doc=("Vertical anchor: 'top', 'center', 'bottom'. Default: 'center'."),
    )
    getTextPixelOffset = param.Parameter(
        default=None,
        doc=("Text pixel offset [x, y]. Accessor or constant. Default: [0, 0]."),
    )
    getTextBackgroundColor = param.Parameter(
        default=None, doc=("RGBA background color. Default: [255, 255, 255, 255].")
    )
    getTextBorderColor = param.Parameter(
        default=None, doc=("RGBA text border color. Default: [0, 0, 0, 255].")
    )
    getTextBorderWidth = param.Parameter(
        default=None, doc=("Text border width. Accessor or constant. Default: 0.")
    )
    textSizeUnits = param.Selector(
        default="pixels",
        objects=["pixels", "meters", "common"],
        doc="Units for text size.",
    )
    textSizeScale = param.Number(
        default=1, bounds=(0, None), doc=("Multiplier for all text sizes.")
    )
    textSizeMinPixels = param.Number(
        default=0, bounds=(0, None), doc=("Minimum text size in pixels.")
    )
    textSizeMaxPixels = param.Number(
        default=None, allow_None=True, doc=("Maximum text size in pixels.")
    )
    textFontFamily = param.String(
        default="Monaco, monospace", doc=("CSS font-family for text rendering.")
    )
    textFontWeight = param.String(
        default="normal", doc=("CSS font-weight for text rendering.")
    )
    textLineHeight = param.Number(
        default=1, doc=("Line height multiplier for multi-line text.")
    )
    textMaxWidth = param.Number(
        default=-1, doc=("Max text width before wrapping. -1 disables wrapping.")
    )
    textWordBreak = param.Selector(
        default="break-word",
        objects=["break-word", "break-all"],
        doc="Word break strategy for text wrapping.",
    )
    textBackground = param.Boolean(
        default=False, doc=("Whether to draw a background behind text labels.")
    )
    textBackgroundPadding = param.Parameter(
        default=None,
        doc=("Background padding [horizontal, vertical]. Default: [0, 0]."),
    )
    textOutlineColor = param.Parameter(
        default=None, doc=("RGBA text outline color. Default: [0, 0, 0, 255].")
    )
    textOutlineWidth = param.Parameter(
        default=None, doc=("Text outline width. Default: 0.")
    )
    textBillboard = param.Boolean(
        default=True, doc=("Whether text always faces the camera.")
    )
    textFontSettings = param.Dict(
        default=None, allow_None=True, doc=("Advanced SDF font atlas settings.")
    )
    textCharacterSet = param.Parameter(
        default=None, doc=("Characters to include in font atlas. String or list.")
    )

    def __init__(self, **params):
        super().__init__(**params)
        self.layer_properties = self._get_layer_properties()
        self._setup_watchers()
        self.to_dict(None)

    def _get_layer_properties(self):
        return self._get_geojson_properties(self.data)

    def _setup_watchers(self):
        for name, p in self.param.objects("existing").items():
            if name == "name":
                continue
            value = getattr(self, name)
            if isinstance(value, param.Parameterized) and "json_dict" in value.param:
                value.param.watch(self.to_dict, ["json_dict"])
                if "cmap_var" in value.param:
                    self._set_objects(value, "cmap_var", types_=["Number"])
                if "cmap_qvar" in value.param:
                    self._set_objects(value, "cmap_qvar", types_=["Number"])
                if "selection_prop" in value.param:
                    self._set_objects(value, "selection_prop")
                if "containing_prop" in value.param:
                    self._set_objects(value, "containing_prop")

    def _set_objects(self, value, param_name, types_=None):
        if types_ is not None:
            x_ = [x for x, typ in self.layer_properties.items() if typ in types_]
        else:
            x_ = list(self.layer_properties.keys())
        value.param[param_name].objects = x_
        if getattr(value, param_name) is None:
            value.param.update(**{param_name: value.param[param_name].objects[0]})

    @staticmethod
    def _get_geojson_properties(data):
        """Extract feature property names and inferred types from GeoJSON data.

        Supports a URL string or an inline GeoJSON dict/list.
        Returns a dict mapping property names to type strings
        (e.g. ``{"pop": "Number", "name": "String"}``).
        """
        if data is None:
            return {}

        if isinstance(data, str):
            response = urlopen(data)
            data = json.loads(response.read())

        features = []
        if isinstance(data, dict):
            features = data.get("features", [])
        elif isinstance(data, list):
            features = data

        if not features:
            return {}

        type_map = {int: "Number", float: "Number", str: "String", bool: "Boolean"}
        props = {}
        for feat in features:
            for k, v in feat.get("properties", {}).items():
                if k not in props and v is not None:
                    props[k] = type_map.get(type(v), "String")
        return props

    def to_dict(self, event):
        """Convert to a DeckGL-compatible dict.

        Only includes properties that differ from their defaults,
        so the output is a minimal configuration dict.
        """
        result = {"@@type": self._layer_type}
        update_triggers = {}
        for name, p in self.param.objects("existing").items():
            if name in ["name", "json_dict", "layer_properties"]:
                continue
            value = getattr(self, name)
            if isinstance(value, param.Parameterized) and "json_dict" in value.param:
                value = value.json_dict
                if name[:3] == "get":
                    update_triggers[name] = [{"@@function": "colorscale_trigger"}]
            if value is None:
                continue
            if value == p.default:
                continue
            result[name] = value

        if update_triggers:
            result["updateTriggers"] = update_triggers

        self.param.update(json_dict=result)


@apply_to_all(log_class_methods)
class MVTLayer(GeoJsonLayer):
    """Configuration for a DeckGL MVTLayer.

    Subclass of GeoJsonLayer that adds MVTLayer and TileLayer-specific
    properties. Inherits all GeoJSON styling, base layer, and point/icon/text
    parameters from GeoJsonLayer.

    Call ``to_dict()`` to produce a DeckGL-compatible JSON-serializable dict.
    """

    _layer_type = "MVTLayer"

    ivs = param.Dict(
        precedence=-1, doc="Estimated initial view state based on tile metadata."
    )

    # ---- Override data type (URL template string) ----
    data = param.String(
        default=None,
        doc=("URL template, TileJSON URL, array of URL templates, or TileJSON object."),
    )

    # ---- MVTLayer-specific ----
    uniqueIdProperty = param.String(
        default=None,
        allow_None=True,
        doc=("Feature attribute name containing a unique ID across tiles."),
    )
    highlightedFeatureId = param.Parameter(
        default=None,
        doc=("Feature ID to highlight. Compared against uniqueIdProperty."),
    )
    binary = param.Boolean(
        default=True, doc=("Use binary tile data for 2-3x performance improvement.")
    )
    loadOptions = param.Dict(
        default=None,
        allow_None=True,
        doc=("Options passed to the MVTLoader (e.g. worker configuration)."),
    )

    # ---- TileLayer (inherited) ----
    tileSize = param.Integer(
        default=512,
        bounds=(1, None),
        doc=("Pixel dimensions of each tile, typically a power of 2."),
    )
    zoomOffset = param.Integer(
        default=0, doc=("Integer offset applied to zoom level when fetching tiles.")
    )
    maxZoom = param.Integer(
        default=None,
        allow_None=True,
        doc=("Max zoom level. Tiles are overzoomed beyond this level."),
    )
    minZoom = param.Integer(
        default=0, bounds=(0, None), doc=("Minimum zoom level for tile display.")
    )
    extent = param.List(
        default=None,
        allow_None=True,
        doc=("Bounding box [minX, minY, maxX, maxY] to limit tile loading area."),
    )
    maxCacheSize = param.Integer(
        default=None, allow_None=True, doc=("Maximum number of tiles to keep in cache.")
    )
    maxCacheByteSize = param.Integer(
        default=None, allow_None=True, doc=("Maximum memory (bytes) for cached tiles.")
    )
    maxRequests = param.Integer(
        default=6, doc=("Max concurrent tile requests. Use -1 for unlimited (HTTP/2).")
    )
    debounceTime = param.Number(
        default=0,
        bounds=(0, None),
        doc=("Milliseconds to queue tile requests before issuing."),
    )
    refinementStrategy = param.Selector(
        default="best-available",
        objects=["best-available", "no-overlap", "never"],
        doc="How placeholder tiles display during loading.",
    )
    zRange = param.List(
        default=None,
        allow_None=True,
        doc=("Height range [minZ, maxZ] for 2.5D tile content."),
    )

    def __init__(self, **params):
        super().__init__(**params)
        self._get_ivs_estimate()

    def _get_ivs_estimate(self):
        data_json = self._get_mvt_meta(
            self.data.replace("{z}/{x}/{y}.pbf", "metadata.json")
        )
        bbox = data_json["bounds"].split(",")
        self.ivs = {
            "bearing": 0,
            "latitude": (float(bbox[1]) + float(bbox[3])) / 2,
            "longitude": (float(bbox[0]) + float(bbox[2])) / 2,
            "minZoom": int(data_json["minzoom"]),
            "maxZoom": int(data_json["maxzoom"]),
            "pitch": 30,
            "zoom": int(data_json["minzoom"]) + 0.5,
        }

    def _get_layer_properties(self):
        return self._get_mvt_field_meta(
            self.data.replace("{z}/{x}/{y}.pbf", "metadata.json")
        )

    @staticmethod
    def _get_mvt_field_meta(url):
        data_json = MVTLayer._get_mvt_meta(url)
        info = [x["fields"] for x in json.loads(data_json["json"])["vector_layers"]]
        return info[0]

    @staticmethod
    def _get_mvt_meta(url):
        response = urlopen(url)
        return json.loads(response.read())


# @apply_to_all(log_class_methods)
class DeckGLPane(JSComponent):
    object: str = param.String()

    initial_view_state: dict = param.Dict()

    views: List = param.ObjectSelector(default="MapView", objects=["MapView", "GlobeView"], constant=True)

    tooltip_format = param.String(default=None, allow_None=True)

    layers: List = param.List(nested_refs=True)

    parse_geometry = param.Boolean(
        default=False,
        allow_refs=True,
        constant=True,
        doc="Whether to include the full coordinates of the pickable in the click_state. Can only be set at initialization,"
        "otherwise leads to unexpected behavior due to internal caching in DeckGL.",
    )

    click_state: dict = param.Dict(default=None)
    hover_state: dict = param.Dict(default=None)

    container_size: dict = param.Dict(default=None)
    zoom_box = param.List(default=None)

    _esm = Path(__file__).parent / "deckgl.js"

    def __init__(self, **params):
        super().__init__(**params)

        self.update_object(None)
        self.param.watch(
            self.update_object,
            ["views", "initial_view_state", "layers", "tooltip_format", "parse_geometry"],
        )
        self.param.watch(self.send_object_to_js, ["object"])

        self._click_state_watchers = {}
        self.param.watch(self._manage_click_state_watchers, ["layers"])
        self._manage_click_state_watchers(None)

        self.param.watch(self.update_ivs, ["zoom_box"])

    def update_ivs(self, event):
        bb = self.zoom_box
        if bb is not None:
            # Calculate the center of the bounding box
            center_lat = (bb[1] + bb[3]) / 2
            center_lng = (bb[0] + bb[2]) / 2

            # Calculate the size of the bounding box
            lat_diff = bb[3] - bb[1]
            lng_diff = bb[2] - bb[0]

            # Get the viewport dimensions
            viewport_width = self.container_size.get("width", 500)
            viewport_height = self.container_size.get("height", 500)

            # Calculate the zoom level based on the bounding box and viewport size
            WORLD_SCALE = 256  # Constant for Web Mercator projection
            lat_zoom = math.log2(WORLD_SCALE * viewport_height / (360 * lat_diff))
            lng_zoom = math.log2(WORLD_SCALE * viewport_width / (360 * lng_diff))
            zoom = (
                min(lat_zoom, lng_zoom) * 0.90
            )  # Use the smaller zoom to fit the entire bounding box

            # Create the new view state
            ivs = {
                "latitude": center_lat,
                "longitude": center_lng,
                "zoom": zoom,
                "transitionDuration": "auto",  # Smooth transition
                "transitionInterpolator": {"@@function": "create_FlyToInterpolator"},
            }

            # Update the initial view state
            self.param.update(initial_view_state={**self.initial_view_state, **ivs})

    def _manage_click_state_watchers(self, event):
        """Add/remove click_state watchers based on auto_switch_trigger_type in ColorSelection."""
        active_keys = set()
        for layer_idx, layer in enumerate(self.layers):
            for prop, value in layer.items():
                if (prop[:3] == "get" and prop[-5:] == "Color") and isinstance(
                    value, dict
                ):
                    if value.get("@@function", "") == "set_selection" and value.get(
                        "auto_switch_trigger_type", False
                    ):
                        active_keys.add((layer_idx, prop))

        # Remove watchers that are no longer needed
        for key in list(self._click_state_watchers):
            if key not in active_keys:
                self.param.unwatch(self._click_state_watchers.pop(key))

        # Add watchers for newly active entries
        for key in active_keys:
            if key not in self._click_state_watchers:
                layer_idx, prop = key
                watcher = self.param.watch(
                    partial(
                        self.switch_trigger_type, layer_idx=layer_idx, get_X_Color=prop
                    ),
                    ["click_state"],
                )
                self._click_state_watchers[key] = watcher

    def switch_trigger_type(self, event, layer_idx=None, get_X_Color=None):
        if not self.click_state.get("properties", {}):
            self.layers[layer_idx][get_X_Color]["trigger_type"] = "hover"
        else:
            self.layers[layer_idx][get_X_Color]["trigger_type"] = "click"
        self.param.trigger("layers")

    def send_object_to_js(self, event):
        self._send_msg({"object": self.object})

    def _handle_msg(self, msg):
        x = json.loads(msg)
        self.param.update(**x)

    def update_object(self, event):

        pickables = [True for lyr in self.layers if lyr.get("pickable", False)]
        if len(pickables) > 1:
            raise NotImplementedError("Only one layer can be pickable at the moment, found {}.".format(pickables))

        json_dict = {
            **{"views": {"@@type": self.views}},
            **{"parameters": {"cull": self.views == "GlobeView"}},
            **{"initialViewState": self.initial_view_state},
            **{"layers": self.layers},
            **{
                "controller": True,
                "onClick": {
                    "@@function": "set_clicker",
                    "parseGeometry": self.parse_geometry,
                },
                "onHover": {"@@function": "set_hover"},
                "getTooltip": {
                    "@@function": "set_tooltip",
                    "customTemplate": self.tooltip_format,
                },
            },
        }

        json_ = json.dumps(json_dict)

        self.param.update(object=json_)


class DeckGL(param.Parameterized):

    map_pane = param.ClassSelector(class_=DeckGLPane)

    layers = param.List(default=[])

    tooltip_format = param.String()

    initial_view_state = param.Dict()

    click_state = param.Parameter(allow_refs=True)

    def __init__(self, **params):
        super().__init__(**params)

        self.search_ivs()

        layers_ = list()
        for lyr in self.layers:
            if isinstance(lyr, param.Parameterized):
                if "json_dict" in lyr.param:
                    layers_.append(lyr.param["json_dict"])
            else:
                layers_.append(lyr)
        
        self.map_pane = DeckGLPane(
            initial_view_state=self.initial_view_state,
            tooltip_format=self.tooltip_format,
            layers=layers_,
            views="MapView",
        )

        self.view = self.make_view()

    def search_ivs(self):
        ivss = list()
        for lyr in self.layers:
            if isinstance(lyr, param.Parameterized):
                for key in [x for x in lyr.param if x == "ivs"]:
                    ivss.append(getattr(lyr, key))
        self.param.update(
            initial_view_state=ivss[0] if len(ivss) > 0 else {}
        )

    def make_view(self):

        self.map_pane.sizing_mode = "stretch_width"
        self.map_pane.height=500

        column = [
            self.map_pane.param["views"],
            self.map_pane.param["tooltip_format"],
            self.map_pane.param["parse_geometry"],
        ]

        for lyr in self.layers:
            if isinstance(lyr, param.Parameterized):
                for key in [x for x in lyr.param if isinstance(getattr(lyr, x), param.Parameterized) and lyr.pickable]:
                    column.append(getattr(lyr, key).param)

        column.append(pn.pane.JSON(self.map_pane.param["click_state"]))

        view = pn.Row(
            self.map_pane,
            pn.Column(
                *column
            ),
        )

        return view
