Fix split_map gray background when using vector layers#1300
Conversation
When using split_map with a GeoJSON layer on one side and a raster on the other, dragging the swipe divider showed a gray background behind the vector layer. This duplicates the raster TileLayer from the opposite side as a background for the vector side, so the GeoJSON is always overlaid on the raster imagery.
There was a problem hiding this comment.
Pull request overview
Fixes a rendering artifact in split_map where using a vector/GeoJSON layer on one side could reveal a gray background while dragging the swipe divider, by ensuring a raster TileLayer background is present under the GeoJSON.
Changes:
- Inject a background
ipyleaflet.TileLayerunderneathipyleaflet.GeoJSONwhen the opposite side is a raster TileLayer. - Apply the same logic to both
Map.split_map()and the module-levelsplit_map()implementation.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | ||
| right_layer, ipyleaflet.TileLayer | ||
| ): | ||
| bg = ipyleaflet.TileLayer( | ||
| url=right_layer.url, | ||
| name=right_layer.name, | ||
| attribution=right_layer.attribution, | ||
| max_zoom=right_layer.max_zoom, | ||
| ) | ||
| left_layer = [bg, left_layer] | ||
|
|
||
| if isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | ||
| left_layer, (ipyleaflet.TileLayer, list) | ||
| ): | ||
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | ||
| bg = ipyleaflet.TileLayer( | ||
| url=source.url, | ||
| name=source.name, | ||
| attribution=source.attribution, | ||
| max_zoom=source.max_zoom, | ||
| ) | ||
| right_layer = [bg, right_layer] | ||
|
|
There was a problem hiding this comment.
This GeoJSON+TileLayer background-wrapping logic is duplicated in both Map.split_map() and the module-level split_map(). To reduce drift/maintenance risk, consider factoring it into a shared helper and calling it from both places.
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | |
| right_layer, ipyleaflet.TileLayer | |
| ): | |
| bg = ipyleaflet.TileLayer( | |
| url=right_layer.url, | |
| name=right_layer.name, | |
| attribution=right_layer.attribution, | |
| max_zoom=right_layer.max_zoom, | |
| ) | |
| left_layer = [bg, left_layer] | |
| if isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | |
| left_layer, (ipyleaflet.TileLayer, list) | |
| ): | |
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | |
| bg = ipyleaflet.TileLayer( | |
| url=source.url, | |
| name=source.name, | |
| attribution=source.attribution, | |
| max_zoom=source.max_zoom, | |
| ) | |
| right_layer = [bg, right_layer] | |
| def _wrap_geojson_with_background(left_layer, right_layer): | |
| """Ensure a GeoJSON layer is displayed over an appropriate TileLayer background. | |
| When one side of the split map is a GeoJSON layer and the other side is a | |
| TileLayer (or list containing a TileLayer as its first element), this helper | |
| wraps the GeoJSON side together with a background TileLayer constructed from | |
| the TileLayer side. | |
| """ | |
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | |
| right_layer, ipyleaflet.TileLayer | |
| ): | |
| bg = ipyleaflet.TileLayer( | |
| url=right_layer.url, | |
| name=right_layer.name, | |
| attribution=right_layer.attribution, | |
| max_zoom=right_layer.max_zoom, | |
| ) | |
| left_layer = [bg, left_layer] | |
| elif isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | |
| left_layer, (ipyleaflet.TileLayer, list) | |
| ): | |
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | |
| bg = ipyleaflet.TileLayer( | |
| url=source.url, | |
| name=source.name, | |
| attribution=source.attribution, | |
| max_zoom=source.max_zoom, | |
| ) | |
| right_layer = [bg, right_layer] | |
| return left_layer, right_layer | |
| left_layer, right_layer = _wrap_geojson_with_background(left_layer, right_layer) |
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | ||
| right_layer, ipyleaflet.TileLayer | ||
| ): |
There was a problem hiding this comment.
There are existing tests that exercise split_map(), but they don't validate the new behavior for GeoJSON/vector inputs (i.e., that a raster background layer is injected under the GeoJSON side). Consider adding a unit test that creates a GeoJSON layer + TileLayer, calls split_map, and asserts the SplitMapControl receives a background TileLayer + GeoJSON (and similarly covers Map.split_map where feasible).
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | ||
| right_layer, ipyleaflet.TileLayer | ||
| ): | ||
| bg = ipyleaflet.TileLayer( | ||
| url=right_layer.url, | ||
| name=right_layer.name, | ||
| attribution=right_layer.attribution, | ||
| max_zoom=right_layer.max_zoom, | ||
| ) | ||
| left_layer = [bg, left_layer] | ||
|
|
||
| if isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | ||
| left_layer, (ipyleaflet.TileLayer, list) | ||
| ): | ||
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | ||
| bg = ipyleaflet.TileLayer( | ||
| url=source.url, | ||
| name=source.name, | ||
| attribution=source.attribution, | ||
| max_zoom=source.max_zoom, | ||
| ) |
There was a problem hiding this comment.
The background TileLayer clone only copies a few fields (url/name/attribution/max_zoom), but the source layer is commonly configured with additional traits (notably max_native_zoom is set earlier). This can make the background behave differently (e.g., tiles disappearing at higher zooms) and may not fully resolve the gray background issue. Consider cloning all relevant TileLayer properties (at least max_native_zoom/min_zoom/opacity and any args used to build the source layer), or centralize layer cloning in a helper to avoid missing fields.
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | |
| right_layer, ipyleaflet.TileLayer | |
| ): | |
| bg = ipyleaflet.TileLayer( | |
| url=right_layer.url, | |
| name=right_layer.name, | |
| attribution=right_layer.attribution, | |
| max_zoom=right_layer.max_zoom, | |
| ) | |
| left_layer = [bg, left_layer] | |
| if isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | |
| left_layer, (ipyleaflet.TileLayer, list) | |
| ): | |
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | |
| bg = ipyleaflet.TileLayer( | |
| url=source.url, | |
| name=source.name, | |
| attribution=source.attribution, | |
| max_zoom=source.max_zoom, | |
| ) | |
| def _clone_tile_layer(layer: ipyleaflet.TileLayer) -> ipyleaflet.TileLayer: | |
| """Create a background TileLayer that closely matches the source layer.""" | |
| tile_kwargs: Dict[str, Any] = dict( | |
| url=layer.url, | |
| name=layer.name, | |
| attribution=layer.attribution, | |
| max_zoom=layer.max_zoom, | |
| ) | |
| # Copy additional commonly used attributes when present to keep | |
| # background behavior (e.g., zoom limits, opacity) consistent. | |
| for attr in ("max_native_zoom", "min_zoom", "opacity"): | |
| value = getattr(layer, attr, None) | |
| if value is not None: | |
| tile_kwargs[attr] = value | |
| return ipyleaflet.TileLayer(**tile_kwargs) | |
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | |
| right_layer, ipyleaflet.TileLayer | |
| ): | |
| bg = _clone_tile_layer(right_layer) | |
| left_layer = [bg, left_layer] | |
| if isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | |
| left_layer, (ipyleaflet.TileLayer, list) | |
| ): | |
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | |
| bg = _clone_tile_layer(source) |
| name=right_layer.name, | ||
| attribution=right_layer.attribution, | ||
| max_zoom=right_layer.max_zoom, | ||
| ) |
There was a problem hiding this comment.
The background TileLayer is given the same name as the source layer. With LayersControl enabled, duplicate layer names can cause confusing UI and can potentially overwrite entries (layers are typically keyed by name). Consider giving the background layer a distinct/internal name (e.g., suffix "(background)") or otherwise excluding it from layer controls.
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | ||
| right_layer, ipyleaflet.TileLayer | ||
| ): |
There was a problem hiding this comment.
This GeoJSON+TileLayer background-wrapping logic is duplicated in both Map.split_map() and the module-level split_map(). To reduce drift/maintenance risk, consider factoring it into a shared helper and calling it from both places.
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | ||
| right_layer, ipyleaflet.TileLayer | ||
| ): | ||
| bg = ipyleaflet.TileLayer( | ||
| url=right_layer.url, | ||
| name=right_layer.name, | ||
| attribution=right_layer.attribution, | ||
| max_zoom=right_layer.max_zoom, | ||
| ) | ||
| left_layer = [bg, left_layer] | ||
|
|
||
| if isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | ||
| left_layer, (ipyleaflet.TileLayer, list) | ||
| ): | ||
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | ||
| bg = ipyleaflet.TileLayer( | ||
| url=source.url, | ||
| name=source.name, | ||
| attribution=source.attribution, | ||
| max_zoom=source.max_zoom, | ||
| ) |
There was a problem hiding this comment.
The background TileLayer clone only copies a few fields (url/name/attribution/max_zoom), but the source layer is commonly configured with additional traits (notably max_native_zoom is set earlier). This can make the background behave differently (e.g., tiles disappearing at higher zooms) and may not fully resolve the gray background issue. Consider cloning all relevant TileLayer properties (at least max_native_zoom/min_zoom/opacity and any args used to build the source layer), or centralize layer cloning in a helper to avoid missing fields.
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | |
| right_layer, ipyleaflet.TileLayer | |
| ): | |
| bg = ipyleaflet.TileLayer( | |
| url=right_layer.url, | |
| name=right_layer.name, | |
| attribution=right_layer.attribution, | |
| max_zoom=right_layer.max_zoom, | |
| ) | |
| left_layer = [bg, left_layer] | |
| if isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | |
| left_layer, (ipyleaflet.TileLayer, list) | |
| ): | |
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | |
| bg = ipyleaflet.TileLayer( | |
| url=source.url, | |
| name=source.name, | |
| attribution=source.attribution, | |
| max_zoom=source.max_zoom, | |
| ) | |
| def _clone_tilelayer_for_background( | |
| src_layer: ipyleaflet.TileLayer, | |
| ) -> ipyleaflet.TileLayer: | |
| """Clone a TileLayer for background use, preserving key display traits.""" | |
| clone_kwargs: Dict[str, Any] = { | |
| "url": src_layer.url, | |
| "name": src_layer.name, | |
| "attribution": src_layer.attribution, | |
| } | |
| # Preserve important zoom/opacity traits when present. | |
| for attr in ("min_zoom", "max_zoom", "max_native_zoom", "opacity"): | |
| value = getattr(src_layer, attr, None) | |
| if value is not None: | |
| clone_kwargs[attr] = value | |
| return ipyleaflet.TileLayer(**clone_kwargs) | |
| if isinstance(left_layer, ipyleaflet.GeoJSON) and isinstance( | |
| right_layer, ipyleaflet.TileLayer | |
| ): | |
| bg = _clone_tilelayer_for_background(right_layer) | |
| left_layer = [bg, left_layer] | |
| if isinstance(right_layer, ipyleaflet.GeoJSON) and isinstance( | |
| left_layer, (ipyleaflet.TileLayer, list) | |
| ): | |
| source = left_layer[0] if isinstance(left_layer, list) else left_layer | |
| bg = _clone_tilelayer_for_background(source) |
| url=right_layer.url, | ||
| name=right_layer.name, | ||
| attribution=right_layer.attribution, | ||
| max_zoom=right_layer.max_zoom, | ||
| ) |
There was a problem hiding this comment.
The background TileLayer is given the same name as the source layer. With LayersControl enabled, duplicate layer names can cause confusing UI and can potentially overwrite entries (layers are typically keyed by name). Consider giving the background layer a distinct/internal name (e.g., suffix "(background)") or otherwise excluding it from layer controls.
|
🚀 Deployed on https://69ae57bb911f6baf13cd350c--opengeos.netlify.app |
Summary
split_mapwith a GeoJSON/vector layer on one side and a raster (TIF) on the other, dragging the swipe divider showed a gray background behind the vector layerMap.split_map()method and the module-levelsplit_map()functionTest plan
split_mapwith a GeoJSON on the left and a raster on the right, drag the swipe divider and verify the vector layer shows the raster underneath instead of a gray backgroundsplit_mapwith two raster layers to verify no regressionsplit_mapwith a GeoDataFrame input to verify it also gets the raster background