import base64
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 pandas as pd

import param
from panel.custom import JSComponent

from holoviz_utils.logger import apply_to_all, log_class_methods
from holoviz_utils.searcher import SearcherMixin

import logging

import param


@apply_to_all(log_class_methods)
class ColorMap(param.Parameterized):
    """Configuration for a continuous color scale used in DeckGL layers.

    Manages a color palette, value range, and optional quantile variable.
    Produces a DeckGL-compatible ``json_dict`` via :meth:`to_dict` and a
    HoloViews colorbar element via the ``cmap`` DynamicMap.

    Parameters
    ----------
    cmap_var : str
        Name of the data property to map to the color scale.
    cmap_palette : str
        Name of the ColorBrewer/Viridis palette to use. Default: ``"RdYlBu"``.
    cmap_range : tuple
        ``(min, max)`` value range for the color scale. Default: ``(0, 1)``.
    cmap_qvar : str, optional
        Name of a secondary property used for quantile thresholding.
    cmap_qthreshold : float, optional
        Quantile threshold value for ``cmap_qvar``.
    """

    json_dict = param.Dict(precedence=-1)

    cmap_var_labels: dict = param.Dict(default={})

    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(default=None, allow_None=True)
    cmap_qthreshold: float = param.Number(default=None, allow_None=True)
    cmap_range: tuple = param.Range(default=(0, 1))
    cmap_range_quantile: bool = param.Boolean(
        default=False,
        doc="Calculate the range of the cmap using q01/q99, otherwise uses min/max",
    )
    lock_cmap_range: bool = param.Boolean(
        default=False,
        # doc="When True, prevent automatic recalculation of cmap_range.",
    )
    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):
        """Initialise ColorMap, wiring the colorbar DynamicMap and change watchers."""
        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_label": self.param["cmap_var_label"],
            },
        )

        self.param.watch(self.update_cmap_var_label, ["cmap_var"])

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

    def update_cmap_var_label(self, event):
        """Update the colorbar title based on the current cmap_var and cmap_var_labels."""
        var_name = event.new
        label = self.cmap_var_labels.get(var_name, var_name)
        self.param.update(cmap_var_label=label)

    def make_colorbar(self, cmap_range, cmap_palette, cmap_var_label):
        """Build a HoloViews HeatMap element that renders as a standalone colorbar.

        Parameters
        ----------
        cmap_range : tuple
            ``(min, max)`` value range shown on the colorbar.
        cmap_palette : str
            ColorBrewer or Viridis palette name.
        cmap_var : str
            Variable name used as the colorbar title.

        Returns
        -------
        hv.HeatMap
            A transparent HeatMap configured to display only its colorbar.
        """

        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

        def hook_set_title(plot, element):
            for renderer in plot.state.above:
                if hasattr(renderer, "title"):
                    renderer.title = cmap_var_label

        hm = hv.HeatMap([(0, 0, cmap_range[0]), (0, 1, cmap_range[1])]).opts(
            colorbar=True,
            colorbar_opts={
                "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, hook_set_title],
            margin=(-10, 0),
            cmap=cmap_palette,
        )

        hm.sizing_mode = "stretch_width"

        return hm

    def to_dict(self, *events):
        """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 ColorSelection(param.Parameterized):
    """Configuration for conditional feature coloring driven by user interaction.

    Determines how a DeckGL layer colors features based on a selection state
    (click or hover).  Supports two selection modes:

    - ``"simple"`` – colors the feature whose ``selection_prop`` matches the
      selected value.
    - ``"contains"`` – colors features whose ``containing_prop`` contains the
      selected feature's ``selection_prop`` value.

    Parameters
    ----------
    color_true : str or ColorMap
        Color applied to selected (matching) features.
    color_false : str or ColorMap
        Color applied to non-selected features.
    selection_type : {"simple", "contains"}
        Selection matching strategy.
    selection_prop : str
        Feature property used to identify the selected feature.
    containing_prop : str
        Feature property inspected for containment checks (``"contains"`` mode).
    trigger_type : {"click", "hover"}
        Interaction event that triggers the color change.
    auto_switch_trigger_type : bool
        When ``True``, automatically switch between ``"click"`` and ``"hover"``
        based on whether a feature is currently selected. Default: ``True``.
    auto_zoom : bool
        When ``True``, zoom to the selected feature's bounding box.
        Default: ``False``.
    """

    json_dict = param.Dict(precedence=-1)

    color_true = param.ClassSelector(class_=(str, ColorMap), default="green")
    color_false = param.ClassSelector(class_=(str, ColorMap), default="green")

    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):
        """Initialise ColorSelection and register parameter change watchers."""
        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.param.watch(self._on_color_change, ["color_true", "color_false"])
        self._nested_watchers = {}
        self._setup_nested_watchers()
        self.to_dict(None)

    def _setup_nested_watchers(self):
        """Watch json_dict on any Parameterized parameter values."""
        for name, _ in self.param.objects("existing").items():
            if name in ["name", "json_dict"]:
                continue
            value = getattr(self, name)
            if isinstance(value, param.Parameterized) and hasattr(value, "json_dict"):
                if name in self._nested_watchers:
                    old_obj, old_watcher = self._nested_watchers[name]
                    if old_obj is value:
                        continue
                    old_obj.param.unwatch(old_watcher)
                watcher = value.param.watch(
                    self._make_nested_json_callback(name), ["json_dict"]
                )
                self._nested_watchers[name] = (value, watcher)
            elif name in self._nested_watchers:
                old_obj, old_watcher = self._nested_watchers.pop(name)
                old_obj.param.unwatch(old_watcher)

    def _make_nested_json_callback(self, param_name):
        """Return a callback that patches only param_name in json_dict, preserving other keys."""

        def _callback(event):
            if self.json_dict is not None:
                self.param.update(json_dict={**self.json_dict, param_name: event.new})

        return _callback

    def _on_color_change(self, event):
        """Refresh nested watchers when color_true or color_false is replaced."""
        self._setup_nested_watchers()

    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)
            if isinstance(value, param.Parameterized):
                if hasattr(value, "json_dict"):
                    value = getattr(value, "json_dict")
                else:
                    continue
            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):
        """Initialise GeoJsonLayer, extract layer properties, and register watchers."""
        super().__init__(**params)
        self.layer_properties = self._get_layer_properties()
        self._setup_watchers()
        self.to_dict(None)

    def _get_layer_properties(self):
        """Return the property name→type mapping for this layer's data source."""
        return self._get_geojson_properties(self.data)

    def _setup_watchers(self, depth=2):
        """Wire ``json_dict`` watchers for any nested ``param.Parameterized`` values.

        For each parameter whose current value is a ``param.Parameterized`` object
        that exposes a ``json_dict`` parameter, this method:

        - Watches ``json_dict`` changes so :meth:`to_dict` re-runs automatically.
        - Populates the ``objects`` list (and sets a sensible default) for
          ``cmap_var``, ``cmap_qvar``, ``selection_prop``, and ``containing_prop``
          based on the feature properties discovered in :attr:`layer_properties`.

        Parameters
        ----------
        depth : int, optional
            How many levels of nested ``param.Parameterized`` objects to traverse.
            Defaults to 2.
        """
        self._setup_watchers_recursive(self, depth=depth)

    def _setup_watchers_recursive(self, obj, depth):
        """Recursively wire ``json_dict`` watchers on nested ``param.Parameterized`` objects.

        Parameters
        ----------
        obj : param.Parameterized
            The object whose parameters are inspected at this level.
        depth : int
            Remaining levels to traverse. Recursion stops when this reaches 0.
        """
        if depth == 0:
            return
        for name, p in obj.param.objects("existing").items():
            if name == "name":
                continue
            value = getattr(obj, name)
            if not isinstance(value, param.Parameterized):
                continue
            if "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 "extra_data_indexer" in value.param:
                    self._set_objects(value, "extra_data_indexer")
                if "containing_prop" in value.param:
                    self._set_objects(value, "containing_prop")
            self._setup_watchers_recursive(value, depth=depth - 1)

    def _set_objects(self, value, param_name, types_=None):
        """Populate the ``objects`` list of a nested parameter from layer properties.

        Parameters
        ----------
        value : param.Parameterized
            The nested parameterised object whose parameter should be updated.
        param_name : str
            Name of the ``ObjectSelector`` parameter on ``value`` to populate.
        types_ : list of str, optional
            If given, only properties whose type string is in this list are
            included (e.g. ``["Number"]``).  If ``None``, all properties are
            included.
        """
        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 = value.param[param_name].objects + [
            v for v in x_ if v not in value.param[param_name].objects
        ]

    @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 _build_dict(self):
        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

        return result

    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.
        """
        self.param.update(json_dict=self._build_dict())


@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"

    initial_view_state = 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):
        """Initialise MVTLayer and fetch the initial view state estimate from tile metadata."""
        super().__init__(**params)
        self._get_ivs_estimate()

    def _build_dict(self):
        result = super()._build_dict()
        result["onTileError"] = {"@@function": "suppress_tile_errors"}
        return result

    def _get_ivs_estimate(self):
        """Populate :attr:`initial_view_state` from the tile-set's ``metadata.json``.

        Fetches the ``metadata.json`` adjacent to the MVT tile URL template and
        uses the ``bounds``, ``minzoom``, and ``maxzoom`` fields to construct a
        sensible starting camera position.
        """
        data_json = self._get_mvt_meta(
            self.data.replace("{z}/{x}/{y}.pbf", "metadata.json")
        )
        bbox = data_json["bounds"].split(",")
        self.initial_view_state = {
            "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 the field name→type mapping from the MVT tile-set metadata."""
        return self._get_mvt_field_meta(
            self.data.replace("{z}/{x}/{y}.pbf", "metadata.json")
        )

    @staticmethod
    def _get_mvt_field_meta(url):
        """Fetch field metadata from an MVT ``metadata.json`` URL.

        Parameters
        ----------
        url : str
            URL of the ``metadata.json`` file for the tile-set.

        Returns
        -------
        dict
            Field name→type mapping for the first vector layer in the tile-set.
        """
        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):
        """Fetch and parse a JSON metadata file from ``url``.

        Parameters
        ----------
        url : str
            URL to fetch.

        Returns
        -------
        dict
            Parsed JSON content.
        """
        response = urlopen(url)
        return json.loads(response.read())


