ssb-sgis 1.0.1__py3-none-any.whl → 1.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. sgis/__init__.py +107 -121
  2. sgis/exceptions.py +5 -3
  3. sgis/geopandas_tools/__init__.py +1 -0
  4. sgis/geopandas_tools/bounds.py +86 -47
  5. sgis/geopandas_tools/buffer_dissolve_explode.py +62 -39
  6. sgis/geopandas_tools/centerlines.py +53 -44
  7. sgis/geopandas_tools/cleaning.py +87 -104
  8. sgis/geopandas_tools/conversion.py +164 -107
  9. sgis/geopandas_tools/duplicates.py +33 -19
  10. sgis/geopandas_tools/general.py +84 -52
  11. sgis/geopandas_tools/geometry_types.py +24 -10
  12. sgis/geopandas_tools/neighbors.py +23 -11
  13. sgis/geopandas_tools/overlay.py +136 -53
  14. sgis/geopandas_tools/point_operations.py +11 -10
  15. sgis/geopandas_tools/polygon_operations.py +53 -61
  16. sgis/geopandas_tools/polygons_as_rings.py +121 -78
  17. sgis/geopandas_tools/sfilter.py +17 -17
  18. sgis/helpers.py +116 -58
  19. sgis/io/dapla_functions.py +32 -23
  20. sgis/io/opener.py +13 -6
  21. sgis/io/read_parquet.py +2 -2
  22. sgis/maps/examine.py +55 -28
  23. sgis/maps/explore.py +471 -112
  24. sgis/maps/httpserver.py +12 -12
  25. sgis/maps/legend.py +285 -134
  26. sgis/maps/map.py +248 -129
  27. sgis/maps/maps.py +123 -119
  28. sgis/maps/thematicmap.py +260 -94
  29. sgis/maps/tilesources.py +3 -8
  30. sgis/networkanalysis/_get_route.py +5 -4
  31. sgis/networkanalysis/_od_cost_matrix.py +44 -1
  32. sgis/networkanalysis/_points.py +10 -4
  33. sgis/networkanalysis/_service_area.py +5 -2
  34. sgis/networkanalysis/closing_network_holes.py +22 -64
  35. sgis/networkanalysis/cutting_lines.py +58 -46
  36. sgis/networkanalysis/directednetwork.py +16 -8
  37. sgis/networkanalysis/finding_isolated_networks.py +6 -5
  38. sgis/networkanalysis/network.py +15 -13
  39. sgis/networkanalysis/networkanalysis.py +79 -61
  40. sgis/networkanalysis/networkanalysisrules.py +21 -17
  41. sgis/networkanalysis/nodes.py +2 -3
  42. sgis/networkanalysis/traveling_salesman.py +6 -3
  43. sgis/parallel/parallel.py +372 -142
  44. sgis/raster/base.py +9 -3
  45. sgis/raster/cube.py +331 -213
  46. sgis/raster/cubebase.py +15 -29
  47. sgis/raster/image_collection.py +2560 -0
  48. sgis/raster/indices.py +17 -12
  49. sgis/raster/raster.py +356 -275
  50. sgis/raster/sentinel_config.py +104 -0
  51. sgis/raster/zonal.py +38 -14
  52. {ssb_sgis-1.0.1.dist-info → ssb_sgis-1.0.3.dist-info}/LICENSE +1 -1
  53. {ssb_sgis-1.0.1.dist-info → ssb_sgis-1.0.3.dist-info}/METADATA +87 -16
  54. ssb_sgis-1.0.3.dist-info/RECORD +61 -0
  55. {ssb_sgis-1.0.1.dist-info → ssb_sgis-1.0.3.dist-info}/WHEEL +1 -1
  56. sgis/raster/bands.py +0 -48
  57. sgis/raster/gradient.py +0 -78
  58. sgis/raster/methods_as_functions.py +0 -124
  59. sgis/raster/torchgeo.py +0 -150
  60. ssb_sgis-1.0.1.dist-info/RECORD +0 -63
