From cc0e7bd583f0362480e41ae1c8d77f44da7289a8 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 31 Jan 2026 01:43:33 -0500 Subject: [PATCH 1/3] Add GeoJSON vector data support, STAC layer support, and comprehensive tests - Add add_geojson() method to MapLibre Map class (addresses #4) - Supports GeoJSON dict, file path, URL, and GeoDataFrame input - Auto-detects geometry types and creates appropriate layers - Auto-fits map bounds to the data extent - Supports custom paint/layout properties, clustering options - Creates multiple filtered layers for mixed-geometry FeatureCollections - Add add_stac_layer() method for STAC item support (addresses #3) - Fetches STAC item JSON from URL, extracts asset COG href - Leverages existing COG protocol for raster rendering - Auto-fits map to STAC item's bounding box - Add corresponding JavaScript handlers (addGeoJSON, addStacLayer) - JS-side URL fetching for remote GeoJSON with auto-bounds - JS-side STAC item fetching with asset extraction - Helper for computing GeoJSON bounds on the JS side - Rewrite test suite with 33 comprehensive tests covering: - MapLibre Map initialization and basic methods - GeoJSON loading from dict, file, URL with various options - Bounds computation and geometry type detection - COG and STAC layer methods - Error handling for unsupported types and missing files --- mapwidget/js/maplibre.js | 215 ++++++++++++++++++++++ mapwidget/maplibre.py | 326 ++++++++++++++++++++++++++++++++- tests/test_mapwidget.py | 385 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 916 insertions(+), 10 deletions(-) diff --git a/mapwidget/js/maplibre.js b/mapwidget/js/maplibre.js index a04c85a..1ac525e 100644 --- a/mapwidget/js/maplibre.js +++ b/mapwidget/js/maplibre.js @@ -441,6 +441,14 @@ function render({ model, el }) { // Handle addCogLayer specially const [url, sourceId, layerId, sourceOptions, layerOptions] = args; addCogLayer(map, url, sourceId, layerId, sourceOptions, layerOptions); + } else if (method === "addGeoJSON") { + // Handle addGeoJSON specially + const [data, sourceId, layerId, layerType, paint, layout, sourceOptions, geomTypes, bounds, beforeId] = args; + addGeoJSONToMap(map, data, sourceId, layerId, layerType, paint, layout, sourceOptions, geomTypes, bounds, beforeId); + } else if (method === "addStacLayer") { + // Handle addStacLayer specially + const [url, assetKey, sourceId, layerId, sourceOptions, layerOptions, fitBounds] = args; + addStacLayer(map, url, assetKey, sourceId, layerId, sourceOptions, layerOptions, fitBounds); } else if (typeof map[method] === "function") { try { map[method](...(args || [])); @@ -1141,6 +1149,213 @@ function render({ model, el }) { }); } + // Function to add GeoJSON data to the map + function addGeoJSONToMap(map, data, sourceId, layerId, layerType, paint, layout, sourceOptions, geomTypes, bounds, beforeId) { + // Helper: determine default paint for a layer type + function defaultPaint(type) { + switch (type) { + case 'fill': + return { 'fill-color': '#088', 'fill-opacity': 0.6 }; + case 'line': + return { 'line-color': '#088', 'line-width': 2 }; + case 'circle': + return { 'circle-radius': 5, 'circle-color': '#088', 'circle-opacity': 0.8 }; + case 'fill-extrusion': + return { 'fill-extrusion-color': '#088', 'fill-extrusion-height': 10, 'fill-extrusion-opacity': 0.6 }; + case 'heatmap': + return {}; + case 'symbol': + return {}; + default: + return {}; + } + } + + // Helper: map geometry type to layer type + function geomToLayerType(geomType) { + switch (geomType) { + case 'Point': + case 'MultiPoint': + return 'circle'; + case 'LineString': + case 'MultiLineString': + return 'line'; + case 'Polygon': + case 'MultiPolygon': + return 'fill'; + default: + return 'circle'; + } + } + + function doAdd(geojsonData) { + try { + // Add source + const source = { + type: 'geojson', + data: geojsonData, + ...sourceOptions, + }; + map.addSource(sourceId, source); + console.log(`Added GeoJSON source: ${sourceId}`); + + // Determine what layers to add + if (layerType) { + // Explicit layer type — single layer + const p = Object.keys(paint).length > 0 ? paint : defaultPaint(layerType); + const layer = { + id: layerId, + type: layerType, + source: sourceId, + paint: p, + }; + if (Object.keys(layout).length > 0) { + layer.layout = layout; + } + if (beforeId) { + map.addLayer(layer, beforeId); + } else { + map.addLayer(layer); + } + console.log(`Added GeoJSON layer: ${layerId} (type: ${layerType})`); + } else if (geomTypes && geomTypes.length > 0) { + // Auto-detect: create layers per geometry family + const families = new Set(); + geomTypes.forEach(gt => families.add(geomToLayerType(gt))); + + families.forEach(type => { + const lid = families.size === 1 ? layerId : `${layerId}-${type}`; + const p = Object.keys(paint).length > 0 ? paint : defaultPaint(type); + const filter = type === 'fill' + ? ['any', ['==', '$type', 'Polygon']] + : type === 'line' + ? ['any', ['==', '$type', 'LineString']] + : ['any', ['==', '$type', 'Point']]; + + const layer = { + id: lid, + type: type, + source: sourceId, + paint: p, + }; + if (families.size > 1) { + layer.filter = filter; + } + if (Object.keys(layout).length > 0) { + layer.layout = layout; + } + if (beforeId) { + map.addLayer(layer, beforeId); + } else { + map.addLayer(layer); + } + console.log(`Added GeoJSON layer: ${lid} (type: ${type})`); + }); + } else { + // Fallback: single circle layer + const type = 'circle'; + const p = Object.keys(paint).length > 0 ? paint : defaultPaint(type); + const layer = { + id: layerId, + type: type, + source: sourceId, + paint: p, + }; + if (Object.keys(layout).length > 0) { + layer.layout = layout; + } + if (beforeId) { + map.addLayer(layer, beforeId); + } else { + map.addLayer(layer); + } + console.log(`Added GeoJSON layer: ${layerId} (type: ${type})`); + } + + // Fit bounds if provided + if (bounds && bounds.length === 4) { + map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 50, maxZoom: 15 }); + } + } catch (err) { + console.error("Failed to add GeoJSON to map:", err); + } + } + + // If data is a URL string, fetch it first + if (typeof data === 'string' && (data.startsWith('http://') || data.startsWith('https://'))) { + fetch(data) + .then(response => response.json()) + .then(geojsonData => { + // Compute bounds from fetched data if not provided + if (!bounds) { + const computedBounds = computeGeoJSONBounds(geojsonData); + doAdd(geojsonData); + if (computedBounds) { + map.fitBounds([[computedBounds[0], computedBounds[1]], [computedBounds[2], computedBounds[3]]], { padding: 50, maxZoom: 15 }); + } + } else { + doAdd(geojsonData); + } + }) + .catch(err => { + console.error(`Failed to fetch GeoJSON from URL: ${data}`, err); + }); + } else { + doAdd(data); + } + } + + // Helper to compute bounds from GeoJSON data on the JS side + function computeGeoJSONBounds(geojson) { + const coords = []; + function extractCoords(obj) { + if (!obj) return; + if (obj.coordinates) flattenCoords(obj.coordinates); + if (obj.geometry) extractCoords(obj.geometry); + if (obj.features) obj.features.forEach(f => extractCoords(f)); + if (obj.geometries) obj.geometries.forEach(g => extractCoords(g)); + } + function flattenCoords(c) { + if (!c || c.length === 0) return; + if (typeof c[0] === 'number') { coords.push(c.slice(0, 2)); return; } + c.forEach(item => flattenCoords(item)); + } + extractCoords(geojson); + if (coords.length === 0) return null; + const lngs = coords.map(c => c[0]); + const lats = coords.map(c => c[1]); + return [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)]; + } + + // Function to add a STAC item's asset as a raster layer + function addStacLayer(map, url, assetKey, sourceId, layerId, sourceOptions, layerOptions, fitBounds) { + fetch(url) + .then(response => response.json()) + .then(stacItem => { + // Extract asset href + const assets = stacItem.assets || {}; + const asset = assets[assetKey]; + if (!asset || !asset.href) { + console.error(`STAC item does not contain asset '${assetKey}' or asset has no href`); + return; + } + const cogUrl = asset.href; + console.log(`Loading STAC asset '${assetKey}' from: ${cogUrl}`); + + // Add as a COG layer + addCogLayer(map, cogUrl, sourceId, layerId, sourceOptions, layerOptions); + + // Fit to STAC item's bbox if available + if (fitBounds && stacItem.bbox && stacItem.bbox.length >= 4) { + const [west, south, east, north] = stacItem.bbox; + map.fitBounds([[west, south], [east, north]], { padding: 50 }); + } + }) + .catch(err => { + console.error(`Failed to fetch STAC item from: ${url}`, err); + }); + } + // Resize after layout stabilizes setTimeout(() => map.resize(), 100); } diff --git a/mapwidget/maplibre.py b/mapwidget/maplibre.py index 9fec559..e4c8c29 100644 --- a/mapwidget/maplibre.py +++ b/mapwidget/maplibre.py @@ -1,9 +1,10 @@ import os +import json import uuid import pathlib import anywidget import traitlets -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List, Union class Map(anywidget.AnyWidget): @@ -527,3 +528,326 @@ def add_cog_layer( self.add_call( "addCogLayer", [url, source_id, layer_id, source_options, layer_options] ) + + def add_geojson( + self, + data: Union[str, Dict[str, Any]], + source_id: Optional[str] = None, + layer_id: Optional[str] = None, + layer_type: Optional[str] = None, + paint: Optional[Dict[str, Any]] = None, + layout: Optional[Dict[str, Any]] = None, + source_options: Optional[Dict[str, Any]] = None, + fit_bounds: bool = True, + before_id: Optional[str] = None, + ) -> str: + """Add GeoJSON data to the map as a source and layer. + + This method supports multiple input formats: + - A GeoJSON dictionary (FeatureCollection, Feature, or Geometry) + - A file path to a .geojson or .json file + - A URL pointing to a GeoJSON resource + - A GeoDataFrame (requires geopandas) + + The method automatically detects geometry types and creates appropriate + map layers (fill for Polygon, line for LineString, circle for Point). + For mixed-geometry FeatureCollections, multiple layers are created. + + Args: + data: GeoJSON data as a dict, file path, URL string, or GeoDataFrame. + source_id: ID for the GeoJSON source. Auto-generated if not provided. + layer_id: Base ID for the layer(s). Auto-generated if not provided. + For mixed-geometry data, suffixes like '-fill', '-line', '-circle' + are appended. + layer_type: Explicit layer type override ('fill', 'line', 'circle', + 'fill-extrusion', 'heatmap', 'symbol'). If not provided, the type + is inferred from the geometry. + paint: Paint properties for the layer. If not provided, sensible + defaults are used based on the layer type. + layout: Layout properties for the layer. + source_options: Additional options for the GeoJSON source (e.g., + 'cluster', 'clusterMaxZoom', 'clusterRadius', 'tolerance'). + fit_bounds: Whether to automatically fit the map to the data bounds. + Defaults to True. + before_id: ID of an existing layer to insert the new layer(s) before. + + Returns: + str: The source ID used for the added data. + + Example: + ```python + # Add GeoJSON from a dictionary + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-77.03, 38.90]}, + "properties": {"name": "Washington DC"} + } + ] + } + m.add_geojson(geojson) + + # Add GeoJSON from a URL + m.add_geojson( + "https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_110m_admin_0_countries.geojson", + paint={"fill-color": "#088", "fill-opacity": 0.5} + ) + + # Add GeoJSON from a file + m.add_geojson("data/parcels.geojson") + + # Add from a GeoDataFrame + import geopandas as gpd + gdf = gpd.read_file("data/roads.shp") + m.add_geojson(gdf, paint={"line-color": "red", "line-width": 2}) + + # Add with clustering enabled + m.add_geojson( + geojson, + source_options={"cluster": True, "clusterRadius": 50}, + ) + ``` + """ + # Resolve the data to a GeoJSON dict + geojson = self._resolve_geojson(data) + + if source_options is None: + source_options = {} + if paint is None: + paint = {} + if layout is None: + layout = {} + + # Generate unique IDs if not provided + if source_id is None: + source_id = f"geojson-{uuid.uuid4().hex[:8]}" + if layer_id is None: + layer_id = f"{source_id}-layer" + + # Detect geometry types present in the data + geom_types = self._get_geometry_types(geojson) + + # Compute bounds for fit_bounds + bounds = None + if fit_bounds: + bounds = self._compute_geojson_bounds(geojson) + + # Delegate to JS side for source + layer creation + self.add_call( + "addGeoJSON", + [ + geojson, + source_id, + layer_id, + layer_type, + paint, + layout, + source_options, + list(geom_types), + bounds, + before_id, + ], + ) + + return source_id + + def _resolve_geojson(self, data) -> Dict[str, Any]: + """Resolve various input types to a GeoJSON dictionary. + + Args: + data: GeoJSON dict, file path string, URL string, or GeoDataFrame. + + Returns: + dict: A valid GeoJSON dictionary. + + Raises: + TypeError: If the data type is not supported. + FileNotFoundError: If a file path doesn't exist. + """ + # Handle GeoDataFrame + if hasattr(data, "__geo_interface__"): + geojson = data.__geo_interface__ + if isinstance(geojson, str): + geojson = json.loads(geojson) + return geojson + + # Handle dict (already GeoJSON) + if isinstance(data, dict): + return data + + # Handle string: file path or URL + if isinstance(data, str): + # URL + if data.startswith(("http://", "https://")): + return data # Pass URL directly to JS for loading + + # File path + if os.path.isfile(data): + with open(data, "r") as f: + return json.load(f) + else: + raise FileNotFoundError(f"File not found: {data}") + + raise TypeError( + f"Unsupported data type: {type(data).__name__}. " + "Expected a GeoJSON dict, file path, URL, or GeoDataFrame." + ) + + @staticmethod + def _get_geometry_types(geojson) -> set: + """Extract unique geometry types from GeoJSON data. + + Args: + geojson: A GeoJSON dictionary or URL string. + + Returns: + set: A set of geometry type strings (e.g., {'Point', 'Polygon'}). + """ + if isinstance(geojson, str): + # URL — can't inspect, return empty (JS will handle) + return set() + + types = set() + + if geojson.get("type") == "FeatureCollection": + for feature in geojson.get("features", []): + geom = feature.get("geometry", {}) + if geom: + types.add(geom.get("type", "")) + elif geojson.get("type") == "Feature": + geom = geojson.get("geometry", {}) + if geom: + types.add(geom.get("type", "")) + elif geojson.get("type") in ( + "Point", + "MultiPoint", + "LineString", + "MultiLineString", + "Polygon", + "MultiPolygon", + "GeometryCollection", + ): + types.add(geojson["type"]) + + return types + + @staticmethod + def _compute_geojson_bounds(geojson) -> Optional[List[float]]: + """Compute bounding box [west, south, east, north] from GeoJSON. + + Args: + geojson: A GeoJSON dictionary. + + Returns: + list or None: [west, south, east, north] or None if bounds + cannot be computed (e.g., URL input or empty data). + """ + if isinstance(geojson, str): + return None # URL — JS will handle bounds + + coords = [] + + def _extract_coords(obj): + """Recursively extract all coordinate pairs.""" + if isinstance(obj, dict): + if "coordinates" in obj: + _flatten_coords(obj["coordinates"]) + if "geometry" in obj: + _extract_coords(obj["geometry"]) + if "features" in obj: + for f in obj["features"]: + _extract_coords(f) + if "geometries" in obj: + for g in obj["geometries"]: + _extract_coords(g) + + def _flatten_coords(c): + """Flatten nested coordinate arrays to [lng, lat] pairs.""" + if not c: + return + if isinstance(c[0], (int, float)): + coords.append(c[:2]) # [lng, lat] + else: + for item in c: + _flatten_coords(item) + + _extract_coords(geojson) + + if not coords: + return None + + lngs = [c[0] for c in coords] + lats = [c[1] for c in coords] + + return [min(lngs), min(lats), max(lngs), max(lats)] + + def add_stac_layer( + self, + url: str, + asset_key: str = "visual", + source_id: Optional[str] = None, + layer_id: Optional[str] = None, + source_options: Optional[Dict[str, Any]] = None, + layer_options: Optional[Dict[str, Any]] = None, + fit_bounds: bool = True, + ) -> None: + """Add a STAC (SpatioTemporal Asset Catalog) item's raster asset to the map. + + This method loads a STAC item from a URL, extracts the specified asset's + COG href, and adds it as a raster layer using the COG protocol. Optionally + fits the map to the item's bounding box. + + Args: + url: URL to a STAC Item JSON (e.g., + 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/...') + asset_key: The asset key to load from the STAC item. Common values + include 'visual', 'B04', 'data', 'rendered_preview'. + Defaults to 'visual'. + source_id: ID for the raster source. Auto-generated if not provided. + layer_id: ID for the raster layer. Auto-generated if not provided. + source_options: Additional options for the raster source (e.g., + tileSize, maxzoom, minzoom). + layer_options: Additional options for the raster layer (e.g., + paint properties like raster-opacity). + fit_bounds: Whether to fit the map to the STAC item's bounding box. + Defaults to True. + + Returns: + None + + Example: + ```python + # Add a STAC item's visual asset + m.add_stac_layer( + "https://planetarycomputer.microsoft.com/api/stac/v1/collections/" + "sentinel-2-l2a/items/S2A_MSIL2A_20230101T100401_R022_T33UUP_20230101T121000" + ) + + # Add a specific band with custom options + m.add_stac_layer( + url="https://example.com/stac/item.json", + asset_key="B04", + layer_options={"paint": {"raster-opacity": 0.7}}, + ) + ``` + + Note: + The STAC item must contain the specified asset key, and the asset + must have an 'href' pointing to a Cloud Optimized GeoTIFF (COG). + """ + if source_options is None: + source_options = {} + if layer_options is None: + layer_options = {} + + if source_id is None: + source_id = f"stac-source-{uuid.uuid4().hex[:8]}" + if layer_id is None: + layer_id = f"stac-layer-{uuid.uuid4().hex[:8]}" + + self.add_call( + "addStacLayer", + [url, asset_key, source_id, layer_id, source_options, layer_options, fit_bounds], + ) diff --git a/tests/test_mapwidget.py b/tests/test_mapwidget.py index 881cceb..ec7950d 100644 --- a/tests/test_mapwidget.py +++ b/tests/test_mapwidget.py @@ -3,19 +3,386 @@ """Tests for `mapwidget` package.""" +import json +import os +import tempfile import unittest -from mapwidget import leaflet +from mapwidget.maplibre import Map -class TestMapwidget(unittest.TestCase): - """Tests for `mapwidget` package.""" +class TestMapLibreMap(unittest.TestCase): + """Tests for the MapLibre Map widget.""" - def setUp(self): - """Set up test fixtures, if any.""" + def test_default_init(self): + """Test default initialization.""" + m = Map() + assert m.center == [0, 20] + assert m.zoom == 2 + assert m.bearing == 0 + assert m.pitch == 0 - def tearDown(self): - """Tear down test fixtures, if any.""" + def test_custom_init(self): + """Test initialization with custom parameters.""" + m = Map(center=[-77.03, 38.90], zoom=10, bearing=45, pitch=30) + assert m.center == [-77.03, 38.90] + assert m.zoom == 10 + assert m.bearing == 45 + assert m.pitch == 30 - def test_000_something(self): - """Test something.""" + def test_add_call(self): + """Test add_call appends to calls list.""" + m = Map() + m.add_call("setZoom", [5]) + assert len(m.calls) == 1 + assert m.calls[0]["method"] == "setZoom" + assert m.calls[0]["args"] == [5] + + def test_set_center(self): + """Test set_center adds a call.""" + m = Map() + m.set_center(-77.03, 38.90) + assert any(c["method"] == "setCenter" for c in m.calls) + + def test_set_zoom(self): + """Test set_zoom adds a call.""" + m = Map() + m.set_zoom(10) + assert any(c["method"] == "setZoom" for c in m.calls) + + def test_fly_to(self): + """Test fly_to adds a call.""" + m = Map() + m.fly_to(center=[-77.03, 38.90], zoom=10) + call = [c for c in m.calls if c["method"] == "flyTo"] + assert len(call) == 1 + assert call[0]["args"][0]["center"] == [-77.03, 38.90] + assert call[0]["args"][0]["zoom"] == 10 + + def test_fit_bounds(self): + """Test fit_bounds adds a call.""" + m = Map() + m.fit_bounds([[-80, 35], [-75, 40]]) + assert any(c["method"] == "fitBounds" for c in m.calls) + + def test_add_source(self): + """Test add_source adds a call.""" + m = Map() + m.add_source("test-source", {"type": "geojson", "data": {}}) + call = [c for c in m.calls if c["method"] == "addSource"] + assert len(call) == 1 + assert call[0]["args"][0] == "test-source" + + def test_add_layer(self): + """Test add_layer adds a call.""" + m = Map() + m.add_layer({"id": "test-layer", "type": "fill", "source": "test-source"}) + call = [c for c in m.calls if c["method"] == "addLayer"] + assert len(call) == 1 + + def test_remove_layer(self): + """Test remove_layer adds a call.""" + m = Map() + m.remove_layer("test-layer") + assert any(c["method"] == "removeLayer" for c in m.calls) + + def test_add_control(self): + """Test add_control appends to controls list.""" + m = Map() + m.add_control("scale", "bottom-left") + assert any(c["type"] == "scale" for c in m.controls) + + def test_set_paint_property(self): + """Test set_paint_property adds a call.""" + m = Map() + m.set_paint_property("layer-id", "fill-color", "#ff0000") + call = [c for c in m.calls if c["method"] == "setPaintProperty"] + assert len(call) == 1 + assert call[0]["args"] == ["layer-id", "fill-color", "#ff0000"] + + +class TestAddGeoJSON(unittest.TestCase): + """Tests for the add_geojson method.""" + + def test_add_geojson_dict_point(self): + """Test adding a GeoJSON point dict.""" + m = Map() + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-77.03, 38.90]}, + "properties": {"name": "DC"}, + } + ], + } + source_id = m.add_geojson(geojson) + assert source_id.startswith("geojson-") + call = [c for c in m.calls if c["method"] == "addGeoJSON"] + assert len(call) == 1 + + def test_add_geojson_dict_polygon(self): + """Test adding a GeoJSON polygon dict.""" + m = Map() + geojson = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [[[-77, 38], [-76, 38], [-76, 39], [-77, 39], [-77, 38]]], + }, + "properties": {}, + } + source_id = m.add_geojson(geojson, layer_type="fill") + assert source_id.startswith("geojson-") + + def test_add_geojson_custom_ids(self): + """Test adding GeoJSON with custom source and layer IDs.""" + m = Map() + geojson = { + "type": "FeatureCollection", + "features": [], + } + source_id = m.add_geojson( + geojson, source_id="my-source", layer_id="my-layer" + ) + assert source_id == "my-source" + call = [c for c in m.calls if c["method"] == "addGeoJSON"] + assert call[0]["args"][1] == "my-source" + assert call[0]["args"][2] == "my-layer" + + def test_add_geojson_file(self): + """Test adding GeoJSON from a file.""" + m = Map() + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {}, + } + ], + } + with tempfile.NamedTemporaryFile( + mode="w", suffix=".geojson", delete=False + ) as f: + json.dump(geojson, f) + tmp_path = f.name + + try: + source_id = m.add_geojson(tmp_path) + assert source_id.startswith("geojson-") + call = [c for c in m.calls if c["method"] == "addGeoJSON"] + assert len(call) == 1 + # The data should be the loaded dict, not the file path + assert call[0]["args"][0]["type"] == "FeatureCollection" + finally: + os.unlink(tmp_path) + + def test_add_geojson_url(self): + """Test adding GeoJSON from a URL passes URL string through.""" + m = Map() + url = "https://example.com/data.geojson" + source_id = m.add_geojson(url) + call = [c for c in m.calls if c["method"] == "addGeoJSON"] + assert call[0]["args"][0] == url + + def test_add_geojson_bad_type(self): + """Test that unsupported types raise TypeError.""" + m = Map() + with self.assertRaises(TypeError): + m.add_geojson(12345) + + def test_add_geojson_missing_file(self): + """Test that a missing file raises FileNotFoundError.""" + m = Map() + with self.assertRaises(FileNotFoundError): + m.add_geojson("/nonexistent/path/data.geojson") + + def test_add_geojson_no_fit_bounds(self): + """Test adding GeoJSON without fitting bounds.""" + m = Map() + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {}, + } + ], + } + m.add_geojson(geojson, fit_bounds=False) + call = [c for c in m.calls if c["method"] == "addGeoJSON"] + # bounds arg should be None + assert call[0]["args"][8] is None + + def test_add_geojson_custom_paint(self): + """Test adding GeoJSON with custom paint properties.""" + m = Map() + geojson = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + "properties": {}, + } + paint = {"fill-color": "#ff0000", "fill-opacity": 0.3} + m.add_geojson(geojson, paint=paint) + call = [c for c in m.calls if c["method"] == "addGeoJSON"] + assert call[0]["args"][4] == paint + + +class TestComputeBounds(unittest.TestCase): + """Tests for _compute_geojson_bounds.""" + + def test_point_bounds(self): + """Test bounds computation for a point.""" + geojson = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-77.03, 38.90]}, + "properties": {}, + } + bounds = Map._compute_geojson_bounds(geojson) + assert bounds == [-77.03, 38.90, -77.03, 38.90] + + def test_polygon_bounds(self): + """Test bounds computation for a polygon.""" + geojson = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], + }, + "properties": {}, + } + bounds = Map._compute_geojson_bounds(geojson) + assert bounds == [0, 0, 10, 10] + + def test_feature_collection_bounds(self): + """Test bounds computation for a FeatureCollection.""" + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-10, -5]}, + "properties": {}, + }, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [10, 5]}, + "properties": {}, + }, + ], + } + bounds = Map._compute_geojson_bounds(geojson) + assert bounds == [-10, -5, 10, 5] + + def test_url_returns_none(self): + """Test that a URL string returns None.""" + bounds = Map._compute_geojson_bounds("https://example.com/data.geojson") + assert bounds is None + + def test_empty_features(self): + """Test bounds for empty FeatureCollection.""" + geojson = {"type": "FeatureCollection", "features": []} + bounds = Map._compute_geojson_bounds(geojson) + assert bounds is None + + +class TestGetGeometryTypes(unittest.TestCase): + """Tests for _get_geometry_types.""" + + def test_point_type(self): + """Test detecting Point geometry.""" + geojson = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {}, + } + types = Map._get_geometry_types(geojson) + assert types == {"Point"} + + def test_mixed_types(self): + """Test detecting mixed geometry types.""" + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {}, + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]], + }, + "properties": {}, + }, + ], + } + types = Map._get_geometry_types(geojson) + assert types == {"Point", "Polygon"} + + def test_url_returns_empty(self): + """Test that a URL returns empty set.""" + types = Map._get_geometry_types("https://example.com/data.geojson") + assert types == set() + + +class TestAddCogLayer(unittest.TestCase): + """Tests for the add_cog_layer method.""" + + def test_add_cog_layer_default_ids(self): + """Test adding COG layer with auto-generated IDs.""" + m = Map() + m.add_cog_layer("https://example.com/data.tif") + call = [c for c in m.calls if c["method"] == "addCogLayer"] + assert len(call) == 1 + assert call[0]["args"][0] == "https://example.com/data.tif" + + def test_add_cog_layer_custom_ids(self): + """Test adding COG layer with custom IDs.""" + m = Map() + m.add_cog_layer( + "https://example.com/data.tif", + source_id="my-cog", + layer_id="my-cog-layer", + ) + call = [c for c in m.calls if c["method"] == "addCogLayer"] + assert call[0]["args"][1] == "my-cog" + assert call[0]["args"][2] == "my-cog-layer" + + +class TestAddStacLayer(unittest.TestCase): + """Tests for the add_stac_layer method.""" + + def test_add_stac_layer(self): + """Test adding STAC layer generates correct call.""" + m = Map() + m.add_stac_layer("https://example.com/stac/item.json", asset_key="visual") + call = [c for c in m.calls if c["method"] == "addStacLayer"] + assert len(call) == 1 + assert call[0]["args"][0] == "https://example.com/stac/item.json" + assert call[0]["args"][1] == "visual" + + def test_add_stac_layer_custom_ids(self): + """Test adding STAC layer with custom IDs.""" + m = Map() + m.add_stac_layer( + "https://example.com/stac/item.json", + source_id="my-stac", + layer_id="my-stac-layer", + ) + call = [c for c in m.calls if c["method"] == "addStacLayer"] + assert call[0]["args"][2] == "my-stac" + assert call[0]["args"][3] == "my-stac-layer" + + +if __name__ == "__main__": + unittest.main() From 71729fb51213ad76904144500d74be630a293deb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 06:44:08 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mapwidget/maplibre.py | 10 +++++++++- tests/test_mapwidget.py | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/mapwidget/maplibre.py b/mapwidget/maplibre.py index e4c8c29..ba23179 100644 --- a/mapwidget/maplibre.py +++ b/mapwidget/maplibre.py @@ -849,5 +849,13 @@ def add_stac_layer( self.add_call( "addStacLayer", - [url, asset_key, source_id, layer_id, source_options, layer_options, fit_bounds], + [ + url, + asset_key, + source_id, + layer_id, + source_options, + layer_options, + fit_bounds, + ], ) diff --git a/tests/test_mapwidget.py b/tests/test_mapwidget.py index ec7950d..4a44629 100644 --- a/tests/test_mapwidget.py +++ b/tests/test_mapwidget.py @@ -129,7 +129,9 @@ def test_add_geojson_dict_polygon(self): "type": "Feature", "geometry": { "type": "Polygon", - "coordinates": [[[-77, 38], [-76, 38], [-76, 39], [-77, 39], [-77, 38]]], + "coordinates": [ + [[-77, 38], [-76, 38], [-76, 39], [-77, 39], [-77, 38]] + ], }, "properties": {}, } @@ -143,9 +145,7 @@ def test_add_geojson_custom_ids(self): "type": "FeatureCollection", "features": [], } - source_id = m.add_geojson( - geojson, source_id="my-source", layer_id="my-layer" - ) + source_id = m.add_geojson(geojson, source_id="my-source", layer_id="my-layer") assert source_id == "my-source" call = [c for c in m.calls if c["method"] == "addGeoJSON"] assert call[0]["args"][1] == "my-source" From e43d6b08dfa96c3f9a6b765f96777636c309b286 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:01:29 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_mapwidget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_mapwidget.py b/tests/test_mapwidget.py index 4a44629..554589c 100644 --- a/tests/test_mapwidget.py +++ b/tests/test_mapwidget.py @@ -2,7 +2,6 @@ """Tests for `mapwidget` package.""" - import json import os import tempfile