@apply_to_all(log_class_methods)
class COGLayer(param.Parameterized):
    """Configuration for a DeckGL COGLayer (Cloud Optimized GeoTIFF).

    Renders raster tiles from a COG source via the
    ``@developmentseed/deck.gl-geotiff`` package. COGLayer is a TileLayer
    subclass on the JS side, so TileLayer properties are exposed here.

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

    _layer_type = "COGLayer"

    json_dict = param.Dict(allow_refs=True)

    # ---- COGLayer-specific ----
    geotiff = param.String(
        default=None,
        allow_None=True,
        doc=("URL to the Cloud Optimized GeoTIFF file."),
    )

    # ---- Base Layer ----
    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.")
    )

    # ---- TileLayer ----
    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)
        watch_params = [
            n for n in self.param if n not in ("name", "json_dict")
        ]
        self.param.watch(self.to_dict, watch_params)
        self.to_dict(None)

    def _build_dict(self):
        result = {"@@type": self._layer_type}
        for name, p in self.param.objects("existing").items():
            if name in ["name", "json_dict"]:
                continue
            value = getattr(self, name)
            if value is None:
                continue
            if value == p.default:
                continue
            result[name] = value
        result["onTileError"] = {"@@function": "suppress_tile_errors"}
        return result

    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.
        """
        self.param.update(json_dict=self._build_dict())