sgis/maps/explore.py CHANGED
@@ -5,39 +5,71 @@ clipmap functions from the 'maps' module.
5
5
  """
6
6
 
7
7
  import os
8
+ import random
9
+ import re
8
10
  import warnings
9
11
  from collections.abc import Iterable
10
12
  from numbers import Number
13
+ from pathlib import Path
11
14
  from statistics import mean
15
+ from typing import Any
16
+ from typing import ClassVar
12
17
 
13
18
  import branca as bc
14
19
  import folium
20
+ import geopandas as gpd
15
21
  import matplotlib
22
+ import matplotlib.pyplot as plt
16
23
  import numpy as np
17
24
  import pandas as pd
18
25
  import xyzservices
19
26
  from folium import plugins
20
- from geopandas import GeoDataFrame, GeoSeries
27
+ from geopandas import GeoDataFrame
28
+ from geopandas import GeoSeries
21
29
  from IPython.display import display
22
30
  from jinja2 import Template
23
31
  from pandas.api.types import is_datetime64_any_dtype
24
32
  from shapely import Geometry
33
+ from shapely import box
25
34
  from shapely.geometry import LineString
26
35
 
27
- from ..geopandas_tools.conversion import from_4326, to_gdf
28
- from ..geopandas_tools.general import clean_geoms, make_all_singlepart
29
- from ..geopandas_tools.geometry_types import get_geom_type, to_single_geom_type
36
+ from ..geopandas_tools.bounds import get_total_bounds
37
+ from ..geopandas_tools.conversion import to_bbox
38
+ from ..geopandas_tools.conversion import to_gdf
39
+ from ..geopandas_tools.conversion import to_shapely
40
+ from ..geopandas_tools.general import clean_geoms
41
+ from ..geopandas_tools.general import make_all_singlepart
42
+ from ..geopandas_tools.geometry_types import get_geom_type
43
+ from ..geopandas_tools.geometry_types import to_single_geom_type
44
+
45
+ try:
46
+ from ..raster.image_collection import Band
47
+ from ..raster.image_collection import Image
48
+ from ..raster.image_collection import ImageCollection
49
+ except ImportError:
50
+
51
+ class Band:
52
+ """Placeholder."""
53
+
54
+ class Image:
55
+ """Placeholder."""
56
+
57
+ class ImageCollection:
58
+ """Placeholder."""
59
+
60
+
30
61
  from .httpserver import run_html_server
31
62
  from .map import Map
32
- from .tilesources import kartverket, xyz
33
-
63
+ from .map import _determine_best_name
64
+ from .tilesources import kartverket
65
+ from .tilesources import xyz
34
66
 
35
67
  try:
36
68
  from torchgeo.datasets.geo import RasterDataset
37
69
  except ImportError:
38
70
 
39
71
  class RasterDataset:
40
- """Placeholder"""
72
+ """Placeholder."""
41
73
 
42
74
 
43
75
  # the geopandas._explore raises a deprication warning. Ignoring for now.
@@ -96,7 +128,10 @@ class MeasureControlFix(plugins.MeasureControl):
96
128
  """
97
129
  )
98
130
 
99
- def __init__(self, active_color="red", completed_color="red", **kwargs):
131
+ def __init__(
132
+ self, active_color: str = "red", completed_color: str = "red", **kwargs
133
+ ) -> None:
134
+ """Run super __init__ after the new _template class attribute is made."""
100
135
  super().__init__(
101
136
  active_color=active_color, completed_color=completed_color, **kwargs
102
137
  )
@@ -145,9 +180,172 @@ def to_tile(tile: str | xyzservices.TileProvider, max_zoom: int) -> folium.TileL
145
180
  return folium.TileLayer(provider, name=name, attr=attr, max_zoom=max_zoom)
146
181
 
147
182
 
