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.
- pyworldatlas_mapview-0.9.1/PKG-INFO +22 -0
- pyworldatlas_mapview-0.9.1/README.md +5 -0
- pyworldatlas_mapview-0.9.1/pyproject.toml +30 -0
- pyworldatlas_mapview-0.9.1/setup.cfg +4 -0
- pyworldatlas_mapview-0.9.1/src/pyworldatlas_mapview/__init__.py +5 -0
- pyworldatlas_mapview-0.9.1/src/pyworldatlas_mapview/viewer.py +455 -0
- pyworldatlas_mapview-0.9.1/src/pyworldatlas_mapview.egg-info/PKG-INFO +22 -0
- pyworldatlas_mapview-0.9.1/src/pyworldatlas_mapview.egg-info/SOURCES.txt +9 -0
- pyworldatlas_mapview-0.9.1/src/pyworldatlas_mapview.egg-info/dependency_links.txt +1 -0
- pyworldatlas_mapview-0.9.1/src/pyworldatlas_mapview.egg-info/requires.txt +1 -0
- pyworldatlas_mapview-0.9.1/src/pyworldatlas_mapview.egg-info/top_level.txt +1 -0
|
@@ -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,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,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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
plotly<7,>=6.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyworldatlas_mapview
|