# @apply_to_all(log_class_methods)
class DeckGLPane(JSComponent):
    """Low-level Panel JSComponent that renders a deck.gl map.

    Serialises the full deck.gl JSON spec to :attr:`object` and sends it to
    the JavaScript front-end via :meth:`send_object_to_js`.  Incoming
    messages from JS (click / hover events, container size) are handled by
    :meth:`_handle_msg` and written back to the corresponding parameters.

    In most cases you should use the higher-level :class:`DeckGL` wrapper
    instead of instantiating this class directly.
    """

    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)

    extra_data = param.Series(allow_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.",
    )

    bounding_box_property = param.ObjectSelector(
        default=None,
        allow_None=True,
        doc=(
            "Name of the feature property that contains a pre-computed bounding box string "
            "(e.g. '(minx, miny, maxx, maxy)'). When set and a clicked feature has this property, "
            "it is used directly for auto-zoom instead of computing the bbox from geometry."
        ),
    )

    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)
    zoom_offset = param.Dict(
        default={},
        constant=True,
        doc=(
            "Offsets to shift the zoom target away from obscured areas. "
            "Keys: 'left', 'right', 'top', 'bottom'. "
            "Values: percentage string (e.g. '30%') or pixel string (e.g. '200px') "
            "or a bare number (interpreted as pixels)."
        ),
    )

    _VENDOR_JS = (Path(__file__).parent / "vendor" / "deckgl-bundle.min.js").read_text()
    _VENDOR_URL = "data:text/javascript;base64," + base64.b64encode(_VENDOR_JS.encode()).decode()

    _importmap = {
        "imports": {
            "deck-vendor": _VENDOR_URL,
        }
    }

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

    def __init__(self, **params):
        """Initialise DeckGLPane, build the initial JSON spec, and wire all watchers."""
        super().__init__(**params)

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

        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"])

        self.param.trigger("extra_data")

    @staticmethod
    def _parse_offset(value, total_pixels):
        """Parse an offset value to pixels.

        Parameters
        ----------
        value : str or int or float
            A percentage string (e.g. ``"30%"``), a pixel string
            (e.g. ``"200px"``), or a bare number (interpreted as pixels).
        total_pixels : float
            The total dimension in pixels (width or height) used to
            resolve percentage values.

        Returns
        -------
        float
            The offset in pixels.
        """
        if isinstance(value, (int, float)):
            return float(value)
        value = str(value).strip()
        if value.endswith("%"):
            return float(value[:-1]) / 100.0 * total_pixels
        if value.endswith("px"):
            return float(value[:-2])
        return float(value)

    def update_ivs(self, event):
        """Recompute :attr:`initial_view_state` to fit :attr:`zoom_box` in the viewport.

        Accounts for :attr:`zoom_offset` so that obscured areas (e.g. a sidebar)
        do not hide the target bounding box.  The camera is transitioned smoothly
        using a ``FlyToInterpolator``.
        """
        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)

            # Parse zoom offsets into pixel values
            left_px = self._parse_offset(
                self.zoom_offset.get("left", 0), viewport_width
            )
            right_px = self._parse_offset(
                self.zoom_offset.get("right", 0), viewport_width
            )
            top_px = self._parse_offset(self.zoom_offset.get("top", 0), viewport_height)
            bottom_px = self._parse_offset(
                self.zoom_offset.get("bottom", 0), viewport_height
            )

            # Effective viewport after removing obscured areas, with padding
            # so the bbox doesn't touch the edges of the visible region.
            PADDING = 0.8
            eff_width = (viewport_width - left_px - right_px) * PADDING
            eff_height = (viewport_height - top_px - bottom_px) * PADDING

            # Calculate the zoom level based on the bounding box and effective viewport.
            # Guard against non-positive values: if offsets exceed the viewport (e.g.
            # viewport not yet sized), fall back to the full viewport; use abs() on bbox
            # diffs to handle inverted bounding boxes.
            WORLD_SCALE = 256  # Constant for Web Mercator projection
            eff_height = max(eff_height, viewport_height * PADDING, 1)
            eff_width = max(eff_width, viewport_width * PADDING, 1)
            lat_diff = max(abs(lat_diff), 1e-6)
            lng_diff = max(abs(lng_diff), 1e-6)
            lat_zoom = math.log2(WORLD_SCALE * eff_height / (360 * lat_diff))
            lng_zoom = math.log2(WORLD_SCALE * eff_width / (360 * lng_diff))
            zoom = min(lat_zoom, lng_zoom)

            # Shift the map center so the bbox appears centered in the
            # unobscured area.  DeckGL renders the given lat/lng at the
            # full viewport center, so we offset by the difference between
            # the full center and the effective center.
            dx_px = (left_px - right_px) / 2
            dy_px = (top_px - bottom_px) / 2
            deg_per_px = 360 / (WORLD_SCALE * (2**zoom)) * 0.5
            center_lng -= dx_px * deg_per_px
            center_lat += dy_px * deg_per_px
            center_lat = max(-85.051129, min(85.051129, center_lat))

            # 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_layer_watchers(
        self,
        watcher_store,
        function_name,
        opt_in_flag,
        watch_param,
        callback,
        extra_kwargs_fn=None,
    ):
        """Generic helper to add/remove param watchers driven by ``get*Color`` layer dicts.

        Scans all ``get*Color`` accessor dicts in :attr:`layers` for entries
        whose ``@@function`` matches ``function_name`` and whose ``opt_in_flag``
        is truthy, then registers or unregisters ``param.watch`` callbacks on
        ``watch_param`` accordingly.

        Parameters
        ----------
        watcher_store : dict
            Mutable dict keyed by ``(layer_idx, prop, sub_key)`` that tracks active
            watchers.  ``sub_key`` is ``None`` for a direct match and the nested
            dict key (e.g. ``"color_false"``) for a one-level-deep match.
        function_name : str
            Value of ``@@function`` to match in the layer color accessor dict.
        opt_in_flag : str
            Key in the accessor dict whose truthy value gates watcher registration.
        watch_param : str
            Name of the parameter on this object to watch.
        callback : callable
            Function called when ``watch_param`` changes. Receives ``event``,
            ``layer_idx``, and ``get_X_Color`` as arguments.
        extra_kwargs_fn : callable, optional
            Called with the accessor dict; should return a dict of additional
            keyword arguments forwarded to ``callback`` via
            :func:`functools.partial`.
        """
        active_keys = set()
        for layer_idx, layer in enumerate(self.layers):
            for prop, value in layer.items():
                if not (prop[:3] == "get" and prop[-5:] == "Color") or not isinstance(
                    value, dict
                ):
                    continue
                # Direct match
                if value.get("@@function", "") == function_name and value.get(
                    opt_in_flag, True
                ):
                    active_keys.add((layer_idx, prop, None))
                # One level deeper (e.g. ColorMap nested inside ColorSelection)
                for sub_key, sub_value in value.items():
                    if isinstance(sub_value, dict):
                        if sub_value.get(
                            "@@function", ""
                        ) == function_name and sub_value.get(opt_in_flag, True):
                            active_keys.add((layer_idx, prop, sub_key))

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

        # Add watchers for newly active entries
        for key in active_keys:
            if key not in watcher_store:
                layer_idx, prop, sub_key = key
                kwargs = {
                    "layer_idx": layer_idx,
                    "get_X_Color": prop,
                    "color_sub_key": sub_key,
                }
                if extra_kwargs_fn is not None:
                    d = self.layers[layer_idx][prop]
                    accessor = d[sub_key] if sub_key is not None else d
                    kwargs.update(extra_kwargs_fn(accessor))
                watcher = self.param.watch(
                    partial(callback, **kwargs),
                    [watch_param],
                )
                watcher_store[key] = watcher

    def _manage_click_state_watchers(self, event):
        """Add/remove click_state watchers based on auto_switch_trigger_type in ColorSelection."""
        self._manage_layer_watchers(
            self._click_state_watchers,
            "set_selection",
            "auto_switch_trigger_type",
            "click_state",
            self.switch_trigger_type,
        )

    def switch_trigger_type(
        self, event, layer_idx=None, get_X_Color=None, color_sub_key=None
    ):
        """Toggle a ``ColorSelection`` between ``"click"`` and ``"hover"`` trigger types.

        Called automatically by click_state watchers registered in
        :meth:`_manage_click_state_watchers`.  Switches to ``"hover"`` when
        nothing is selected (empty ``properties``), and back to ``"click"``
        once a feature is selected.

        Parameters
        ----------
        event : param.parameterized.Event
            The ``click_state`` change event (unused directly).
        layer_idx : int
            Index of the target layer in :attr:`layers`.
        get_X_Color : str
            Key of the color accessor dict within that layer (e.g.
            ``"getFillColor"``).
        color_sub_key : str or None
            Optional sub-key for a nested color accessor dict (unused for
            ``ColorSelection``, which is always at the top level).
        """
        target = self.layers[layer_idx][get_X_Color]
        if color_sub_key is not None:
            target = target[color_sub_key]
        if not self.click_state.get("properties", {}):
            target["trigger_type"] = "hover"
        else:
            target["trigger_type"] = "click"
        self.param.trigger("layers")

    def send_object_to_js(self, event: param.Event):
        """Push the serialised deck.gl spec to the JavaScript front-end."""
        data = getattr(self, event.name)
        if isinstance(data, pd.Series):
            message = {
                event.name: data.to_json(),
                "extra_data_indexer": data.index.name,
            }
        else:
            message = {event.name: data}
        self._send_msg(message)

    def _handle_msg(self, msg):
        """Deserialise a JSON message from JavaScript and update matching parameters."""
        logging.debug(f"Received message from JS: {msg}")
        try:
            x = json.loads(msg)
            zoom_box = x.get("click_state", {}).get("bounding_box", None)
            if zoom_box is not None:
                x["zoom_box"] = zoom_box
            self.param.update(**x)

        except Exception as e:
            import traceback
            print(f"DeckGLPane._handle_msg error: {e}\n{traceback.format_exc()}")

    def update_object(self, event):
        """Rebuild and serialise the full deck.gl JSON spec into :attr:`object`.

        Raises
        ------
        NotImplementedError
            If more than one layer has ``pickable=True``.
        """
        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
                )
            )

        ivs = self.initial_view_state
        lat = ivs.get("latitude") if ivs else None
        ivs_entry = (
            {"initialViewState": ivs}
            if ivs
            and isinstance(lat, (int, float))
            and math.isfinite(lat)
            else {}
        )

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

        json_ = json.dumps(json_dict)

        self.param.update(object=json_)