183
+ def _single_band_to_arr(band, mask, name, raster_data_dict):
184
+ try:
185
+ arr = band.values
186
+ except (ValueError, AttributeError):
187
+ arr = band.load(indexes=1, bounds=mask).values
188
+ bounds: tuple = (
189
+ _any_to_bbox_crs4326(mask, band.crs)
190
+ if mask is not None
191
+ else gpd.GeoSeries(box(*band.bounds), crs=band.crs)
192
+ .to_crs(4326)
193
+ .unary_union.bounds
194
+ )
195
+ # if np.max(arr) > 0:
196
+ # arr = arr / 255
197
+ raster_data_dict["cmap"] = plt.get_cmap(band.cmap) if band.cmap else None
198
+ raster_data_dict["arr"] = arr
199
+ raster_data_dict["bounds"] = bounds
200
+ raster_data_dict["label"] = name
201
+
202
+
203
+ def _any_to_bbox_crs4326(obj, crs):
204
+ return to_bbox(to_gdf(obj, crs).to_crs(4326))
205
+
206
+
207
+ def _image_collection_to_background_map(
208
+ image_collection: ImageCollection | Image | Band,
209
+ mask: Any | None,
210
+ name: str,
211
+ max_images: int,
212
+ n_added_images: int,
213
+ rbg_bands: list[str] = (["B02", "B03", "B04"], ["B2", "B3", "B4"]),
214
+ ) -> list[dict]:
215
+ out = []
216
+
217
+ if all(isinstance(x, str) for x in rbg_bands):
218
+ rbg_bands = (rbg_bands,)
219
+
220
+ # red, blue, green = rbg_bands
221
+ if isinstance(image_collection, ImageCollection):
222
+ images = image_collection.images
223
+ name = None
224
+ elif isinstance(image_collection, Image):
225
+ img = image_collection
226
+ if mask is not None and not to_shapely(mask).intersects(to_shapely(img.bounds)):
227
+ return out
228
+
229
+ if len(img) == 1:
230
+ band = next(iter(img))
231
+ raster_data_dict = {}
232
+ out.append(raster_data_dict)
233
+ name = _determine_label(band, name, out, 0)
234
+ _single_band_to_arr(band, mask, name, raster_data_dict)
235
+ return out
236
+ elif len(img) < 3:
237
+ raster_data_dict = {}
238
+ out.append(raster_data_dict)
239
+ for i, band in enumerate(img):
240
+ name = _determine_label(band, None, out, i)
241
+ _single_band_to_arr(band, mask, name, raster_data_dict)
242
+ return out
243
+ else:
244
+ images = [image_collection]
245
+
246
+ elif isinstance(image_collection, Band):
247
+ band = image_collection
248
+
249
+ if mask is not None and not to_shapely(mask).intersects(
250
+ to_shapely(band.bounds)
251
+ ):
252
+ return out
253
+
254
+ raster_data_dict = {}
255
+ out.append(raster_data_dict)
256
+ _single_band_to_arr(band, mask, name, raster_data_dict)
257
+ return out
258
+
259
+ else:
260
+ raise TypeError(type(image_collection))
261
+
262
+ if len(images) + n_added_images > max_images:
263
+ warnings.warn(
264
+ f"Showing only a sample of {max_images}. Set 'max_images.", stacklevel=1
265
+ )
266
+ random.shuffle(images)
267
+ images = images[: (max_images - n_added_images)]
268
+ try:
269
+ images = list(sorted(images))
270
+ except Exception:
271
+ pass
272
+
273
+ i = -1
274
+ for image in images:
275
+ i += 1
276
+ if mask is not None and not to_shapely(mask).intersects(
277
+ to_shapely(image.bounds)
278
+ ):
279
+ continue
280
+
281
+ raster_data_dict = {}
282
+ out.append(raster_data_dict)
283
+
284
+ if len(image) < 3:
285
+ for band in image:
286
+ name = _determine_label(band, None, out, i)
287
+ _single_band_to_arr(band, mask, name, raster_data_dict)
288
+ i += 1
289
+ continue
290
+
291
+ for red, blue, green in rbg_bands:
292
+ try:
293
+ red_band = image[red].load(indexes=1, bounds=mask).values
294
+ except KeyError:
295
+ continue
296
+ try:
297
+ blue_band = image[blue].load(indexes=1, bounds=mask).values
298
+ except KeyError:
299
+ continue
300
+ try:
301
+ green_band = image[green].load(indexes=1, bounds=mask).values
302
+ except KeyError:
303
+ continue
304
+ break
305
+
306
+ if mask is not None:
307
+ print(mask)
308
+ print(to_gdf(mask).area.sum())
309
+ print(to_gdf(image.bounds).area.sum())
310
+ print(red_band.shape)
311
+ print(image.bounds)
312
+
313
+ if red_band.shape[0] == 0:
314
+ continue
315
+ if blue_band.shape[0] == 0:
316
+ continue
317
+ if green_band.shape[0] == 0:
318
+ continue
319
+
320
+ crs = image.crs
321
+ bounds: tuple = (
322
+ _any_to_bbox_crs4326(mask, crs)
323
+ if mask is not None
324
+ else (
325
+ gpd.GeoSeries(box(*image.bounds), crs=crs)
326
+ .to_crs(4326)
327
+ .unary_union.bounds
328
+ )
329
+ )
330
+ print(bounds)
331
+ print(to_gdf(bounds).area.sum())
332
+
333
+ # to 3d array in shape (x, y, 3)
334
+ rbg_image = np.stack([red_band, blue_band, green_band], axis=2)
335
+
336
+ raster_data_dict["arr"] = rbg_image
337
+ raster_data_dict["bounds"] = bounds
338
+ raster_data_dict["cmap"] = None
339
+ raster_data_dict["label"] = _determine_label(image, name, out, i)
340
+
341
+ return out
342
+
343
+
148
344
  class Explore(Map):
345
+ """Class for displaying and saving html maps of multiple GeoDataFrames."""
346
+
149
347
  # class attribute that can be overridden locally
