simplestmaps 1.7.2__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.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.5
2
+ Name: simplestmaps
3
+ Version: 1.7.2
4
+ Summary: Super simple on-liner maps in python.
5
+ Project-URL: Homepage, http://github.com/fisadev/simplestmaps
6
+ Project-URL: Documentation, https://simplestmaps.fisadev.com/
7
+ Author-email: Juan Pedro Fisanotti <fisadev@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Natural Language :: English
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.8
16
+ Requires-Dist: folium>=0.15.0
17
+ Requires-Dist: xyzservices
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Simplest Maps
21
+
22
+ Super simple on-liner maps in python.
23
+
24
+ # Installation
25
+
26
+ `pip3 install simplestmaps`
27
+
28
+ # Usage
29
+
30
+ This lib aims to be super simple to use. For instance, to draw a markers and an area, you could do something like this:
31
+
32
+ ```python
33
+ from simplestmaps import draw_map, area, marker
34
+
35
+ draw_map(
36
+ marker(-31.2526, -61.4917, popup="my hometown"),
37
+ area([(-31.2741, -61.5105), (-31.2872, -61.5137), (-31.2895, -61.5003), (-31.2764, -61.4973)], color="green", popup="our airfield"),
38
+ )
39
+ ```
40
+
41
+ ![example map](./docs/readme_example1.png)
42
+
43
+ It can even do more advanced stuff, like plotting the contents of a geojson file (points, areas, lines):
44
+
45
+ ```python
46
+ from simplestmaps import draw_map, geojson
47
+
48
+ draw_map(
49
+ geojson("./docs/readme_example.geojson"),
50
+ )
51
+ ```
52
+
53
+ ![example map](./docs/readme_example2.png)
54
+
55
+ SimplestMaps can plot markers, dots, labels (text), custom html, lines, areas and geojsons, and all of that can be combined in every map.
56
+
57
+ And every element plotted into the map is also customizable (colors, fonts, sizes, popups, etc).
58
+
59
+ # More demos and docs
60
+
61
+ Here are a good set of examples that showcase (and document) all of the supported features:
62
+
63
+ ## [Take a look at everything that's possible with so little code!](https://simplestmaps.fisadev.com/)
64
+
65
+ # Development
66
+
67
+ The project uses [uv](https://docs.astral.sh/uv/). Run `uv sync` to create the virtualenv with all the dev dependencies, `make build_docs` to rebuild the docs, and `make release` to build and publish a new version to PyPI.
@@ -0,0 +1,5 @@
1
+ simplestmaps.py,sha256=VPUipdNMVS0aUtip-0onDi_MPSQOxSry175fuhPk7UU,16568
2
+ simplestmaps-1.7.2.dist-info/METADATA,sha256=AB2cxGdoyAOYuwGHK8BiGJvwNVY1TCsvlM_zNpa9Gjo,2174
3
+ simplestmaps-1.7.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ simplestmaps-1.7.2.dist-info/licenses/LICENSE,sha256=zkQJHtFEGPwAMePT5TgZg8wNz6Pyb7o7kV-8wqtwlQA,1077
5
+ simplestmaps-1.7.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Juan Pedro Fisanotti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
simplestmaps.py ADDED
@@ -0,0 +1,439 @@
1
+ from collections import namedtuple
2
+ from functools import partial, wraps
3
+ from pathlib import Path
4
+ from types import GeneratorType
5
+ import json
6
+
7
+ import folium
8
+ import xyzservices
9
+
10
+
11
+ Point = namedtuple("Point", "lat lon")
12
+
13
+ # type -> converter function that receives instance of type and returns either a single (lat,lon)
14
+ # tuple, or a sequence of (lat,lon) tuples
15
+ converters = {}
16
+
17
+
18
+ def all_numbers(sequence):
19
+ """
20
+ Is a sequence composed of just numbers?
21
+ """
22
+ return all(
23
+ isinstance(x, (int, float)) and not isinstance(x, bool)
24
+ for x in sequence
25
+ )
26
+
27
+
28
+ def to_points(source, inverted_tuples=False):
29
+ """
30
+ Convert any possible thing to lat lon points and/or sequences of points.
31
+ Optionally reverse lat,lon values (used when converting points from lists of coordinates in
32
+ geojsons).
33
+ """
34
+ while source.__class__ in converters:
35
+ # a custom type for which we have a converter registered, use it before trying to extract
36
+ # points from it
37
+ source = converters[source.__class__](source)
38
+
39
+ if isinstance(source, Point):
40
+ # don't re-convert things that are already points
41
+ return source
42
+ elif isinstance(source, GeneratorType):
43
+ # traverse the generator and build a tuple with its components, that we can inspect and
44
+ # parse (so we can do things like decide how to parse it based on its length and the types
45
+ # of its items)
46
+ return to_points(tuple(source), inverted_tuples=inverted_tuples)
47
+ elif hasattr(source, "lat") and hasattr(source, "lon"):
48
+ # a few known classes that have lat long attributes
49
+ lat, lon = source.lat, source.lon
50
+ elif hasattr(source, "latitude") and hasattr(source, "longitude"):
51
+ # a few known classes that have latitude longitude attributes, like some common geo
52
+ # libs
53
+ lat, lon = source.latitude, source.longitude
54
+ elif hasattr(source, "latitude_deg") and hasattr(source, "longitude_deg"):
55
+ # a few known classes that have latitude_deg longitude_deg attributes, like the
56
+ # locations from the orbit-predictor lib
57
+ lat, lon = source.latitude_deg, source.longitude_deg
58
+ elif source.__class__.__name__ == "Position" and hasattr(source, "position_llh"):
59
+ # a known class from the orbit-predictor lib, which has ECEF coordinates but can easily
60
+ # produce lat, lon, height coords
61
+ lat, lon, _ = source.position_llh
62
+ elif isinstance(source, (tuple, list)):
63
+ if len(source) == 1:
64
+ # a sequence with a single object inside, so extract a point from it as if we just got
65
+ # that object alone
66
+ return to_points(source[0], inverted_tuples=inverted_tuples)
67
+ elif len(source) in (2, 3) and all_numbers(source):
68
+ # a sequence of 2 or 3 numbers, these must be coordinates, finally!
69
+ if inverted_tuples:
70
+ lon, lat = source[:2]
71
+ else:
72
+ lat, lon = source[:2]
73
+ else:
74
+ # a sequence that doesn't look like coordinates but maybe a collection of points, try
75
+ # to extract all of them into a list
76
+ return [to_points(item, inverted_tuples=inverted_tuples) for item in source]
77
+ else:
78
+ raise ValueError(
79
+ f"Can't guess the latitude and longitude from this object: {repr(source)}"
80
+ )
81
+
82
+ return Point(lat, lon)
83
+
84
+
85
+ def auto_convert(custom_type, converter_function):
86
+ """
87
+ Register a custom type to be automatically converted to single coordinates or sequences of
88
+ coordinates, to be able to use them in the helper functions for map elements.
89
+ """
90
+ converters[custom_type] = converter_function
91
+
92
+
93
+ # types of things we can display on a map, with their attributes
94
+ Marker = namedtuple("Marker", "point popup")
95
+ Dot = namedtuple("Dot", "point color radius opacity border_color border_width popup")
96
+ Label = namedtuple("Label", "point text color size font opacity popup")
97
+ Html = namedtuple("Html", "point code popup")
98
+ Line = namedtuple("Line", "points_sequence color width opacity popup")
99
+ Area = namedtuple("Area", "points_sequence color opacity border_color border_width popup")
100
+ Geojson = namedtuple("Geojson", "path points_as lines_as areas_as")
101
+
102
+
103
+ def extract_single_points(points_source):
104
+ """
105
+ Extract and iterate over single points that might be part of point sequences, or even trees of
106
+ points.
107
+
108
+ This function assumes that points_source can contain only two types of things: Point instances,
109
+ or sequences (lists/tuples/generators) containing any of both.
110
+
111
+ Something like [[point_a], [point_b, point_c, point_d], [[[point_e]], point_f]]
112
+ Will yield all of the Point instances, one by one, in order.
113
+ """
114
+ pending = [points_source]
115
+ while pending:
116
+ current = pending.pop(0)
117
+
118
+ if isinstance(current, Point):
119
+ yield current
120
+ elif isinstance(current, (list, tuple, GeneratorType)):
121
+ pending.extend(current)
122
+
123
+
124
+ def extract_points_sequences(points_source):
125
+ """
126
+ Extract and iterate over point sequences that might be themselves part of higher order
127
+ sequences, or even trees of sequences.
128
+
129
+ This function assumes that points_source can contain only two types of things: Point instances,
130
+ or sequences (lists/tuples/generators) containing any of both.
131
+
132
+ The only condition is that any sequence (list/tuple/generator) contains either all Point
133
+ instances, or all sequences. No mixing of the two element types inside the same sequence.
134
+
135
+ Something like [[point_a], [point_b, point_c, point_d], [[[point_e]]]]
136
+ Will yield three sequences:
137
+ [point_a]
138
+ [point_b, point_c, point_d]
139
+ [point_e]
140
+
141
+ While something like [point_a, [point_b, point_c]]
142
+ Will yield an error, as there's a sequence (the top level one) mixing types inside it.
143
+ """
144
+ pending = [points_source]
145
+ while pending:
146
+ current = pending.pop(0)
147
+
148
+ if isinstance(current, Point):
149
+ # we received a single point, not even in a sequence, just yield it as a sequence of
150
+ # one element
151
+ yield [current]
152
+ elif isinstance(current, (list, tuple, GeneratorType)):
153
+ # we have a sequence, but is it a sequence of points? or a sequence of other sequences?
154
+ if all(isinstance(item, Point) for item in current):
155
+ # current is a sequence of coords!
156
+ yield current
157
+ elif any(isinstance(item, Point) for item in current):
158
+ raise ValueError(
159
+ f"There's a sequence mixing points and other kinds of objects: {repr(current)}"
160
+ )
161
+ else:
162
+ # a sequence of sequences, or a sequence of broken things. Add each item to the
163
+ # pending queue and deal with them when popped
164
+ pending.extend(current)
165
+ else:
166
+ raise ValueError(
167
+ f"Can't guess a sequence of coordinates from this object: {repr(current)}"
168
+ )
169
+
170
+
171
+ def marker(*points_sources, popup=None):
172
+ """
173
+ Helper to easily build a Marker instance.
174
+ """
175
+ if not points_sources:
176
+ return partial(marker, popup=popup)
177
+
178
+ return [
179
+ Marker(point, popup)
180
+ for point in extract_single_points(to_points(points_sources))
181
+ ]
182
+
183
+
184
+ def dot(*points_sources, color="blue", radius=3, opacity=1, border_color=None, border_width=0,
185
+ popup=None):
186
+ """
187
+ Helper to easily build a Dot instance.
188
+ """
189
+ if not points_sources:
190
+ return partial(dot, color=color, radius=radius, opacity=opacity, border_color=border_color,
191
+ border_width=border_width, popup=popup)
192
+
193
+ if border_color is None:
194
+ border_color = color
195
+ elif border_width == 0:
196
+ border_width = 2
197
+
198
+ return [
199
+ Dot(point, color, radius, opacity, border_color, border_width, popup)
200
+ for point in extract_single_points(to_points(points_sources))
201
+ ]
202
+
203
+
204
+ def label(*points_sources, text, color="blue", size=12, font="arial", opacity=1, popup=None):
205
+ """
206
+ Helper to easily build a Label instance.
207
+ """
208
+ if not points_sources:
209
+ return partial(label, text=text, color=color, size=size, font=font, opacity=opacity,
210
+ popup=popup)
211
+
212
+ return [
213
+ Label(point, text, color, size, font, opacity, popup)
214
+ for point in extract_single_points(to_points(points_sources))
215
+ ]
216
+
217
+
218
+ def html(*points_sources, code, popup=None):
219
+ """
220
+ Helper to easily build a Html instance.
221
+ """
222
+ if not points_sources:
223
+ return partial(html, code=code, popup=popup)
224
+
225
+ return [
226
+ Html(point, code, popup)
227
+ for point in extract_single_points(to_points(points_sources))
228
+ ]
229
+
230
+
231
+ def line(*points_sequences_sources, color="blue", width=2, opacity=1, popup=None):
232
+ """
233
+ Helper to easily build a Line instance.
234
+ """
235
+ if not points_sequences_sources:
236
+ return partial(line, color=color, width=width, opacity=opacity, popup=popup)
237
+
238
+ return [
239
+ Line(points_sequence, color, width, opacity, popup)
240
+ for points_sequence in extract_points_sequences(to_points(points_sequences_sources))
241
+ ]
242
+
243
+
244
+ def area(*points_sequences_sources, color="blue", opacity=0.5, border_color=None, border_width=0,
245
+ popup=None):
246
+ """
247
+ Helper to easily build an Area instance.
248
+ """
249
+ if not points_sequences_sources:
250
+ return partial(area, color=color, opacity=opacity, border_color=border_color,
251
+ border_width=border_width, popup=popup)
252
+
253
+ if border_color is None:
254
+ border_color = color
255
+ elif border_width == 0:
256
+ border_width = 2
257
+
258
+ return [
259
+ Area(points_sequence, color, opacity, border_color, border_width, popup)
260
+ for points_sequence in extract_points_sequences(to_points(points_sequences_sources))
261
+ ]
262
+
263
+
264
+ def geojson(path_or_data, points_as=marker, lines_as=line, areas_as=area):
265
+ """
266
+ Helper to easily read the contents of a geojson, and extract all the things it contains.
267
+ """
268
+ if isinstance(path_or_data, (str, Path)):
269
+ # a path to a geojson file, read it and extract its contents
270
+ with open(path_or_data, "r") as geojson_f:
271
+ geojson_data = json.load(geojson_f)
272
+ else:
273
+ geojson_data = path_or_data
274
+
275
+ if isinstance(geojson_data, list):
276
+ # a list of geo items, extract them individually
277
+ for item_data in geojson_data:
278
+ yield from geojson(item_data, points_as, lines_as, areas_as)
279
+
280
+ elif isinstance(geojson_data, dict):
281
+ # a single geo object, try to identify its type
282
+ geojson_type = geojson_data.get("type")
283
+
284
+ if geojson_type is None:
285
+ raise ValueError(
286
+ "The provided geojson seems to contain invalid data (no 'type' key present):\n\n"
287
+ f"{repr(geojson_data)}"
288
+ )
289
+
290
+ elif geojson_type == "FeatureCollection":
291
+ # a collection of features, extract them individually
292
+ for feature in geojson_data["features"]:
293
+ yield from geojson(feature["geometry"], points_as, lines_as, areas_as)
294
+ elif geojson_type == "Point":
295
+ yield points_as(
296
+ extract_single_points(to_points(geojson_data["coordinates"],
297
+ inverted_tuples=True))
298
+ )
299
+ elif geojson_type == "LineString":
300
+ yield lines_as(
301
+ extract_points_sequences(to_points(geojson_data["coordinates"],
302
+ inverted_tuples=True))
303
+ )
304
+ elif geojson_type in ("Polygon", "MultiPolygon"):
305
+ yield areas_as(
306
+ extract_points_sequences(to_points(geojson_data["coordinates"],
307
+ inverted_tuples=True))
308
+ )
309
+
310
+
311
+ def draw_map(*things, center=(0, 0), zoom=1.5, tiles="OpenStreetMap", tiles_key=None):
312
+ """
313
+ Draw a simple map with elements on it.
314
+
315
+ Some tile providers (like "CartoDB.Positron") require an API key, which can be specified with
316
+ tiles_key.
317
+
318
+ More info on tiles:
319
+ https://python-visualization.github.io/folium/latest/user_guide/raster_layers/tiles.html
320
+ """
321
+ center = to_points(center)
322
+
323
+ if tiles_key:
324
+ tiles = xyzservices.providers.query_name(tiles)
325
+ # each provider names its key field differently (apikey, key, accessToken, ...)
326
+ tiles = tiles(**{field: tiles_key for field, value in tiles.items()
327
+ if isinstance(value, str) and value.startswith("<insert your")})
328
+
329
+ map_ = folium.Map(location=(center.lat, center.lon), zoom_start=zoom, attr=".", tiles=tiles)
330
+
331
+ # consume all the things to display, and use inverted order so the user can easily understand
332
+ # what's o top of what
333
+ things = list(reversed(things))
334
+
335
+ while things:
336
+ thing = things.pop()
337
+
338
+ if isinstance(thing, (list, tuple, GeneratorType)):
339
+ # a collection of things, probably created using the geojson() helper. Feed those
340
+ # things into the pending list
341
+ things.extend(thing)
342
+
343
+ if isinstance(thing, Marker):
344
+ folium.Marker(
345
+ location=[thing.point.lat, thing.point.lon],
346
+ popup=thing.popup,
347
+ ).add_to(map_)
348
+
349
+ elif isinstance(thing, Dot):
350
+ folium.CircleMarker(
351
+ location=[thing.point.lat, thing.point.lon],
352
+ radius=thing.radius,
353
+ color=thing.border_color,
354
+ weight=thing.border_width,
355
+ fillColor=thing.color,
356
+ fillOpacity=thing.opacity,
357
+ popup=thing.popup,
358
+ ).add_to(map_)
359
+
360
+ elif isinstance(thing, Label):
361
+ folium.Marker(
362
+ location=[thing.point.lat, thing.point.lon],
363
+ icon=folium.DivIcon(html=f"""
364
+ <div style="font-family: {thing.font};
365
+ font-size: {thing.size}px;
366
+ color: {thing.color};
367
+ opacity: {thing.opacity}">
368
+ {thing.text}
369
+ </div>
370
+ """),
371
+ popup=thing.popup,
372
+ ).add_to(map_)
373
+
374
+ elif isinstance(thing, Html):
375
+ folium.Marker(
376
+ location=[thing.point.lat, thing.point.lon],
377
+ icon=folium.DivIcon(html=thing.code),
378
+ popup=thing.popup,
379
+ ).add_to(map_)
380
+
381
+ elif isinstance(thing, Line):
382
+ folium.PolyLine(
383
+ locations=[(point.lat, point.lon)
384
+ for point in thing.points_sequence],
385
+ color=thing.color,
386
+ weight=thing.width,
387
+ opacity=thing.opacity,
388
+ popup=thing.popup,
389
+ ).add_to(map_)
390
+
391
+ elif isinstance(thing, Area):
392
+ folium.Polygon(
393
+ locations=[(point.lat, point.lon)
394
+ for point in thing.points_sequence],
395
+ color=thing.border_color,
396
+ weight=thing.border_width,
397
+ fill_color=thing.color,
398
+ fill_opacity=thing.opacity,
399
+ popup=thing.popup,
400
+ ).add_to(map_)
401
+
402
+ return map_
403
+
404
+
405
+ try:
406
+ # add shapely support if present
407
+ from shapely.geometry import (
408
+ Point as ShpPoint,
409
+ LineString as ShpLineString,
410
+ LinearRing as ShpLinearRing,
411
+ Polygon as ShpPolygon,
412
+ MultiPoint as ShpMultiPoint,
413
+ MultiPolygon as ShpMultiPolygon,
414
+ MultiLineString as ShpMultiLineString
415
+ )
416
+
417
+ auto_convert(ShpPoint, lambda p: (p.y, p.x))
418
+ auto_convert(ShpLineString, lambda ls: [(c[1], c[0]) for c in ls.coords])
419
+ auto_convert(ShpLinearRing, lambda ls: [(c[1], c[0]) for c in ls.coords])
420
+ auto_convert(ShpPolygon, lambda p: [(c[1], c[0]) for c in p.exterior.coords])
421
+ auto_convert(ShpMultiPoint, lambda mp: list(mp.geoms))
422
+ auto_convert(ShpMultiLineString, lambda mp: list(mp.geoms))
423
+ auto_convert(ShpMultiPolygon, lambda mp: list(mp.geoms))
424
+ except ImportError:
425
+ # shapely not present, do nothing
426
+ pass
427
+
428
+
429
+ try:
430
+ # add shapely support if present
431
+ # (https://telluric.readthedocs.io/en/latest/index.html)
432
+ from telluric import GeoVector, FeatureCollection, GeoFeature
433
+
434
+ auto_convert(GeoVector, lambda gv: gv.get_shape(gv.crs))
435
+ auto_convert(GeoFeature, lambda gf: gf.geometry)
436
+ auto_convert(FeatureCollection, lambda fc: fc.geometries)
437
+ except ImportError:
438
+ # telluric not present, do nothing
439
+ pass