class DeckGL(SearcherMixin, param.Parameterized):
    """High-level deck.gl map widget with Panel UI controls.

    Wraps :class:`DeckGLPane` and a set of Panel widgets into a ready-to-use
    :attr:`view` (``pn.Row``).  Accepts both plain layer dicts and
    ``param.Parameterized`` layer objects (e.g. :class:`GeoJsonLayer` or
    :class:`MVTLayer`); the latter are automatically converted to reactive
    ``json_dict`` references.

    Parameters
    ----------
    layers : list
        Layer definitions.  Each entry may be a plain ``dict`` or a
        ``param.Parameterized`` object that exposes a ``json_dict`` parameter.
    initial_view_state : dict
        Initial deck.gl camera state (``latitude``, ``longitude``, ``zoom``,
        etc.).
    tooltip_format : str, optional
        Jinja-style tooltip template string.
    bounding_box_property : str, optional
        Feature property name that stores a pre-computed bounding box string.
        Used for auto-zoom on click when ``auto_zoom`` is enabled on a layer's
        :class:`ColorSelection`.
    zoom_offset : dict, optional
        Pixel/percentage offsets for the unobscured viewport area.  Keys:
        ``"left"``, ``"right"``, ``"top"``, ``"bottom"``.
    """

    map_pane = param.ClassSelector(class_=DeckGLPane)

    layers = param.List(default=[])

    tooltip_format = param.String()

    initial_view_state = param.Dict(allow_refs=True)

    click_state = param.Parameter(allow_refs=True)
    hover_state = param.Parameter(allow_refs=True)

    extra_data = param.Series(allow_refs=True)

    bounding_box_property = param.ObjectSelector(
        default=None,
        allow_None=True,
        doc=(
            "Name of the feature property that contains a pre-computed bounding box string "
            "(e.g. '(minx, miny, maxx, maxy)'). When set and a clicked feature has this property, "
            "it is used directly for auto-zoom instead of computing the bbox from geometry."
        ),
    )

    zoom_offset = param.Dict(
        default={},
        constant=True,
        doc=(
            "Offsets to shift the zoom target away from obscured areas. "
            "Keys: 'left', 'right', 'top', 'bottom'. "
            "Values: percentage string (e.g. '30%') or pixel string (e.g. '200px') "
            "or a bare number (interpreted as pixels)."
        ),
    )

    def __init__(self, **params):
        """Initialise DeckGL, convert layer objects to refs, and build the view."""
        super().__init__(**params)

        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.search_and_add_params(["initial_view_state"])

        self.map_pane = DeckGLPane(
            initial_view_state=self.initial_view_state,
            tooltip_format=self.tooltip_format,
            bounding_box_property=self.bounding_box_property,
            zoom_offset=self.zoom_offset,
            extra_data=self.param["extra_data"],
            layers=layers_,
            views="MapView",
        )

        self.map_pane.sizing_mode = "stretch_both"

        self.click_state = self.map_pane.param["click_state"]
        self.hover_state = self.map_pane.param["hover_state"]

        self.view = self.make_view()

        self.param.watch(self.add_extra_data_to_colormaps, ["extra_data"])
        self.param.trigger("extra_data")

    def add_extra_data_to_colormaps(self, event: param.parameterized.Event):
        """Update ``cmap_var.objects`` on all ColorMap instances when ``extra_data`` changes.

        If the old Series name is already present in ``objects`` it is replaced
        with the new name; otherwise the new name is appended.
        """
        if event.new is None:
            return
        old_name = event.old.name if event.old is not None else None
        new_name = event.new.name
        for path in self.find_params(["cmap_var"])["cmap_var"]:
            parts = path.split(".")
            owner = self._resolve_owner(".".join(parts[:-1]))
            if isinstance(owner, ColorMap):
                existing = list(owner.param["cmap_var"].objects)
                if old_name is not None and old_name in existing:
                    existing[existing.index(old_name)] = new_name
                elif new_name not in existing:
                    existing.append(new_name)
                owner.param["cmap_var"].objects = existing
                if owner.lock_cmap_range:
                    owner.param.update(cmap_var=event.new.name)
                else:
                    cmap_range = self.calc_cmap_range(
                        event.new, cmap_range_quantile=owner.cmap_range_quantile
                    )
                    owner.param.update(cmap_var=event.new.name, cmap_range=cmap_range)

    @staticmethod
    def calc_cmap_range(extra_data, cmap_range_quantile=False):
        if extra_data is None:
            return
        extra_data = extra_data.dropna()
        if len(extra_data) == 0:
            cmap_range = (0.0, 0.0)
        elif cmap_range_quantile:
            cmap_range = (
                float(extra_data.quantile(0.01)),
                float(extra_data.quantile(0.99)),
            )
        else:
            cmap_range = (float(extra_data.min()), float(extra_data.max()))
        return cmap_range

    def make_view(self):
        """Build and return the Panel layout for this widget.

        The layout is a ``pn.Row`` containing the map pane on the left and a
        ``pn.Column`` of controls on the right.  Controls include the map
        ``views`` selector, ``tooltip_format``, ``parse_geometry`` toggle, and
        any nested ``param.Parameterized`` parameters found on pickable layers.

        Returns
        -------
        pn.Row
            The fully assembled Panel layout.
        """

        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