150
- tiles = (
348
+ tiles: ClassVar[tuple[str]] = (
151
349
  "grunnkart",
152
350
  "norge_i_bilder",
153
351
  "dark",
@@ -161,27 +359,57 @@ class Explore(Map):
161
359
  column: str | None = None,
162
360
  popup: bool = True,
163
361
  max_zoom: int = 40,
164
- smooth_factor: float = 1.5,
362
+ smooth_factor: float = 1.1,
165
363
  browser: bool = False,
166
364
  prefer_canvas: bool = True,
167
365
  measure_control: bool = True,
168
366
  geocoder: bool = False,
169
- save=None,
367
+ out_path: str | None = None,
170
368
  show: bool | Iterable[bool] | None = None,
171
369
  text: str | None = None,
172
370
  decimals: int = 6,
371
+ max_images: int = 15,
173
372
  **kwargs,
174
- ):
373
+ ) -> None:
374
+ """Initialiser.
375
+
376
+ Args:
377
+ *gdfs: One or more GeoDataFrames.
378
+ mask: Optional mask to clip to.
379
+ column: Optional column to color the data by.
380
+ popup: Whether to make the data popups clickable.
381
+ max_zoom: Max levels of zoom.
382
+ smooth_factor: Float of 1 or higher, 1 meaning no smoothing
383
+ of lines.
384
+ browser: Whether to open the map in a browser tab.
385
+ prefer_canvas: Option.
386
+ measure_control: Whether to include measurement box.
387
+ geocoder: Whether to include search bar for addresses.
388
+ out_path: Optional file path to an html file. The map will then
389
+ be saved instead of displayed.
390
+ show: Whether to show or hide the data upon creating the map.
391
+ If False, the data can be toggled on later. 'show' can also be
392
+ a sequence of boolean values the same length as the number of
393
+ GeoDataFrames.
394
+ max_images: Maximum number of images (Image, ImageCollection, Band) to show per
395
+ map. Defaults to 15.
396
+ text: Optional text for a text box in the map.
397
+ decimals: Number of decimals in the coordinates.
398
+ **kwargs: Additional keyword arguments. Can also be geometry-like objects
399
+ where the key is the label.
400
+ """
175
401
  self.popup = popup
176
402
  self.max_zoom = max_zoom
177
403
  self.smooth_factor = smooth_factor
178
404
  self.prefer_canvas = prefer_canvas
179
405
  self.measure_control = measure_control
180
406
  self.geocoder = geocoder
181
- self.save = save
407
+ self.out_path = out_path
182
408
  self.mask = mask
183
409
  self.text = text
184
410
  self.decimals = decimals
411
+ self.max_images = max_images
412
+ self.legend = None
185
413
 
186
414
  self.browser = browser
187
415
  if not self.browser and "show_in_browser" in kwargs:
@@ -195,14 +423,30 @@ class Explore(Map):
195
423
  else:
196
424
  show_was_none = False
197
425
 
198
- self.raster_datasets = tuple(
199
- raster_dataset_to_background_map(x)
200
- for x in gdfs
201
- if isinstance(x, RasterDataset)
202
- )
203
- self.tiles # += self.raster_datasets
426
+ new_gdfs = {}
427
+ self.rasters = {}
428
+ for i, gdf in enumerate(gdfs):
429
+ name = _determine_best_name(gdf, column, i)
204
430
 
205
- super().__init__(*gdfs, column=column, show=show, **kwargs)
431
+ if name in new_gdfs or name in self.rasters:
432
+ name += str(i)
433
+
434
+ if isinstance(gdf, (ImageCollection | Image | Band)):
435
+ self.rasters[name] = gdf.copy()
436
+ continue
437
+ try:
438
+ new_gdfs[name] = to_gdf(gdf)
439
+ except Exception:
440
+ continue
441
+
442
+ new_kwargs = {}
443
+ for key, value in kwargs.items():
444
+ if isinstance(value, (ImageCollection | Image | Band)):
445
+ self.rasters[key] = value
446
+ else:
447
+ new_kwargs[key] = value
448
+
449
+ super().__init__(column=column, show=show, **new_kwargs, **new_gdfs)
206
450
 
207
451
  if self.gdfs is None:
208
452
  return
@@ -239,10 +483,13 @@ class Explore(Map):
239
483
  gdf.index = gdf.index.astype(str)
240
484
  except Exception:
241
485
  pass
242
- new_gdfs.append(gdf)
486
+ new_gdfs.append(to_gdf(gdf))
243
487
  show_new.append(show)
244
488
  self._gdfs = new_gdfs
245
- self._gdf = pd.concat(new_gdfs, ignore_index=True)
489
+ if self._gdfs:
490
+ self._gdf = pd.concat(new_gdfs, ignore_index=True)
491
+ else:
492
+ self._gdf = GeoDataFrame({"geometry": [], self._column: []})
246
493
  self.show = show_new
247
494
 
248
495
  if show_was_none and len(self._gdfs) > 6:
@@ -254,22 +501,34 @@ class Explore(Map):
254
501
  else:
255
502
  if not self._cmap:
256
503
  self._cmap = "viridis"
257
- self.cmap_start = kwargs.pop("cmap_start", 0)
258
- self.cmap_stop = kwargs.pop("cmap_stop", 256)
504
+ self.cmap_start = self.kwargs.pop("cmap_start", 0)
505
+ self.cmap_stop = self.kwargs.pop("cmap_stop", 256)
259
506
 
