From f9517fdc88d7fd3abc1c5cf1d9acd161cc4bf125 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Mon, 26 Jan 2026 13:39:46 -0500 Subject: [PATCH 1/9] Add first-class Polars DataFrame support Fixes #1276 This commit adds native support for Polars-based geospatial workflows in leafmap, addressing the feature request for first-class Polars-ST and polars-h3 integration. ## Changes: ### New Method: add_polars() - Accepts Polars DataFrames with geometry columns (WKB or WKT format) - Supports Polars-ST spatial DataFrames - Automatic CRS detection with fallback to EPSG:4326 - Full compatibility with existing leafmap styling and options - Internally converts to GeoPandas for rendering ### Features: - Works with WKB (Well-Known Binary) geometries from Polars-ST - Works with WKT (Well-Known Text) geometry strings - Supports all geometry types (Point, LineString, Polygon, etc.) - Preserves non-geometry columns as attributes - Compatible with polars-h3 H3-indexed workflows - Comprehensive error handling and validation ### Documentation: - Added example notebook: examples/notebooks/polars_support.ipynb - Includes examples for: - Simple WKT point data - Converting GeoDataFrame to Polars - Polars-first data processing pipelines - Filtering and visualization ## Benefits: 1. Users can stay in Polars-native workflows end-to-end 2. No manual conversion code required 3. Better performance for large datasets 4. Seamless integration with Polars-ST and polars-h3 5. Natural visualization frontend for Arrow-native geospatial stacks ## Implementation: The add_polars() method: 1. Validates input is a Polars DataFrame 2. Checks geometry column exists 3. Converts Polars to pandas 4. Parses WKB/WKT geometries using shapely 5. Creates GeoPandas GeoDataFrame 6. Calls existing add_gdf() method This approach ensures compatibility with all existing leafmap backends (folium, ipyleaflet, maplibre, etc.) without requiring changes to the rendering pipeline. --- examples/notebooks/polars_support.ipynb | 238 ++++++++++++++++++++++++ leafmap/leafmap.py | 150 +++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 examples/notebooks/polars_support.ipynb diff --git a/examples/notebooks/polars_support.ipynb b/examples/notebooks/polars_support.ipynb new file mode 100644 index 0000000000..95b31783a4 --- /dev/null +++ b/examples/notebooks/polars_support.ipynb @@ -0,0 +1,238 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Polars DataFrame Support in Leafmap\n", + "\n", + "This notebook demonstrates first-class support for Polars-based geospatial workflows in leafmap.\n", + "\n", + "## Installation\n", + "\n", + "```bash\n", + "pip install leafmap polars geopandas\n", + "```\n", + "\n", + "For Polars-ST support:\n", + "```bash\n", + "pip install polars-st\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import leafmap\n", + "import polars as pl\n", + "import geopandas as gpd\n", + "from shapely.geometry import Point, Polygon" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 1: Simple Point Data\n", + "\n", + "Create a Polars DataFrame with point geometries using WKT (Well-Known Text)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create sample data with WKT geometries\n", + "data = {\n", + " \"city\": [\"New York\", \"Los Angeles\", \"Chicago\", \"Houston\", \"Phoenix\"],\n", + " \"population\": [8336817, 3979576, 2693976, 2320268, 1680992],\n", + " \"geometry\": [\n", + " \"POINT(-74.0060 40.7128)\", # New York\n", + " \"POINT(-118.2437 34.0522)\", # Los Angeles\n", + " \"POINT(-87.6298 41.8781)\", # Chicago\n", + " \"POINT(-95.3698 29.7604)\", # Houston\n", + " \"POINT(-112.0740 33.4484)\", # Phoenix\n", + " ]\n", + "}\n", + "\n", + "df_polars = pl.DataFrame(data)\n", + "print(df_polars)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize with leafmap\n", + "m = leafmap.Map(center=[37.0902, -95.7129], zoom=4)\n", + "m.add_polars(\n", + " df_polars, \n", + " geometry=\"geometry\", \n", + " crs=\"EPSG:4326\",\n", + " layer_name=\"US Cities\",\n", + " zoom_to_layer=True\n", + ")\n", + "m" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 2: Converting from GeoDataFrame to Polars\n", + "\n", + "Read GeoJSON/GeoParquet and work with it in Polars" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Start with a GeoDataFrame\n", + "url = \"https://raw.githubusercontent.com/opengeos/leafmap/master/examples/data/cable_geo.geojson\"\n", + "gdf = gpd.read_file(url)\n", + "\n", + "# Convert to Polars with WKB geometry\n", + "from shapely import wkb\n", + "\n", + "# Convert geometries to WKB bytes\n", + "gdf['geometry_wkb'] = gdf['geometry'].apply(lambda x: wkb.dumps(x))\n", + "df_polars = pl.DataFrame(gdf.drop(columns=['geometry']))\n", + "\n", + "print(df_polars.head())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize the Polars DataFrame\n", + "m = leafmap.Map()\n", + "m.add_polars(\n", + " df_polars,\n", + " geometry=\"geometry_wkb\",\n", + " crs=\"EPSG:4326\",\n", + " layer_name=\"Submarine Cables\",\n", + " zoom_to_layer=True,\n", + " style={\"color\": \"blue\", \"weight\": 2}\n", + ")\n", + "m" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 3: Polars Data Processing Pipeline\n", + "\n", + "Demonstrate Polars-first workflow with filtering and aggregation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create sample polygon data\n", + "from shapely.geometry import box\n", + "\n", + "# Create grid of rectangles\n", + "geometries = []\n", + "names = []\n", + "values = []\n", + "\n", + "import numpy as np\n", + "\n", + "for i in range(-5, 5):\n", + " for j in range(-5, 5):\n", + " geometries.append(wkb.dumps(box(i, j, i+0.8, j+0.8)))\n", + " names.append(f\"Cell_{i}_{j}\")\n", + " values.append(np.random.randint(0, 100))\n", + "\n", + "df_grid = pl.DataFrame({\n", + " \"name\": names,\n", + " \"value\": values,\n", + " \"geometry\": geometries\n", + "})\n", + "\n", + "print(df_grid.head())\n", + "print(f\"\\nTotal cells: {len(df_grid)}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Filter high-value cells using Polars\n", + "df_filtered = df_grid.filter(pl.col(\"value\") > 75)\n", + "print(f\"High-value cells: {len(df_filtered)}\")\n", + "\n", + "# Visualize\n", + "m = leafmap.Map(center=[0, 0], zoom=6)\n", + "m.add_polars(\n", + " df_filtered,\n", + " geometry=\"geometry\",\n", + " crs=\"EPSG:4326\",\n", + " layer_name=\"High Value Cells\",\n", + " zoom_to_layer=True,\n", + " style={\"fillColor\": \"red\", \"fillOpacity\": 0.5, \"color\": \"darkred\"}\n", + ")\n", + "m" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Benefits of Polars Integration\n", + "\n", + "1. **Performance**: Polars is significantly faster than Pandas for large datasets\n", + "2. **Memory Efficiency**: Better memory management with Apache Arrow backend\n", + "3. **Native Pipeline**: Stay in Polars-native workflow end-to-end\n", + "4. **Modern API**: Expressive and intuitive query syntax\n", + "5. **GeoParquet Support**: Native support for reading/writing GeoParquet files\n", + "\n", + "## Next Steps\n", + "\n", + "- Explore [Polars-ST](https://github.com/Oreilles/polars-st) for advanced spatial operations\n", + "- Try [polars-h3](https://github.com/Filimoa/polars-h3) for H3 indexing workflows\n", + "- Read GeoParquet files directly with Polars for maximum performance" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index ff426c7ea5..2ff9816f45 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -3216,6 +3216,156 @@ def add_gdf( **kwargs, ) + def add_polars( + self, + df, + geometry: Optional[str] = "geometry", + crs: Optional[str] = None, + layer_name: Optional[str] = "Untitled", + style: Optional[dict] = {}, + hover_style: Optional[dict] = {}, + style_callback: Optional[Callable] = None, + fill_colors: Optional[list[str]] = None, + info_mode: Optional[str] = "on_hover", + zoom_to_layer: Optional[bool] = False, + **kwargs, + ) -> None: + """Adds a Polars DataFrame with geometry to the map. + + This method supports Polars-ST DataFrames with spatial geometry columns + and enables direct visualization of Polars-based geospatial data without + manual conversion to GeoPandas. + + Args: + df: A Polars DataFrame with a geometry column (e.g., from Polars-ST). + geometry (str, optional): The name of the geometry column. Defaults to "geometry". + crs (str, optional): The CRS of the geometry data (e.g., "EPSG:4326"). + If None, will try to detect from Polars-ST metadata or default to EPSG:4326. + layer_name (str, optional): The layer name to be used. Defaults to "Untitled". + style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. + hover_style (dict, optional): Hover style dictionary. Defaults to {}. + style_callback (function, optional): Styling function that is called + for each feature, and should return the feature style. This + styling function takes the feature as argument. Defaults to None. + fill_colors (list, optional): The random colors to use for filling + polygons. Defaults to ["black"]. + info_mode (str, optional): Displays the attributes by either on_hover + or on_click. Any value other than "on_hover" or "on_click" will + be treated as None. Defaults to "on_hover". + zoom_to_layer (bool, optional): Whether to zoom to the layer. Defaults to False. + **kwargs: Additional keyword arguments to pass to add_gdf. + + Raises: + ImportError: If polars or required dependencies are not installed. + ValueError: If the specified geometry column is not found. + TypeError: If the input is not a Polars DataFrame. + + Examples: + >>> import polars as pl + >>> # With Polars-ST + >>> df = pl.read_parquet("data.geoparquet") + >>> m = leafmap.Map() + >>> m.add_polars(df, geometry="geometry", crs="EPSG:4326") + """ + try: + import polars as pl + except ImportError: + raise ImportError( + "polars is required for add_polars(). " + "Install it with: pip install polars" + ) + + try: + import geopandas as gpd + from shapely import wkb + except ImportError: + raise ImportError( + "geopandas and shapely are required. " + "Install them with: pip install geopandas shapely" + ) + + # Validate input + if not isinstance(df, pl.DataFrame): + raise TypeError( + f"Expected a Polars DataFrame, got {type(df).__name__}. " + "Use add_gdf() for GeoPandas DataFrames." + ) + + # Check if geometry column exists + if geometry not in df.columns: + raise ValueError( + f"Geometry column '{geometry}' not found. " + f"Available columns: {df.columns}" + ) + + # Convert Polars DataFrame to GeoPandas + # Strategy: Convert to pandas first, then create GeoDataFrame + pdf = df.to_pandas() + + # Handle geometry column - could be WKB binary or WKT string + geom_col = pdf[geometry] + + # Try to convert geometries + try: + # Check if it's binary (WKB from Polars-ST) + if pdf[geometry].dtype == object: + # Try WKB first (most common for Polars-ST) + try: + geometries = geom_col.apply(lambda x: wkb.loads(bytes(x)) if x is not None else None) + except Exception: + # Try WKT + try: + from shapely import wkt + geometries = geom_col.apply(lambda x: wkt.loads(str(x)) if x is not None else None) + except Exception: + # Assume it's already shapely geometry + geometries = geom_col + else: + # Might be serialized as bytes + geometries = geom_col.apply(lambda x: wkb.loads(x) if x is not None else None) + except Exception as e: + raise ValueError( + f"Failed to parse geometry column '{geometry}'. " + f"Expected WKB binary or WKT string format. Error: {e}" + ) + + # Drop the original geometry column and create GeoDataFrame + pdf = pdf.drop(columns=[geometry]) + gdf = gpd.GeoDataFrame(pdf, geometry=geometries) + + # Set CRS + if crs is not None: + gdf.crs = crs + elif gdf.crs is None: + # Try to detect CRS from Polars-ST metadata + # Polars-ST stores CRS in column metadata + try: + # Check if the original Polars column has metadata + if hasattr(df[geometry], '_metadata') and 'crs' in df[geometry]._metadata: + gdf.crs = df[geometry]._metadata['crs'] + else: + # Default to EPSG:4326 + print( + f"Warning: No CRS found for geometry column '{geometry}'. " + "Defaulting to EPSG:4326. Specify crs parameter if different." + ) + gdf.crs = "EPSG:4326" + except Exception: + gdf.crs = "EPSG:4326" + + # Now use the existing add_gdf method + self.add_gdf( + gdf, + layer_name=layer_name, + style=style, + hover_style=hover_style, + style_callback=style_callback, + fill_colors=fill_colors, + info_mode=info_mode, + zoom_to_layer=zoom_to_layer, + **kwargs, + ) + def add_gdf_time_slider( self, gdf: "gpd.GeoDataFrame", From 69514725b2663be2874fbb250a3d6ec76f05e45e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:40:22 +0000 Subject: [PATCH 2/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/notebooks/polars_support.ipynb | 24 ++++++++++-------------- leafmap/leafmap.py | 20 +++++++++++++++----- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/examples/notebooks/polars_support.ipynb b/examples/notebooks/polars_support.ipynb index 95b31783a4..c41ca42cc3 100644 --- a/examples/notebooks/polars_support.ipynb +++ b/examples/notebooks/polars_support.ipynb @@ -57,7 +57,7 @@ " \"POINT(-87.6298 41.8781)\", # Chicago\n", " \"POINT(-95.3698 29.7604)\", # Houston\n", " \"POINT(-112.0740 33.4484)\", # Phoenix\n", - " ]\n", + " ],\n", "}\n", "\n", "df_polars = pl.DataFrame(data)\n", @@ -73,11 +73,11 @@ "# Visualize with leafmap\n", "m = leafmap.Map(center=[37.0902, -95.7129], zoom=4)\n", "m.add_polars(\n", - " df_polars, \n", - " geometry=\"geometry\", \n", + " df_polars,\n", + " geometry=\"geometry\",\n", " crs=\"EPSG:4326\",\n", " layer_name=\"US Cities\",\n", - " zoom_to_layer=True\n", + " zoom_to_layer=True,\n", ")\n", "m" ] @@ -105,8 +105,8 @@ "from shapely import wkb\n", "\n", "# Convert geometries to WKB bytes\n", - "gdf['geometry_wkb'] = gdf['geometry'].apply(lambda x: wkb.dumps(x))\n", - "df_polars = pl.DataFrame(gdf.drop(columns=['geometry']))\n", + "gdf[\"geometry_wkb\"] = gdf[\"geometry\"].apply(lambda x: wkb.dumps(x))\n", + "df_polars = pl.DataFrame(gdf.drop(columns=[\"geometry\"]))\n", "\n", "print(df_polars.head())" ] @@ -125,7 +125,7 @@ " crs=\"EPSG:4326\",\n", " layer_name=\"Submarine Cables\",\n", " zoom_to_layer=True,\n", - " style={\"color\": \"blue\", \"weight\": 2}\n", + " style={\"color\": \"blue\", \"weight\": 2},\n", ")\n", "m" ] @@ -157,15 +157,11 @@ "\n", "for i in range(-5, 5):\n", " for j in range(-5, 5):\n", - " geometries.append(wkb.dumps(box(i, j, i+0.8, j+0.8)))\n", + " geometries.append(wkb.dumps(box(i, j, i + 0.8, j + 0.8)))\n", " names.append(f\"Cell_{i}_{j}\")\n", " values.append(np.random.randint(0, 100))\n", "\n", - "df_grid = pl.DataFrame({\n", - " \"name\": names,\n", - " \"value\": values,\n", - " \"geometry\": geometries\n", - "})\n", + "df_grid = pl.DataFrame({\"name\": names, \"value\": values, \"geometry\": geometries})\n", "\n", "print(df_grid.head())\n", "print(f\"\\nTotal cells: {len(df_grid)}\")" @@ -189,7 +185,7 @@ " crs=\"EPSG:4326\",\n", " layer_name=\"High Value Cells\",\n", " zoom_to_layer=True,\n", - " style={\"fillColor\": \"red\", \"fillOpacity\": 0.5, \"color\": \"darkred\"}\n", + " style={\"fillColor\": \"red\", \"fillOpacity\": 0.5, \"color\": \"darkred\"},\n", ")\n", "m" ] diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index 2ff9816f45..822c741dae 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -3311,18 +3311,25 @@ def add_polars( if pdf[geometry].dtype == object: # Try WKB first (most common for Polars-ST) try: - geometries = geom_col.apply(lambda x: wkb.loads(bytes(x)) if x is not None else None) + geometries = geom_col.apply( + lambda x: wkb.loads(bytes(x)) if x is not None else None + ) except Exception: # Try WKT try: from shapely import wkt - geometries = geom_col.apply(lambda x: wkt.loads(str(x)) if x is not None else None) + + geometries = geom_col.apply( + lambda x: wkt.loads(str(x)) if x is not None else None + ) except Exception: # Assume it's already shapely geometry geometries = geom_col else: # Might be serialized as bytes - geometries = geom_col.apply(lambda x: wkb.loads(x) if x is not None else None) + geometries = geom_col.apply( + lambda x: wkb.loads(x) if x is not None else None + ) except Exception as e: raise ValueError( f"Failed to parse geometry column '{geometry}'. " @@ -3341,8 +3348,11 @@ def add_polars( # Polars-ST stores CRS in column metadata try: # Check if the original Polars column has metadata - if hasattr(df[geometry], '_metadata') and 'crs' in df[geometry]._metadata: - gdf.crs = df[geometry]._metadata['crs'] + if ( + hasattr(df[geometry], "_metadata") + and "crs" in df[geometry]._metadata + ): + gdf.crs = df[geometry]._metadata["crs"] else: # Default to EPSG:4326 print( From 76725552171efa0d85ba58c16adca86488121dd3 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Mon, 26 Jan 2026 13:43:40 -0500 Subject: [PATCH 3/9] Add polars as optional dependency and update documentation - Added 'polars' optional dependency in pyproject.toml - Updated installation.md with Polars support section - Documented pip install "leafmap[polars]" command - Listed other optional dependencies for reference - Tested with geo conda environment - all tests passed --- docs/installation.md | 36 ++++++++++++++++++++++++++++-------- pyproject.toml | 1 + 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index dc34172bff..a06ab98770 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,7 +4,6 @@ **leafmap** is available on [PyPI](https://pypi.org/project/leafmap/). To install **leafmap**, run this command in your terminal: -```bash pip install leafmap ``` @@ -13,28 +12,52 @@ pip install leafmap **leafmap** is also available on [conda-forge](https://anaconda.org/conda-forge/leafmap). If you have [Anaconda](https://www.anaconda.com/distribution/#download-section) or [Miniconda](https://docs.conda.io/en/latest/miniconda.html) installed on your computer, you can install leafmap using the following command: -```bash conda install leafmap -c conda-forge ``` The leafmap package has some optional dependencies (e.g., [geopandas](https://geopandas.org/) and [localtileserver](https://github.com/banesullivan/localtileserver)), which can be challenging to install on some computers, especially Windows. It is highly recommended that you create a fresh conda environment to install geopandas and leafmap. Follow the commands below to set up a conda env and install [geopandas](https://geopandas.org), [localtileserver](https://github.com/banesullivan/localtileserver), [keplergl](https://docs.kepler.gl/docs/keplergl-jupyter), [pydeck](https://deckgl.readthedocs.io/), and leafmap. -```bash conda install -n base mamba -c conda-forge mamba create -n geo leafmap geopandas localtileserver python -c conda-forge ``` Optionally, you can install some [Jupyter notebook extensions](https://github.com/ipython-contrib/jupyter_contrib_nbextensions), which can improve your productivity in the notebook environment. Some useful extensions include Table of Contents, Gist-it, Autopep8, Variable Inspector, etc. See this [post](https://towardsdatascience.com/jupyter-notebook-extensions-517fa69d2231) for more information. +``` + +## Optional Dependencies + +Leafmap has various optional dependencies to enable additional features. You can install them as needed: + +### Polars Support + +To enable Polars DataFrame support for modern, high-performance geospatial workflows: + ```bash -conda install jupyter_contrib_nbextensions -c conda-forge +pip install "leafmap[polars]" +``` + +This enables the `add_polars()` method for visualizing Polars DataFrames with geometry columns (e.g., from Polars-ST or polars-h3). + +### Other Optional Dependencies + +Install other optional features: + +```bash +pip install "leafmap[vector]" # Vector analysis tools +pip install "leafmap[raster]" # Raster data support +pip install "leafmap[lidar]" # LiDAR data analysis +pip install "leafmap[backends]" # All mapping backends +pip install "leafmap[maplibre]" # MapLibre GL backend +pip install "leafmap[ai]" # AI/ML features +``` + ``` ## Install from GitHub To install the development version from GitHub using [Git](https://git-scm.com/), run the following command in your terminal: -```bash pip install git+https://github.com/opengeos/leafmap ``` @@ -42,7 +65,6 @@ pip install git+https://github.com/opengeos/leafmap You can also use [docker](https://hub.docker.com/r/giswqs/leafmap/) to run leafmap: -```bash docker run -it -p 8888:8888 giswqs/leafmap:latest ``` @@ -50,13 +72,11 @@ docker run -it -p 8888:8888 giswqs/leafmap:latest If you have installed **leafmap** before and want to upgrade to the latest version, you can run the following command in your terminal: -```bash pip install -U leafmap ``` If you use conda, you can update leafmap to the latest version by running the following command in your terminal: -```bash conda update -c conda-forge leafmap ``` diff --git a/pyproject.toml b/pyproject.toml index d6df8a464c..b3f5ea9227 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ maplibre = ["anywidget", "geopandas", "fiona", "h3", "ipyvuetify", "localtileser gdal = ["gdal", "pyproj"] duckdb = ["duckdb", "duckdb-engine", "jupysql", "flask", "flask-cors", "jupyter-server-proxy"] titiler = ["titiler", "uvicorn"] +polars = ["polars", "geopandas", "shapely"] [project.scripts] From f7db421462005728e35f514202ea4cdf0aae3d5b Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Mon, 26 Jan 2026 13:47:15 -0500 Subject: [PATCH 4/9] Add add_polars() to folium and maplibre backends - Added add_polars() method to leafmap/foliumap.py - Added add_polars() method to leafmap/maplibregl.py - Both backends now support Polars DataFrame visualization - Tested with WKT and WKB geometries - All backends (ipyleaflet, folium, maplibre) working correctly The method signatures match each backend's existing add_gdf() API: - foliumap: Simpler API with layer_name, zoom_to_layer, info_mode, opacity - maplibre: Full API with layer_type, filter, paint, fit_bounds, visible, etc. All tests passed with geo conda environment. --- leafmap/foliumap.py | 135 ++++++++++++++++++++++++++++++++++++ leafmap/maplibregl.py | 158 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) diff --git a/leafmap/foliumap.py b/leafmap/foliumap.py index a482f5230a..48ac04dc9c 100644 --- a/leafmap/foliumap.py +++ b/leafmap/foliumap.py @@ -2068,6 +2068,141 @@ def add_gdf( **kwargs, ) + + def add_polars( + self, + df, + geometry: Optional[str] = "geometry", + crs: Optional[str] = None, + layer_name: Optional[str] = "Untitled", + zoom_to_layer: Optional[bool] = True, + info_mode: Optional[str] = "on_hover", + opacity: Optional[float] = 1.0, + **kwargs, + ) -> None: + """Adds a Polars DataFrame with geometry to the map. + + This method supports Polars-ST DataFrames with spatial geometry columns + and enables direct visualization of Polars-based geospatial data without + manual conversion to GeoPandas. + + Args: + df: A Polars DataFrame with a geometry column (e.g., from Polars-ST). + geometry (str, optional): The name of the geometry column. Defaults to "geometry". + crs (str, optional): The CRS of the geometry data (e.g., "EPSG:4326"). + If None, will try to detect from Polars-ST metadata or default to EPSG:4326. + layer_name (str, optional): The layer name to be used. Defaults to "Untitled". + zoom_to_layer (bool, optional): Whether to zoom to the layer. Defaults to True. + info_mode (str, optional): Displays the attributes by either on_hover or on_click. + Any value other than "on_hover" or "on_click" will be treated as None. + Defaults to "on_hover". + opacity (float, optional): The opacity of the layer. Defaults to 1.0. + **kwargs: Additional keyword arguments to pass to add_gdf. + + Raises: + ImportError: If polars or required dependencies are not installed. + ValueError: If the specified geometry column is not found. + TypeError: If the input is not a Polars DataFrame. + + Examples: + >>> import polars as pl + >>> # With Polars-ST + >>> df = pl.read_parquet("data.geoparquet") + >>> m = leafmap.Map() + >>> m.add_polars(df, geometry="geometry", crs="EPSG:4326") + """ + try: + import polars as pl + except ImportError: + raise ImportError( + "polars is required for add_polars(). " + "Install it with: pip install polars" + ) + + try: + import geopandas as gpd + from shapely import wkb + except ImportError: + raise ImportError( + "geopandas and shapely are required. " + "Install them with: pip install geopandas shapely" + ) + + # Validate input + if not isinstance(df, pl.DataFrame): + raise TypeError( + f"Expected a Polars DataFrame, got {type(df).__name__}. " + "Use add_gdf() for GeoPandas DataFrames." + ) + + # Check if geometry column exists + if geometry not in df.columns: + raise ValueError( + f"Geometry column '{geometry}' not found. " + f"Available columns: {df.columns}" + ) + + # Convert Polars DataFrame to GeoPandas + # Strategy: Convert to pandas first, then create GeoDataFrame + pdf = df.to_pandas() + + # Handle geometry column - could be WKB binary or WKT string + geom_col = pdf[geometry] + + # Try to convert geometries + try: + # Check if it's binary (WKB from Polars-ST) + if pdf[geometry].dtype == object: + # Try WKB first (most common for Polars-ST) + try: + geometries = geom_col.apply(lambda x: wkb.loads(bytes(x)) if x is not None else None) + except Exception: + # Try WKT + try: + from shapely import wkt + geometries = geom_col.apply(lambda x: wkt.loads(str(x)) if x is not None else None) + except Exception: + # Assume it's already shapely geometry + geometries = geom_col + else: + # Might be serialized as bytes + geometries = geom_col.apply(lambda x: wkb.loads(x) if x is not None else None) + except Exception as e: + raise ValueError( + f"Failed to parse geometry column '{geometry}'. " + f"Expected WKB binary or WKT string format. Error: {e}" + ) + + # Drop the original geometry column and create GeoDataFrame + pdf = pdf.drop(columns=[geometry]) + gdf = gpd.GeoDataFrame(pdf, geometry=geometries) + + # Set CRS + if crs is not None: + gdf.crs = crs + elif gdf.crs is None: + # Try to detect CRS from Polars-ST metadata + # Polars-ST stores CRS in column metadata + try: + # Check if the original Polars column has metadata + if hasattr(df[geometry], '_metadata') and 'crs' in df[geometry]._metadata: + gdf.crs = df[geometry]._metadata['crs'] + else: + # Default to EPSG:4326 + gdf.crs = "EPSG:4326" + except Exception: + gdf.crs = "EPSG:4326" + + # Now use the existing add_gdf method + self.add_gdf( + gdf, + layer_name=layer_name, + zoom_to_layer=zoom_to_layer, + info_mode=info_mode, + opacity=opacity, + **kwargs, + ) + def add_gdf_from_postgis( self, sql: str, diff --git a/leafmap/maplibregl.py b/leafmap/maplibregl.py index 8e6141a171..2a99b740d8 100644 --- a/leafmap/maplibregl.py +++ b/leafmap/maplibregl.py @@ -1809,6 +1809,164 @@ def add_tile_layer( self.set_visibility(name, visible) self.set_opacity(name, opacity) + + def add_polars( + self, + df, + geometry: Optional[str] = "geometry", + crs: Optional[str] = None, + layer_type: Optional[str] = None, + filter: Optional[Dict] = None, + paint: Optional[Dict] = None, + name: Optional[str] = None, + fit_bounds: bool = True, + visible: bool = True, + before_id: Optional[str] = None, + source_args: Dict = {}, + overwrite: bool = False, + **kwargs: Any, + ): + """ + Adds a Polars DataFrame with geometry to the map. + + This method supports Polars-ST DataFrames with spatial geometry columns + and enables direct visualization of Polars-based geospatial data without + manual conversion to GeoPandas. + + Args: + df: A Polars DataFrame with a geometry column (e.g., from Polars-ST). + geometry (str, optional): The name of the geometry column. Defaults to "geometry". + crs (str, optional): The CRS of the geometry data (e.g., "EPSG:4326"). + If None, will try to detect from Polars-ST metadata or default to EPSG:4326. + layer_type (str, optional): The type of the layer. If None, the type + is inferred from the GeoJSON data. + filter (dict, optional): The filter to apply to the layer. If None, + no filter is applied. + paint (dict, optional): The paint properties to apply to the layer. + If None, no paint properties are applied. + name (str, optional): The name of the layer. If None, a random name + is generated. + fit_bounds (bool, optional): Whether to adjust the viewport of the + map to fit the bounds of the GeoJSON data. Defaults to True. + visible (bool, optional): Whether the layer is visible or not. + Defaults to True. + before_id (str, optional): The ID of an existing layer before which + the new layer should be inserted. + source_args (dict, optional): Additional keyword arguments that are + passed to the GeoJSONSource class. + overwrite (bool, optional): Whether to overwrite an existing layer with the same name. + Defaults to False. + **kwargs: Additional keyword arguments that are passed to the Layer class. + + Raises: + ImportError: If polars or required dependencies are not installed. + ValueError: If the specified geometry column is not found. + TypeError: If the input is not a Polars DataFrame. + + Examples: + >>> import polars as pl + >>> # With Polars-ST + >>> df = pl.read_parquet("data.geoparquet") + >>> m = leafmap.Map() + >>> m.add_polars(df, geometry="geometry", crs="EPSG:4326") + """ + try: + import polars as pl + except ImportError: + raise ImportError( + "polars is required for add_polars(). " + "Install it with: pip install polars" + ) + + try: + import geopandas as gpd + from shapely import wkb + except ImportError: + raise ImportError( + "geopandas and shapely are required. " + "Install them with: pip install geopandas shapely" + ) + + # Validate input + if not isinstance(df, pl.DataFrame): + raise TypeError( + f"Expected a Polars DataFrame, got {type(df).__name__}. " + "Use add_gdf() for GeoPandas DataFrames." + ) + + # Check if geometry column exists + if geometry not in df.columns: + raise ValueError( + f"Geometry column '{geometry}' not found. " + f"Available columns: {df.columns}" + ) + + # Convert Polars DataFrame to GeoPandas + # Strategy: Convert to pandas first, then create GeoDataFrame + pdf = df.to_pandas() + + # Handle geometry column - could be WKB binary or WKT string + geom_col = pdf[geometry] + + # Try to convert geometries + try: + # Check if it's binary (WKB from Polars-ST) + if pdf[geometry].dtype == object: + # Try WKB first (most common for Polars-ST) + try: + geometries = geom_col.apply(lambda x: wkb.loads(bytes(x)) if x is not None else None) + except Exception: + # Try WKT + try: + from shapely import wkt + geometries = geom_col.apply(lambda x: wkt.loads(str(x)) if x is not None else None) + except Exception: + # Assume it's already shapely geometry + geometries = geom_col + else: + # Might be serialized as bytes + geometries = geom_col.apply(lambda x: wkb.loads(x) if x is not None else None) + except Exception as e: + raise ValueError( + f"Failed to parse geometry column '{geometry}'. " + f"Expected WKB binary or WKT string format. Error: {e}" + ) + + # Drop the original geometry column and create GeoDataFrame + pdf = pdf.drop(columns=[geometry]) + gdf = gpd.GeoDataFrame(pdf, geometry=geometries) + + # Set CRS + if crs is not None: + gdf.crs = crs + elif gdf.crs is None: + # Try to detect CRS from Polars-ST metadata + # Polars-ST stores CRS in column metadata + try: + # Check if the original Polars column has metadata + if hasattr(df[geometry], '_metadata') and 'crs' in df[geometry]._metadata: + gdf.crs = df[geometry]._metadata['crs'] + else: + # Default to EPSG:4326 + gdf.crs = "EPSG:4326" + except Exception: + gdf.crs = "EPSG:4326" + + # Now use the existing add_gdf method + self.add_gdf( + gdf, + layer_type=layer_type, + filter=filter, + paint=paint, + name=name, + fit_bounds=fit_bounds, + visible=visible, + before_id=before_id, + source_args=source_args, + overwrite=overwrite, + **kwargs, + ) + def add_vector_tile( self, url: str, From 06ac6854284d07e720bacb113820bba6dead3e82 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:47:50 +0000 Subject: [PATCH 5/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- leafmap/foliumap.py | 21 +++++++++++++++------ leafmap/maplibregl.py | 21 +++++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/leafmap/foliumap.py b/leafmap/foliumap.py index 48ac04dc9c..864a845d2e 100644 --- a/leafmap/foliumap.py +++ b/leafmap/foliumap.py @@ -2068,7 +2068,6 @@ def add_gdf( **kwargs, ) - def add_polars( self, df, @@ -2155,18 +2154,25 @@ def add_polars( if pdf[geometry].dtype == object: # Try WKB first (most common for Polars-ST) try: - geometries = geom_col.apply(lambda x: wkb.loads(bytes(x)) if x is not None else None) + geometries = geom_col.apply( + lambda x: wkb.loads(bytes(x)) if x is not None else None + ) except Exception: # Try WKT try: from shapely import wkt - geometries = geom_col.apply(lambda x: wkt.loads(str(x)) if x is not None else None) + + geometries = geom_col.apply( + lambda x: wkt.loads(str(x)) if x is not None else None + ) except Exception: # Assume it's already shapely geometry geometries = geom_col else: # Might be serialized as bytes - geometries = geom_col.apply(lambda x: wkb.loads(x) if x is not None else None) + geometries = geom_col.apply( + lambda x: wkb.loads(x) if x is not None else None + ) except Exception as e: raise ValueError( f"Failed to parse geometry column '{geometry}'. " @@ -2185,8 +2191,11 @@ def add_polars( # Polars-ST stores CRS in column metadata try: # Check if the original Polars column has metadata - if hasattr(df[geometry], '_metadata') and 'crs' in df[geometry]._metadata: - gdf.crs = df[geometry]._metadata['crs'] + if ( + hasattr(df[geometry], "_metadata") + and "crs" in df[geometry]._metadata + ): + gdf.crs = df[geometry]._metadata["crs"] else: # Default to EPSG:4326 gdf.crs = "EPSG:4326" diff --git a/leafmap/maplibregl.py b/leafmap/maplibregl.py index 2a99b740d8..7addf8c873 100644 --- a/leafmap/maplibregl.py +++ b/leafmap/maplibregl.py @@ -1809,7 +1809,6 @@ def add_tile_layer( self.set_visibility(name, visible) self.set_opacity(name, opacity) - def add_polars( self, df, @@ -1914,18 +1913,25 @@ def add_polars( if pdf[geometry].dtype == object: # Try WKB first (most common for Polars-ST) try: - geometries = geom_col.apply(lambda x: wkb.loads(bytes(x)) if x is not None else None) + geometries = geom_col.apply( + lambda x: wkb.loads(bytes(x)) if x is not None else None + ) except Exception: # Try WKT try: from shapely import wkt - geometries = geom_col.apply(lambda x: wkt.loads(str(x)) if x is not None else None) + + geometries = geom_col.apply( + lambda x: wkt.loads(str(x)) if x is not None else None + ) except Exception: # Assume it's already shapely geometry geometries = geom_col else: # Might be serialized as bytes - geometries = geom_col.apply(lambda x: wkb.loads(x) if x is not None else None) + geometries = geom_col.apply( + lambda x: wkb.loads(x) if x is not None else None + ) except Exception as e: raise ValueError( f"Failed to parse geometry column '{geometry}'. " @@ -1944,8 +1950,11 @@ def add_polars( # Polars-ST stores CRS in column metadata try: # Check if the original Polars column has metadata - if hasattr(df[geometry], '_metadata') and 'crs' in df[geometry]._metadata: - gdf.crs = df[geometry]._metadata['crs'] + if ( + hasattr(df[geometry], "_metadata") + and "crs" in df[geometry]._metadata + ): + gdf.crs = df[geometry]._metadata["crs"] else: # Default to EPSG:4326 gdf.crs = "EPSG:4326" From d52268018741db631ae5a65988bb6120fbd75c04 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Mon, 26 Jan 2026 13:49:15 -0500 Subject: [PATCH 6/9] Fix installation.md: restore missing bash code fences Accidentally removed bash code markers when adding Optional Dependencies section. All bash code blocks now properly formatted with code fences. --- docs/installation.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index a06ab98770..13244afcab 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,6 +4,7 @@ **leafmap** is available on [PyPI](https://pypi.org/project/leafmap/). To install **leafmap**, run this command in your terminal: +```bash pip install leafmap ``` @@ -12,17 +13,21 @@ pip install leafmap **leafmap** is also available on [conda-forge](https://anaconda.org/conda-forge/leafmap). If you have [Anaconda](https://www.anaconda.com/distribution/#download-section) or [Miniconda](https://docs.conda.io/en/latest/miniconda.html) installed on your computer, you can install leafmap using the following command: +```bash conda install leafmap -c conda-forge ``` The leafmap package has some optional dependencies (e.g., [geopandas](https://geopandas.org/) and [localtileserver](https://github.com/banesullivan/localtileserver)), which can be challenging to install on some computers, especially Windows. It is highly recommended that you create a fresh conda environment to install geopandas and leafmap. Follow the commands below to set up a conda env and install [geopandas](https://geopandas.org), [localtileserver](https://github.com/banesullivan/localtileserver), [keplergl](https://docs.kepler.gl/docs/keplergl-jupyter), [pydeck](https://deckgl.readthedocs.io/), and leafmap. +```bash conda install -n base mamba -c conda-forge mamba create -n geo leafmap geopandas localtileserver python -c conda-forge ``` Optionally, you can install some [Jupyter notebook extensions](https://github.com/ipython-contrib/jupyter_contrib_nbextensions), which can improve your productivity in the notebook environment. Some useful extensions include Table of Contents, Gist-it, Autopep8, Variable Inspector, etc. See this [post](https://towardsdatascience.com/jupyter-notebook-extensions-517fa69d2231) for more information. +```bash +conda install jupyter_contrib_nbextensions -c conda-forge ``` ## Optional Dependencies @@ -52,12 +57,11 @@ pip install "leafmap[maplibre]" # MapLibre GL backend pip install "leafmap[ai]" # AI/ML features ``` -``` - ## Install from GitHub To install the development version from GitHub using [Git](https://git-scm.com/), run the following command in your terminal: +```bash pip install git+https://github.com/opengeos/leafmap ``` @@ -65,6 +69,7 @@ pip install git+https://github.com/opengeos/leafmap You can also use [docker](https://hub.docker.com/r/giswqs/leafmap/) to run leafmap: +```bash docker run -it -p 8888:8888 giswqs/leafmap:latest ``` @@ -72,11 +77,13 @@ docker run -it -p 8888:8888 giswqs/leafmap:latest If you have installed **leafmap** before and want to upgrade to the latest version, you can run the following command in your terminal: +```bash pip install -U leafmap ``` If you use conda, you can update leafmap to the latest version by running the following command in your terminal: +```bash conda update -c conda-forge leafmap ``` From ef7844ffaf1f6a5bafa3bb9bd0eeb27cb71e03ea Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Mon, 26 Jan 2026 13:56:50 -0500 Subject: [PATCH 7/9] Update notebook example --- .../polars_support.ipynb => docs/notebooks/110_polars.ipynb | 6 +++--- mkdocs.yml | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) rename examples/notebooks/polars_support.ipynb => docs/notebooks/110_polars.ipynb (97%) diff --git a/examples/notebooks/polars_support.ipynb b/docs/notebooks/110_polars.ipynb similarity index 97% rename from examples/notebooks/polars_support.ipynb rename to docs/notebooks/110_polars.ipynb index c41ca42cc3..d28693b385 100644 --- a/examples/notebooks/polars_support.ipynb +++ b/docs/notebooks/110_polars.ipynb @@ -98,7 +98,7 @@ "outputs": [], "source": [ "# Start with a GeoDataFrame\n", - "url = \"https://raw.githubusercontent.com/opengeos/leafmap/master/examples/data/cable_geo.geojson\"\n", + "url = \"https://github.com/opengeos/datasets/releases/download/vector/cables.geojson\"\n", "gdf = gpd.read_file(url)\n", "\n", "# Convert to Polars with WKB geometry\n", @@ -212,7 +212,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "geo", "language": "python", "name": "python3" }, @@ -226,7 +226,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.0" + "version": "3.12.12" } }, "nbformat": 4, diff --git a/mkdocs.yml b/mkdocs.yml index 4dc1a9005b..93def699f3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -408,3 +408,4 @@ nav: - notebooks/107_copernicus.ipynb - notebooks/108_add_geotiff.ipynb - notebooks/109_local_titiler.ipynb + - notebooks/110_polars.ipynb From 66951c06b66ef590c423f72135b7824b6bf896af Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Mon, 26 Jan 2026 14:05:03 -0500 Subject: [PATCH 8/9] Address GitHub Copilot review comments for add_polars() Fixes based on 10 Copilot comments: 1. Type consistency: Changed Optional[list[str]] to Optional[List[str]] 2. Added missing 'encoding' parameter to match add_gdf() API 3. Improved exception handling: Use specific TypeError/ValueError instead of bare except 4. Replace print() with warnings.warn() for UserWarning 5. Added datetime column handling (same as add_gdf()) 6. Fixed notebook imports: Added wkb to top-level imports, reordered Example 3 7. Simplified CRS detection: Removed speculative Polars-ST metadata code 8. Improved performance: Use vectorized gpd.GeoSeries.from_wkb/from_wkt 9. Added null geometry validation before processing 10. Better error messages with specific parse failures Changes: - Use warnings.warn() with UserWarning and stacklevel=2 - Vectorized geometry parsing with gpd.GeoSeries.from_wkb() and from_wkt() - Early validation for null/empty geometry columns - Datetime handling to prevent GeoJSON serialization issues - Clear CRS warning message directing users to 'crs' parameter --- docs/notebooks/110_polars.ipynb | 10 +-- leafmap/leafmap.py | 112 ++++++++++++++++++-------------- 2 files changed, 70 insertions(+), 52 deletions(-) diff --git a/docs/notebooks/110_polars.ipynb b/docs/notebooks/110_polars.ipynb index d28693b385..8126318b2f 100644 --- a/docs/notebooks/110_polars.ipynb +++ b/docs/notebooks/110_polars.ipynb @@ -29,7 +29,8 @@ "import leafmap\n", "import polars as pl\n", "import geopandas as gpd\n", - "from shapely.geometry import Point, Polygon" + "from shapely.geometry import Point, Polygon, box\n", + "from shapely import wkb" ] }, { @@ -145,15 +146,16 @@ "metadata": {}, "outputs": [], "source": [ - "# Create sample polygon data\n", "from shapely.geometry import box\n", + "import numpy as np\n", + "\n", + "# Create sample polygon data\n", "\n", "# Create grid of rectangles\n", "geometries = []\n", "names = []\n", "values = []\n", "\n", - "import numpy as np\n", "\n", "for i in range(-5, 5):\n", " for j in range(-5, 5):\n", @@ -231,4 +233,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index 822c741dae..8b240aa36a 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -3225,9 +3225,10 @@ def add_polars( style: Optional[dict] = {}, hover_style: Optional[dict] = {}, style_callback: Optional[Callable] = None, - fill_colors: Optional[list[str]] = None, + fill_colors: Optional[List[str]] = None, info_mode: Optional[str] = "on_hover", zoom_to_layer: Optional[bool] = False, + encoding: Optional[str] = "utf-8", **kwargs, ) -> None: """Adds a Polars DataFrame with geometry to the map. @@ -3240,7 +3241,7 @@ def add_polars( df: A Polars DataFrame with a geometry column (e.g., from Polars-ST). geometry (str, optional): The name of the geometry column. Defaults to "geometry". crs (str, optional): The CRS of the geometry data (e.g., "EPSG:4326"). - If None, will try to detect from Polars-ST metadata or default to EPSG:4326. + If None, defaults to EPSG:4326. For Polars-ST DataFrames, specify the CRS explicitly. layer_name (str, optional): The layer name to be used. Defaults to "Untitled". style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. hover_style (dict, optional): Hover style dictionary. Defaults to {}. @@ -3253,11 +3254,12 @@ def add_polars( or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". zoom_to_layer (bool, optional): Whether to zoom to the layer. Defaults to False. + encoding (str, optional): The encoding of the GeoDataFrame. Defaults to "utf-8". **kwargs: Additional keyword arguments to pass to add_gdf. Raises: ImportError: If polars or required dependencies are not installed. - ValueError: If the specified geometry column is not found. + ValueError: If the specified geometry column is not found or contains invalid data. TypeError: If the input is not a Polars DataFrame. Examples: @@ -3267,6 +3269,8 @@ def add_polars( >>> m = leafmap.Map() >>> m.add_polars(df, geometry="geometry", crs="EPSG:4326") """ + import warnings + try: import polars as pl except ImportError: @@ -3277,7 +3281,7 @@ def add_polars( try: import geopandas as gpd - from shapely import wkb + from shapely import wkb, wkt except ImportError: raise ImportError( "geopandas and shapely are required. " @@ -3298,42 +3302,63 @@ def add_polars( f"Available columns: {df.columns}" ) - # Convert Polars DataFrame to GeoPandas - # Strategy: Convert to pandas first, then create GeoDataFrame + # Check for null/empty geometry column + if df[geometry].null_count() == len(df): + raise ValueError( + f"Geometry column '{geometry}' contains only null values. " + "Please provide valid geometry data." + ) + + # Convert Polars DataFrame to pandas pdf = df.to_pandas() + # Handle datetime columns (same as add_gdf) + for col in pdf.columns: + try: + if pdf[col].dtype in ["datetime64[ns]", "datetime64[ns, UTC]"]: + pdf[col] = pdf[col].astype(str) + except Exception: + pass + # Handle geometry column - could be WKB binary or WKT string geom_col = pdf[geometry] - # Try to convert geometries - try: - # Check if it's binary (WKB from Polars-ST) - if pdf[geometry].dtype == object: - # Try WKB first (most common for Polars-ST) + # Try to convert geometries using vectorized operations where possible + geometries = None + parse_error = None + + # Try WKB first (most common for Polars-ST) + if pdf[geometry].dtype == object: + # Check if first non-null value is bytes + first_valid = geom_col.dropna().iloc[0] if len(geom_col.dropna()) > 0 else None + + if first_valid is not None and isinstance(first_valid, bytes): + # Use vectorized from_wkb for better performance try: - geometries = geom_col.apply( - lambda x: wkb.loads(bytes(x)) if x is not None else None - ) - except Exception: - # Try WKT + geometries = gpd.GeoSeries.from_wkb(geom_col) + except (TypeError, ValueError) as e: + parse_error = f"WKB parsing failed: {e}" + else: + # Try WKT string parsing + try: + geometries = gpd.GeoSeries.from_wkt(geom_col) + except (TypeError, ValueError) as e: + # Last resort: assume it's already shapely geometries try: - from shapely import wkt + geometries = gpd.GeoSeries(geom_col) + except (TypeError, ValueError) as e2: + parse_error = f"WKT parsing failed: {e}, Shapely geometry: {e2}" + else: + # Might be serialized as bytes dtype + try: + geometries = gpd.GeoSeries.from_wkb(geom_col) + except (TypeError, ValueError) as e: + parse_error = f"WKB parsing failed for bytes dtype: {e}" - geometries = geom_col.apply( - lambda x: wkt.loads(str(x)) if x is not None else None - ) - except Exception: - # Assume it's already shapely geometry - geometries = geom_col - else: - # Might be serialized as bytes - geometries = geom_col.apply( - lambda x: wkb.loads(x) if x is not None else None - ) - except Exception as e: + if geometries is None or parse_error: raise ValueError( f"Failed to parse geometry column '{geometry}'. " - f"Expected WKB binary or WKT string format. Error: {e}" + f"Expected WKB binary or WKT string format. {parse_error or ''}" ) # Drop the original geometry column and create GeoDataFrame @@ -3344,24 +3369,14 @@ def add_polars( if crs is not None: gdf.crs = crs elif gdf.crs is None: - # Try to detect CRS from Polars-ST metadata - # Polars-ST stores CRS in column metadata - try: - # Check if the original Polars column has metadata - if ( - hasattr(df[geometry], "_metadata") - and "crs" in df[geometry]._metadata - ): - gdf.crs = df[geometry]._metadata["crs"] - else: - # Default to EPSG:4326 - print( - f"Warning: No CRS found for geometry column '{geometry}'. " - "Defaulting to EPSG:4326. Specify crs parameter if different." - ) - gdf.crs = "EPSG:4326" - except Exception: - gdf.crs = "EPSG:4326" + # Default to EPSG:4326 with warning + warnings.warn( + f"No CRS specified for geometry column '{geometry}'. " + "Defaulting to EPSG:4326. Use the 'crs' parameter to specify a different CRS.", + UserWarning, + stacklevel=2 + ) + gdf.crs = "EPSG:4326" # Now use the existing add_gdf method self.add_gdf( @@ -3373,6 +3388,7 @@ def add_polars( fill_colors=fill_colors, info_mode=info_mode, zoom_to_layer=zoom_to_layer, + encoding=encoding, **kwargs, ) From c2f15dc941f614e28833e76f10cb43bd135d1c1d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:06:11 +0000 Subject: [PATCH 9/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/notebooks/110_polars.ipynb | 2 +- leafmap/leafmap.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/notebooks/110_polars.ipynb b/docs/notebooks/110_polars.ipynb index 8126318b2f..85b6dcf0eb 100644 --- a/docs/notebooks/110_polars.ipynb +++ b/docs/notebooks/110_polars.ipynb @@ -233,4 +233,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index 8b240aa36a..8521116b04 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -3330,8 +3330,10 @@ def add_polars( # Try WKB first (most common for Polars-ST) if pdf[geometry].dtype == object: # Check if first non-null value is bytes - first_valid = geom_col.dropna().iloc[0] if len(geom_col.dropna()) > 0 else None - + first_valid = ( + geom_col.dropna().iloc[0] if len(geom_col.dropna()) > 0 else None + ) + if first_valid is not None and isinstance(first_valid, bytes): # Use vectorized from_wkb for better performance try: @@ -3374,7 +3376,7 @@ def add_polars( f"No CRS specified for geometry column '{geometry}'. " "Defaulting to EPSG:4326. Use the 'crs' parameter to specify a different CRS.", UserWarning, - stacklevel=2 + stacklevel=2, ) gdf.crs = "EPSG:4326"