pyworldatlas-mapview 0.9.1__tar.gz

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,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyworldatlas-mapview
3
+ Version: 0.9.1
4
+ Summary: Offline interactive 3D map viewer for PyWorldAtlas
5
+ Author: jcari-dev
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://jcari-dev.github.io/pyworldatlas-documentation/maps.html
8
+ Project-URL: Source, https://github.com/jcari-dev/pyworldatlas
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Education
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Scientific/Engineering :: GIS
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: plotly<7,>=6.1
17
+
18
+ # PyWorldAtlas map viewer
19
+
20
+ This companion package provides the optional offline browser renderer used by
21
+ PyWorldAtlas map extras. Install it through `pyworldatlas[maps]` or
22
+ `pyworldatlas[maps-overview]` rather than installing it directly.
@@ -0,0 +1,5 @@
1
+ # PyWorldAtlas map viewer
2
+
3
+ This companion package provides the optional offline browser renderer used by
4
+ PyWorldAtlas map extras. Install it through `pyworldatlas[maps]` or
5
+ `pyworldatlas[maps-overview]` rather than installing it directly.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyworldatlas-mapview"
7
+ version = "0.9.1"
8
+ description = "Offline interactive 3D map viewer for PyWorldAtlas"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{name = "jcari-dev"}]
13
+ dependencies = ["plotly>=6.1,<7"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Education",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Scientific/Engineering :: GIS",
20
+ ]
21
+
22
+ [project.urls]
23
+ Documentation = "https://jcari-dev.github.io/pyworldatlas-documentation/maps.html"
24
+ Source = "https://github.com/jcari-dev/pyworldatlas"
25
+
26
+ [tool.setuptools]
27
+ package-dir = {"" = "src"}
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Optional offline 3D map viewer for PyWorldAtlas."""
2
+
3
+ from .viewer import CountryMap, MapDataError, available_map_qualities
4
+
5
+ __all__ = ["CountryMap", "MapDataError", "available_map_qualities"]
@@ -0,0 +1,455 @@
1
+ """Build interactive country maps from an installed PyWorldAtlas map pack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from array import array
6
+ from dataclasses import dataclass
7
+ from hashlib import sha256
8
+ import importlib.util
9
+ from importlib import resources
10
+ import json
11
+ from pathlib import Path
12
+ import sqlite3
13
+ import sys
14
+ import tempfile
15
+ from typing import Any
16
+ import webbrowser
17
+ import zlib
18
+
19
+
20
+ _PACKAGES = {
21
+ "overview": "pyworldatlas_mapdata_overview",
22
+ "standard": "pyworldatlas_mapdata_standard",
23
+ }
24
+
25
+ _ELEVATION_SCALE = (
26
+ (0.00, "#16796f"),
27
+ (0.12, "#55a868"),
28
+ (0.34, "#a6bd72"),
29
+ (0.55, "#d2b06b"),
30
+ (0.76, "#9a7556"),
31
+ (0.90, "#74675d"),
32
+ (1.00, "#f1f3f4"),
33
+ )
34
+
35
+ _CLIMATE_COLORS = {
36
+ 1: "#166534", 2: "#228b22", 3: "#65a30d",
37
+ 4: "#dc2626", 5: "#f97316", 6: "#eab308", 7: "#facc15",
38
+ 8: "#7c3aed", 9: "#8b5cf6", 10: "#a78bfa",
39
+ 11: "#0891b2", 12: "#06b6d4", 13: "#67e8f9",
40
+ 14: "#2563eb", 15: "#60a5fa", 16: "#93c5fd",
41
+ 17: "#9333ea", 18: "#a855f7", 19: "#c084fc", 20: "#d8b4fe",
42
+ 21: "#4f46e5", 22: "#6366f1", 23: "#818cf8", 24: "#a5b4fc",
43
+ 25: "#1d4ed8", 26: "#3b82f6", 27: "#60a5fa", 28: "#93c5fd",
44
+ 29: "#94a3b8", 30: "#e2e8f0",
45
+ }
46
+
47
+
48
+ class MapDataError(RuntimeError):
49
+ """Raised when map data is missing, incompatible, or damaged."""
50
+
51
+
52
+ def available_map_qualities() -> tuple[str, ...]:
53
+ """Return installed map qualities from least to most detailed."""
54
+ return tuple(quality for quality, package in _PACKAGES.items() if importlib.util.find_spec(package))
55
+
56
+
57
+ def _select_quality(requested: str) -> str:
58
+ if requested not in {"auto", *_PACKAGES}:
59
+ raise ValueError("quality must be 'auto', 'overview', or 'standard'")
60
+ available = available_map_qualities()
61
+ if requested == "auto":
62
+ if "standard" in available:
63
+ return "standard"
64
+ if "overview" in available:
65
+ return "overview"
66
+ elif requested in available:
67
+ return requested
68
+ command = (
69
+ 'pip install "pyworldatlas[maps]"'
70
+ if requested in {"auto", "standard"}
71
+ else 'pip install "pyworldatlas[maps-overview]"'
72
+ )
73
+ raise MapDataError(f"The {requested} map data is not installed. Run: {command}")
74
+
75
+
76
+ def _load_payload(alpha2: str, quality: str) -> dict[str, Any]:
77
+ package = _PACKAGES[quality]
78
+ database = resources.files(package).joinpath("data/maps.sqlite3")
79
+ with resources.as_file(database) as path:
80
+ connection = sqlite3.connect(path)
81
+ try:
82
+ metadata = dict(connection.execute("SELECT key, value FROM metadata"))
83
+ row = connection.execute(
84
+ "SELECT payload, uncompressed_bytes, sha256 FROM country_map WHERE alpha2 = ?",
85
+ (alpha2,),
86
+ ).fetchone()
87
+ finally:
88
+ connection.close()
89
+ if metadata.get("format_version") != "1" or metadata.get("quality") != quality:
90
+ raise MapDataError(f"The installed {quality} map pack is incompatible")
91
+ if row is None:
92
+ raise MapDataError(f"The installed {quality} map pack has no profile for {alpha2}")
93
+ try:
94
+ raw = zlib.decompress(row[0])
95
+ if len(raw) != row[1] or sha256(raw).hexdigest() != row[2]:
96
+ raise MapDataError(f"The installed map record for {alpha2} failed its integrity check")
97
+ return json.loads(raw)
98
+ except (OSError, ValueError, json.JSONDecodeError) as error:
99
+ raise MapDataError(f"The installed map record for {alpha2} could not be read") from error
100
+
101
+
102
+ def _decode_elevation(encoded: str, rows: int, columns: int) -> list[list[int | None]]:
103
+ values = array("h")
104
+ values.frombytes(bytes.fromhex(encoded))
105
+ if sys.byteorder != "little":
106
+ values.byteswap()
107
+ expected = rows * columns
108
+ if len(values) != expected:
109
+ raise MapDataError("The elevation grid has an invalid size")
110
+ return [
111
+ [None if value == -32768 else int(value) for value in values[offset : offset + columns]]
112
+ for offset in range(0, expected, columns)
113
+ ]
114
+
115
+
116
+ def _decode_climate(
117
+ encoded: str,
118
+ rows: int,
119
+ columns: int,
120
+ legend: dict[str, dict[str, str]],
121
+ ) -> tuple[list[list[int | None]], list[list[str | None]]]:
122
+ values = bytes.fromhex(encoded)
123
+ expected = rows * columns
124
+ if len(values) != expected:
125
+ raise MapDataError("The climate grid has an invalid size")
126
+ numeric: list[list[int | None]] = []
127
+ labels: list[list[str | None]] = []
128
+ for offset in range(0, expected, columns):
129
+ numeric_row: list[int | None] = []
130
+ label_row: list[str | None] = []
131
+ for value in values[offset : offset + columns]:
132
+ details = legend.get(str(value))
133
+ numeric_row.append(value or None)
134
+ label_row.append(f"{details['code']} · {details['name']}" if details else None)
135
+ numeric.append(numeric_row)
136
+ labels.append(label_row)
137
+ return numeric, labels
138
+
139
+
140
+ def _axis(start: float, step: float, count: int) -> list[float]:
141
+ return [round(start + step * index, 8) for index in range(count)]
142
+
143
+
144
+ def _nearest_elevation(
145
+ longitude: float,
146
+ latitude: float,
147
+ longitudes: list[float],
148
+ latitudes: list[float],
149
+ elevation: list[list[int | None]],
150
+ ) -> int:
151
+ while longitude < longitudes[0]:
152
+ longitude += 360.0
153
+ while longitude > longitudes[-1]:
154
+ longitude -= 360.0
155
+ x = min(len(longitudes) - 1, max(0, round((longitude - longitudes[0]) / (longitudes[1] - longitudes[0]))))
156
+ y = min(len(latitudes) - 1, max(0, round((latitude - latitudes[0]) / (latitudes[1] - latitudes[0]))))
157
+ for radius in range(5):
158
+ for row in range(max(0, y - radius), min(len(latitudes), y + radius + 1)):
159
+ for column in range(max(0, x - radius), min(len(longitudes), x + radius + 1)):
160
+ value = elevation[row][column]
161
+ if value is not None:
162
+ return value
163
+ return 0
164
+
165
+
166
+ def _flatten_lines(
167
+ parts: list[list[list[float]]],
168
+ longitudes: list[float],
169
+ latitudes: list[float],
170
+ elevation: list[list[int | None]],
171
+ offset: int,
172
+ ) -> tuple[list[float | None], list[float | None], list[int | None]]:
173
+ x: list[float | None] = []
174
+ y: list[float | None] = []
175
+ z: list[int | None] = []
176
+ for part in parts:
177
+ for longitude, latitude in part:
178
+ x.append(longitude)
179
+ y.append(latitude)
180
+ z.append(_nearest_elevation(longitude, latitude, longitudes, latitudes, elevation) + offset)
181
+ x.append(None)
182
+ y.append(None)
183
+ z.append(None)
184
+ return x, y, z
185
+
186
+
187
+ def _climate_scale() -> list[list[float | str]]:
188
+ scale: list[list[float | str]] = []
189
+ for index in range(1, 31):
190
+ color = _CLIMATE_COLORS[index]
191
+ scale.extend([[(index - 1) / 30, color], [index / 30, color]])
192
+ return scale
193
+
194
+
195
+ @dataclass(frozen=True, slots=True)
196
+ class CountryMap:
197
+ """A lazily loaded interactive 3D country map.
198
+
199
+ Use :meth:`show` to open a standalone offline browser view. Use
200
+ :meth:`figure` when direct Plotly customization or notebook display is
201
+ preferred.
202
+ """
203
+
204
+ alpha2: str
205
+ country_name: str
206
+ flag: str | None = None
207
+ capital_name: str | None = None
208
+ capital_latitude: float | None = None
209
+ capital_longitude: float | None = None
210
+ requested_quality: str = "auto"
211
+
212
+ @classmethod
213
+ def from_country(cls, country: object, *, quality: str = "auto") -> "CountryMap":
214
+ """Create a map request from a PyWorldAtlas country profile."""
215
+ capital = getattr(country, "capital", None)
216
+ coordinates = getattr(capital, "coordinates", None) if capital else None
217
+ return cls(
218
+ alpha2=str(getattr(country, "alpha2")),
219
+ country_name=str(getattr(country, "name")),
220
+ flag=getattr(country, "flag", None),
221
+ capital_name=getattr(capital, "name", None) if capital else None,
222
+ capital_latitude=getattr(coordinates, "latitude", None) if coordinates else None,
223
+ capital_longitude=getattr(coordinates, "longitude", None) if coordinates else None,
224
+ requested_quality=quality,
225
+ )
226
+
227
+ @property
228
+ def quality(self) -> str:
229
+ """Return the installed map quality this request will use."""
230
+ return _select_quality(self.requested_quality)
231
+
232
+ @property
233
+ def resolution_arc_minutes(self) -> int:
234
+ """Return the nominal elevation sampling interval in arc-minutes."""
235
+ return int(_load_payload(self.alpha2, self.quality)["resolutionArcMinutes"])
236
+
237
+ def figure(self) -> object:
238
+ """Return a ready-to-display ``plotly.graph_objects.Figure``.
239
+
240
+ The figure contains elevation and climate surface controls, a country
241
+ outline, source-provided river centerlines, and the primary capital
242
+ when coordinates are available.
243
+ """
244
+ import plotly.graph_objects as go
245
+
246
+ payload = _load_payload(self.alpha2, self.quality)
247
+ rows, columns = int(payload["rows"]), int(payload["columns"])
248
+ longitudes = _axis(float(payload["west"]), float(payload["longitudeStep"]), columns)
249
+ latitudes = _axis(float(payload["south"]), float(payload["latitudeStep"]), rows)
250
+ elevation = _decode_elevation(payload["elevation"], rows, columns)
251
+ climate, climate_labels = _decode_climate(
252
+ payload["climate"], rows, columns, payload["climateLegend"]
253
+ )
254
+ minimum = int(payload["minimumElevationM"] or 0)
255
+ maximum = int(payload["maximumElevationM"] or 1)
256
+ surface = go.Surface(
257
+ x=longitudes,
258
+ y=latitudes,
259
+ z=elevation,
260
+ surfacecolor=elevation,
261
+ customdata=climate_labels,
262
+ colorscale=list(_ELEVATION_SCALE),
263
+ cmin=minimum,
264
+ cmax=maximum,
265
+ colorbar={"title": {"text": "Elevation (m)"}, "thickness": 14, "len": 0.68},
266
+ lighting={"ambient": 0.58, "diffuse": 0.88, "roughness": 0.82, "specular": 0.18},
267
+ lightposition={"x": -80, "y": -20, "z": 9000},
268
+ hovertemplate=(
269
+ "Longitude %{x:.2f}°<br>Latitude %{y:.2f}°<br>"
270
+ "Elevation %{z:,.0f} m<br>Climate %{customdata}<extra></extra>"
271
+ ),
272
+ name="Terrain",
273
+ showscale=True,
274
+ )
275
+ boundary_x, boundary_y, boundary_z = _flatten_lines(
276
+ payload["boundary"], longitudes, latitudes, elevation, 40
277
+ )
278
+ traces: list[object] = [
279
+ surface,
280
+ go.Scatter3d(
281
+ x=boundary_x,
282
+ y=boundary_y,
283
+ z=boundary_z,
284
+ mode="lines",
285
+ line={"color": "#17202a", "width": 4},
286
+ hoverinfo="skip",
287
+ name="Country outline",
288
+ showlegend=False,
289
+ ),
290
+ ]
291
+ river_parts = [river["points"] for river in payload["rivers"]]
292
+ if river_parts:
293
+ river_x, river_y, river_z = _flatten_lines(
294
+ river_parts, longitudes, latitudes, elevation, 55
295
+ )
296
+ river_text: list[str | None] = []
297
+ for river in payload["rivers"]:
298
+ river_text.extend([river["name"]] * len(river["points"]))
299
+ river_text.append(None)
300
+ traces.append(
301
+ go.Scatter3d(
302
+ x=river_x,
303
+ y=river_y,
304
+ z=river_z,
305
+ text=river_text,
306
+ mode="lines",
307
+ line={"color": "#168aad", "width": 5},
308
+ hovertemplate="%{text}<extra>River</extra>",
309
+ name="Rivers",
310
+ showlegend=True,
311
+ )
312
+ )
313
+ if self.capital_latitude is not None and self.capital_longitude is not None:
314
+ capital_longitude = self.capital_longitude
315
+ while capital_longitude < longitudes[0]:
316
+ capital_longitude += 360.0
317
+ while capital_longitude > longitudes[-1]:
318
+ capital_longitude -= 360.0
319
+ traces.append(
320
+ go.Scatter3d(
321
+ x=[capital_longitude],
322
+ y=[self.capital_latitude],
323
+ z=[
324
+ _nearest_elevation(
325
+ capital_longitude,
326
+ self.capital_latitude,
327
+ longitudes,
328
+ latitudes,
329
+ elevation,
330
+ )
331
+ + 120
332
+ ],
333
+ text=[self.capital_name],
334
+ mode="markers+text",
335
+ textposition="top center",
336
+ marker={"color": "#d1495b", "size": 5, "symbol": "diamond"},
337
+ hovertemplate=f"{self.capital_name}<extra>Capital</extra>",
338
+ name="Capital",
339
+ showlegend=False,
340
+ )
341
+ )
342
+ present = sorted(int(index) for index in payload["climateLegend"])
343
+ figure = go.Figure(data=traces)
344
+ figure.update_layout(
345
+ title={
346
+ "text": f"{self.country_name} · {self.quality.title()} 3D map",
347
+ "x": 0.03,
348
+ "xanchor": "left",
349
+ },
350
+ template="plotly_white",
351
+ margin={"l": 0, "r": 20, "t": 88, "b": 58},
352
+ legend={"orientation": "h", "y": 1.02, "x": 0.72},
353
+ uirevision=f"pyworldatlas-{self.alpha2}-{self.quality}",
354
+ scene={
355
+ "aspectmode": "manual",
356
+ "aspectratio": {"x": 1, "y": 0.92, "z": 0.16},
357
+ "camera": {
358
+ "eye": {"x": 0.35, "y": -0.55, "z": 0.68},
359
+ "projection": {"type": "orthographic"},
360
+ },
361
+ "xaxis": {"title": "Longitude", "showbackground": False},
362
+ "yaxis": {"title": "Latitude", "showbackground": False},
363
+ "zaxis": {"title": "", "showticklabels": False, "showbackground": False},
364
+ },
365
+ updatemenus=[
366
+ {
367
+ "type": "buttons",
368
+ "direction": "right",
369
+ "x": 0.03,
370
+ "y": 1.02,
371
+ "showactive": True,
372
+ "buttons": [
373
+ {
374
+ "label": "Elevation",
375
+ "method": "restyle",
376
+ "args": [
377
+ {
378
+ "surfacecolor": [elevation],
379
+ "colorscale": [list(_ELEVATION_SCALE)],
380
+ "cmin": [minimum],
381
+ "cmax": [maximum],
382
+ "colorbar": [{"title": {"text": "Elevation (m)"}, "thickness": 14, "len": 0.68}],
383
+ },
384
+ [0],
385
+ ],
386
+ },
387
+ {
388
+ "label": "Climate",
389
+ "method": "restyle",
390
+ "args": [
391
+ {
392
+ "surfacecolor": [climate],
393
+ "colorscale": [_climate_scale()],
394
+ "cmin": [0.5],
395
+ "cmax": [30.5],
396
+ "colorbar": [
397
+ {
398
+ "title": {"text": "Climate"},
399
+ "tickvals": present,
400
+ "ticktext": [payload["climateLegend"][str(index)]["code"] for index in present],
401
+ "thickness": 14,
402
+ "len": 0.68,
403
+ }
404
+ ],
405
+ },
406
+ [0],
407
+ ],
408
+ },
409
+ ],
410
+ }
411
+ ],
412
+ annotations=[
413
+ {
414
+ "text": (
415
+ f"{payload['resolutionArcMinutes']} arc-minute elevation · "
416
+ "NOAA ETOPO 2022 · Natural Earth · Beck et al. climate · Not for navigation"
417
+ ),
418
+ "showarrow": False,
419
+ "xref": "paper",
420
+ "yref": "paper",
421
+ "x": 0.5,
422
+ "y": -0.06,
423
+ "xanchor": "center",
424
+ "font": {"size": 11, "color": "#5f6b76"},
425
+ }
426
+ ],
427
+ )
428
+ return figure
429
+
430
+ def to_html(self) -> str:
431
+ """Return a complete standalone HTML document with Plotly embedded."""
432
+ import plotly.io as pio
433
+
434
+ html = pio.to_html(
435
+ self.figure(),
436
+ include_plotlyjs=True,
437
+ full_html=True,
438
+ config={"responsive": True, "displaylogo": False, "scrollZoom": True},
439
+ )
440
+ title = f"{self.country_name} 3D map · PyWorldAtlas"
441
+ return html.replace("<head>", f"<head><title>{title}</title>", 1)
442
+
443
+ def write_html(self, path: str | Path) -> Path:
444
+ """Write a standalone offline HTML map and return its resolved path."""
445
+ target = Path(path).expanduser().resolve()
446
+ target.parent.mkdir(parents=True, exist_ok=True)
447
+ target.write_text(self.to_html(), encoding="utf-8", newline="\n")
448
+ return target
449
+
450
+ def show(self) -> Path:
451
+ """Open the interactive map in the default browser and return its path."""
452
+ directory = Path(tempfile.gettempdir()) / "pyworldatlas-maps"
453
+ target = self.write_html(directory / f"{self.alpha2.casefold()}-{self.quality}.html")
454
+ webbrowser.open(target.as_uri(), new=2)
455
+ return target
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyworldatlas-mapview
3
+ Version: 0.9.1
4
+ Summary: Offline interactive 3D map viewer for PyWorldAtlas
5
+ Author: jcari-dev
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://jcari-dev.github.io/pyworldatlas-documentation/maps.html
8
+ Project-URL: Source, https://github.com/jcari-dev/pyworldatlas
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Education
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Scientific/Engineering :: GIS
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: plotly<7,>=6.1
17
+
18
+ # PyWorldAtlas map viewer
19
+
20
+ This companion package provides the optional offline browser renderer used by
21
+ PyWorldAtlas map extras. Install it through `pyworldatlas[maps]` or
22
+ `pyworldatlas[maps-overview]` rather than installing it directly.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/pyworldatlas_mapview/__init__.py
4
+ src/pyworldatlas_mapview/viewer.py
5
+ src/pyworldatlas_mapview.egg-info/PKG-INFO
6
+ src/pyworldatlas_mapview.egg-info/SOURCES.txt
7
+ src/pyworldatlas_mapview.egg-info/dependency_links.txt
8
+ src/pyworldatlas_mapview.egg-info/requires.txt
9
+ src/pyworldatlas_mapview.egg-info/top_level.txt