260
- if self._gdf.crs is None:
261
- self.kwargs["crs"] = "Simple"
507
+ # if self._gdf.crs is None:
508
+ # self.kwargs["crs"] = "Simple"
262
509
 
263
510
  self.original_crs = self.gdf.crs
264
511
 
265
- def __repr__(self):
512
+ def __repr__(self) -> str:
513
+ """Representation."""
266
514
  return f"{self.__class__.__name__}()"
267
515
 
268
516
  def explore(
269
- self, column: str | None = None, center=None, size=None, **kwargs
517
+ self,
518
+ column: str | None = None,
519
+ center: Any | None = None,
520
+ size: int | None = None,
521
+ mask: Any | None = None,
522
+ **kwargs,
270
523
  ) -> None:
271
- if not any(len(gdf) for gdf in self._gdfs) and not len(self.raster_datasets):
272
- warnings.warn("None of the GeoDataFrames have rows.")
524
+ """Explore all the data."""
525
+ self.mask = mask if mask is not None else self.mask
526
+ if (
527
+ self._gdfs
528
+ and not any(len(gdf) for gdf in self._gdfs)
529
+ and not len(self.rasters)
530
+ ):
531
+ warnings.warn("None of the GeoDataFrames have rows.", stacklevel=1)
273
532
  return
274
533
  if column:
275
534
  self._column = column
@@ -306,51 +565,44 @@ class Explore(Map):
306
565
 
307
566
  def samplemap(
308
567
  self,
309
- size: int = 1000,
568
+ size: int,
569
+ sample: Any,
310
570
  column: str | None = None,
311
- sample_from_first: bool = True,
312
571
  **kwargs,
313
572
  ) -> None:
573
+ """Explore a sample of the data."""
314
574
  if column:
315
575
  self._column = column
316
576
  self._update_column()
317
577
  kwargs.pop("column", None)
318
578
 
319
- if sample_from_first:
320
- sample = self._gdfs[0].sample(1)
321
- else:
322
- sample = self._gdf.sample(1)
323
-
324
- # convert lines to polygons
325
- if get_geom_type(sample) == "line":
326
- sample["geometry"] = sample.buffer(1)
579
+ try:
580
+ sample = sample.sample(1)
581
+ except Exception:
582
+ pass
327
583
 
328
- if get_geom_type(sample) == "polygon":
329
- random_point = sample.sample_points(size=1)
584
+ try:
585
+ sample = to_gdf(to_shapely(sample)).explode(ignore_index=True)
586
+ except Exception:
587
+ sample = to_gdf(to_shapely(to_bbox(sample))).explode(ignore_index=True)
330
588
 
331
- # if point or mixed geometries
332
- else:
333
- random_point = sample.centroid
589
+ random_point = sample.sample_points(size=1)
334
590
 
335
591
  self.center = (random_point.geometry.iloc[0].x, random_point.geometry.iloc[0].y)
336
592
  print(f"center={self.center}, size={size}")
337
593
 
338
- gdfs: tuple[GeoDataFrame] = ()
339
- for gdf in self._gdfs:
340
- gdf = gdf.clip(random_point.buffer(size))
341
- gdfs = gdfs + (gdf,)
342
- self._gdfs = gdfs
343
- self._gdf = pd.concat(gdfs, ignore_index=True)
594
+ mask = random_point.buffer(size)
344
595
 
345
- self._get_unique_values()
346
- self._explore(**kwargs)
596
+ return self.clipmap(mask, column, **kwargs)
347
597
 
348
598
  def clipmap(
349
599
  self,
350
- mask,
600
+ mask: Any,
351
601
  column: str | None = None,
352
602
  **kwargs,
353
603
  ) -> None:
604
+ """Explore the data within a mask extent."""
605
+ self.mask = mask
354
606
  if column:
355
607
  self._column = column
356
608
  self._update_column()
@@ -358,17 +610,56 @@ class Explore(Map):
358
610
 
359
611
  gdfs: tuple[GeoDataFrame] = ()
360
612
  for gdf in self._gdfs:
361
- gdf = gdf.clip(mask)
613
+ gdf = gdf.clip(self.mask)
362
614
  collections = gdf.loc[gdf.geom_type == "GeometryCollection"]
363
615
  if len(collections):
364
616
  collections = make_all_singlepart(collections)
365
617
  gdf = pd.concat([gdf, collections], ignore_index=False)
366
618
  gdfs = gdfs + (gdf,)
367
619
  self._gdfs = gdfs
368
- self._gdf = pd.concat(gdfs, ignore_index=True)
620
+ if self._gdfs:
621
+ self._gdf = pd.concat(self._gdfs, ignore_index=True)
622
+ else:
623
+ self._gdf = GeoDataFrame({"geometry": [], self._column: []})
624
+
369
625
  self._explore(**kwargs)
370
626
 
371
- def _explore(self, **kwargs):
627
+ def _load_rasters_as_images(self):
628
+ self.raster_data = []
629
+ n_added_images = 0
630
+ for name, value in self.rasters.items():
631
+ data = _image_collection_to_background_map(
632
+ value,
633
+ self.mask,
634
+ name,
635
+ max_images=self.max_images,
636
+ n_added_images=n_added_images,
637
+ )
638
+ n_added_images += len(data)
639
+ self.raster_data += data
640
+
641
+ def _rasters_to_background_maps(self):
642
+ for raster_data_dict in self.raster_data:
643
+ arr = raster_data_dict["arr"]
644
+ label = raster_data_dict["label"]
645
+ bounds = raster_data_dict["bounds"]
646
+ if raster_data_dict["cmap"] is not None:
647
+ kwargs = {"colormap": raster_data_dict["cmap"]}
648
+ else:
649
+ kwargs = {}
650
+ minx, miny, maxx, maxy = bounds
651
+ image_overlay = folium.raster_layers.ImageOverlay(
652
+ arr, bounds=[[miny, minx], [maxy, maxx]], **kwargs
653
+ )
654
+ image_overlay.layer_name = Path(label).stem
655
+ image_overlay.add_to(self.map)
656
+
657
+ def save(self, path: str) -> None:
658
+ """Save the map to local disk as an html document."""
659
+ with open(path, "w") as f:
660
+ f.write(self.map._repr_html_())
661
+
662
+ def _explore(self, **kwargs) -> None:
372
663
  self.kwargs = self.kwargs | kwargs
373
664
 
374
665
  if self._is_categorical:
@@ -376,15 +667,17 @@ class Explore(Map):
376
667
  else:
377
668
  self._create_continous_map()
378
669
 
379
- if self.save:
380
- with open(os.getcwd() + "/" + self.save.strip(".html") + ".html", "w") as f:
670
+ if self.out_path:
671
+ with open(
672
+ os.getcwd() + "/" + self.out_path.strip(".html") + ".html", "w"
673
+ ) as f:
381
674
  f.write(self.map._repr_html_())
382
675
  elif self.browser:
383
676
  run_html_server(self.map._repr_html_())
384
677
  else:
385
678
  display(self.map)
386
679
 
387
- def _split_categories(self):
680
+ def _split_categories(self) -> None:
388
681
  new_gdfs, new_labels, new_shows = [], [], []
389
682
  for cat in self._unique_values:
390
683
  gdf = self.gdf.loc[self.gdf[self.column] == cat]
@@ -396,7 +689,7 @@ class Explore(Map):
396
689
  self.labels = new_labels
397
690
  self.show = new_shows
398
691
 
399
- def _to_single_geom_type(self, gdf) -> GeoDataFrame:
692
+ def _to_single_geom_type(self, gdf: GeoDataFrame) -> GeoDataFrame:
400
693
  gdf = clean_geoms(gdf)
401
694
 
402
695
  if get_geom_type(gdf) != "mixed":
@@ -420,21 +713,43 @@ class Explore(Map):
420
713
 
421
714
  assert get_geom_type(gdf) != "mixed", gdf.geom_type.value_counts()
422
715
 
423
- warnings.warn(mess)
716
+ warnings.warn(mess, stacklevel=1)
424
717
 
425
718
  return gdf
426
719
 
427
- def _update_column(self):
720
+ def _update_column(self) -> None:
428
721
  self._is_categorical = self._check_if_categorical()
429
722
  self._fillna_if_col_is_missing()
430
723
  self._gdf = pd.concat(self._gdfs, ignore_index=True)
431
724
 
432
- def _create_categorical_map(self):
433
- self._get_categorical_colors()
725
+ def _get_bounds(
726
+ self, gdf: GeoDataFrame
727
+ ) -> tuple[float, float, float, float] | None:
728
+ if not len(gdf) or all(x is None for x in gdf.total_bounds):
729
+ try:
730
+ return get_total_bounds([x["bounds"] for x in self.raster_data])
731
+ except Exception:
732
+ return None
733
+
734
+ return gdf.total_bounds
434
735
 
435
- gdf = self._prepare_gdf_for_map(self._gdf)
736
+ def _create_categorical_map(self) -> None:
737
+ self._make_categories_colors_dict()
738
+ if self._gdf is not None and len(self._gdf):
739
+ self._fix_nans()
740
+ gdf = self._prepare_gdf_for_map(self._gdf)
741
+ else:
742
+ gdf = GeoDataFrame({"geometry": [], self._column: []})
743
+
744
+ self._load_rasters_as_images()
745
+
746
+ bounds = self._get_bounds(gdf)
747
+
748
+ if bounds is None:
749
+ self.map = None
750
+ return
436
751
  self.map = self._make_folium_map(
437
- bounds=gdf.total_bounds,
752
+ bounds=bounds,
438
753
  max_zoom=self.max_zoom,
439
754
  popup=self.popup,
440
755
  prefer_canvas=self.prefer_canvas,
@@ -445,8 +760,6 @@ class Explore(Map):
445
760
  if not len(gdf):
446
761
  continue
447
762
 
448
- f = folium.FeatureGroup(name=label)
449
-
450
763
  gdf = self._to_single_geom_type(gdf)
451
764
  gdf = self._prepare_gdf_for_map(gdf)
452
765
 
@@ -464,9 +777,10 @@ class Explore(Map):
464
777
  )
465
778
  gjs.layer_name = label
466
779
 
467
- gjs.add_to(f)
468
780
  gjs.add_to(self.map)
469
781
 
782
+ self._rasters_to_background_maps()
783
+
470
784
  _categorical_legend(
471
785
  self.map,
472
786
  self._column,
@@ -478,7 +792,7 @@ class Explore(Map):
478
792
 
479
793
  def _add_tiles(
480
794
  self, mapobj: folium.Map, tiles: list[str, xyzservices.TileProvider]
481
- ):
795
+ ) -> None:
482
796
  for tile in tiles:
483
797
  to_tile(tile, max_zoom=self.max_zoom).add_to(mapobj)
484
798
 
@@ -490,9 +804,15 @@ class Explore(Map):
490
804
  n_colors = len(np.unique(classified_sequential)) - any(self._nan_idx)
491
805
  unique_colors = self._get_continous_colors(n=n_colors)
492
806
 
807
+ self._load_rasters_as_images()
808
+
493
809
  gdf = self._prepare_gdf_for_map(self._gdf)
810
+ bounds = self._get_bounds(gdf)
811
+ if bounds is None:
812
+ self.map = None
813
+ return
494
814
  self.map = self._make_folium_map(
495
- bounds=gdf.total_bounds,
815
+ bounds=bounds,
496
816
  max_zoom=self.max_zoom,
497
817
  popup=self.popup,
498
818
  prefer_canvas=self.prefer_canvas,
@@ -510,7 +830,6 @@ class Explore(Map):
510
830
  for gdf, label, show in zip(self._gdfs, self.labels, self.show, strict=True):
511
831
  if not len(gdf):
512
832
  continue
513
- f = folium.FeatureGroup(name=label)
514
833
 
515
834
  gdf = self._to_single_geom_type(gdf)
516
835
  gdf = self._prepare_gdf_for_map(gdf)
@@ -532,12 +851,15 @@ class Explore(Map):
532
851
  **{
533
852
  key: value
534
853
  for key, value in self.kwargs.items()
535
- if key not in ["title"]
854
+ if key not in ["title", "tiles"]
536
855
  },
537
856
  )
538
857
 
539
- f.add_child(gjs)
540
- self.map.add_child(f)
858
+ gjs.layer_name = label
859
+
860
+ gjs.add_to(self.map)
861
+
862
+ self._rasters_to_background_maps()
541
863
 
542
864
  self.map.add_child(colorbar)
543
865
  self.map.add_child(folium.LayerControl())
@@ -549,7 +871,7 @@ class Explore(Map):
549
871
  return [col for col in gdf.columns if col not in COLS_TO_DROP]
550
872
 
551
873
  @staticmethod
552
- def _prepare_gdf_for_map(gdf):
874
+ def _prepare_gdf_for_map(gdf: GeoDataFrame) -> GeoDataFrame:
553
875
  if isinstance(gdf, GeoSeries):
554
876
  gdf = gdf.to_frame("geometry")
555
877
 
@@ -567,13 +889,13 @@ class Explore(Map):
567
889
 
568
890
  def _make_folium_map(
569
891
  self,
570
- bounds,
571
- attr=None,
572
- tiles=None,
573
- width="100%",
574
- height="100%",
575
- control_scale=True,
576
- map_kwds=None,
892
+ bounds: tuple[float, float, float, float],
893
+ attr: Any = None,
894
+ tiles: Any = None,
895
+ width: str = "100%",
896
+ height: str = "100%",
897
+ control_scale: bool = True,
898
+ map_kwds: dict | None = None,
577
899
  **kwargs,
578
900
  ):
579
901
  if not map_kwds:
@@ -679,21 +1001,29 @@ class Explore(Map):
679
1001
 
680
1002
  def _make_geojson(
681
1003
  self,
682
- df,
1004
+ df: GeoDataFrame,
683
1005
  show: bool,
684
- color=None,
685
- tooltip=True,
686
- popup=False,
687
- highlight=True,
688
- marker_type=None,
689
- marker_kwds={},
690
- style_kwds={},
691
- highlight_kwds={},
692
- tooltip_kwds={},
693
- popup_kwds={},
694
- map_kwds={},
1006
+ color: str | None = None,
1007
+ tooltip: bool = True,
1008
+ popup: bool = False,
1009
+ highlight: bool = True,
1010
+ marker_type: str | None = None,
1011
+ marker_kwds: dict | None = None,
1012
+ style_kwds: dict | None = None,
1013
+ highlight_kwds: dict | None = None,
1014
+ tooltip_kwds: dict | None = None,
1015
+ popup_kwds: dict | None = None,
1016
+ map_kwds: dict | None = None,
695
1017
  **kwargs,
696
- ):
1018
+ ) -> folium.GeoJson:
1019
+
1020
+ marker_kwds = marker_kwds or {}
1021
+ style_kwds = style_kwds or {}
1022
+ highlight_kwds = highlight_kwds or {}
1023
+ tooltip_kwds = tooltip_kwds or {}
1024
+ popup_kwds = popup_kwds or {}
1025
+ map_kwds = map_kwds or {}
1026
+
697
1027
  gdf = df.copy()
698
1028
 
699
1029
  # convert LinearRing to LineString
@@ -704,7 +1034,8 @@ class Explore(Map):
704
1034
  )
705
1035
 
706
1036
  if gdf.crs is None:
707
- kwargs["crs"] = "Simple"
1037
+ pass
1038
+ # kwargs["crs"] = "Simple"
708
1039
  elif not gdf.crs.equals(4326):
709
1040
  gdf = gdf.to_crs(4326)
710
1041
 
@@ -816,9 +1147,10 @@ class Explore(Map):
816
1147
  )
817
1148
 
818
1149
 
819
- def _tooltip_popup(type, fields, gdf, **kwds):
820
- """get tooltip or popup"""
821
-
1150
+ def _tooltip_popup(
1151
+ type_: str, fields: Any, gdf: GeoDataFrame, **kwargs
1152
+ ) -> folium.GeoJsonTooltip | folium.GeoJsonPopup:
1153
+ """Get tooltip or popup."""
822
1154
  # specify fields to show in the tooltip
823
1155
  if fields is False or fields is None or fields == 0:
824
1156
  return None
@@ -836,20 +1168,48 @@ def _tooltip_popup(type, fields, gdf, **kwds):
836
1168
 
837
1169
  # Cast fields to str
838
1170
  fields = list(map(str, fields))
839
- if type == "tooltip":
840
- return folium.GeoJsonTooltip(fields, **kwds)
841
- elif type == "popup":
842
- return folium.GeoJsonPopup(fields, **kwds)
1171
+ if type_ == "tooltip":
1172
+ return folium.GeoJsonTooltip(fields, **kwargs)
1173
+ elif type_ == "popup":
1174
+ return folium.GeoJsonPopup(fields, **kwargs)
1175
+
1176
+
1177
+ def _determine_label(
1178
+ obj: Image | Band | ImageCollection, obj_name: str | None, out: list[dict], i: int
1179
+ ) -> str:
1180
+ # Prefer the object's name
1181
+ if obj_name:
1182
+ # Avoid the generic label e.g. Image(1)
1183
+ does_not_have_generic_name = (
1184
+ re.sub("(\d+)", "", obj_name) != f"{obj.__class__.__name__}()"
1185
+ )
1186
+ if does_not_have_generic_name:
1187
+ return obj_name
1188
+ # try:
1189
+ # if obj.tile and obj.date:
1190
+ # name = f"{obj.tile}_{obj.date[:8]}"
1191
+ # except (ValueError, AttributeError):
1192
+ # name = None
1193
+
1194
+ try:
1195
+ # Images/Bands/Collections constructed from arrays have no path stems
1196
+ if obj.stem:
1197
+ name = obj.stem
1198
+ else:
1199
+ name = str(obj)[:23]
1200
+ except (AttributeError, ValueError):
1201
+ name = str(obj)[:23]
843
1202
 
1203
+ if name in [x["label"] for x in out if "label" in x]:
1204
+ name += f"_{i}"
844
1205
 
845
- def raster_dataset_to_background_map(dataset: RasterDataset):
846
- crs = dataset.crs
847
- bbox = dataset.bounds
1206
+ return name
848
1207
 
849
1208
 
850
- def _categorical_legend(m, title, categories, colors):
851
- """
852
- Add categorical legend to a map
1209
+ def _categorical_legend(
1210
+ m: folium.Map, title: str, categories: list[str], colors: list[str]
1211
+ ) -> None:
1212
+ """Add categorical legend to a map.
853
1213
 
854
1214
  The implementation is using the code originally written by Michel Metran
855
1215
  (@michelmetran) and released on GitHub
@@ -868,7 +1228,6 @@ def _categorical_legend(m, title, categories, colors):
868
1228
  colors : list-like
869
1229
  list of colors (in the same order as categories)
870
1230
  """
871
-
872
1231
  # Header to Add
873
1232
  head = """
874
1233
  {% macro header(this, kwargs) %}