dotted-map 0.1.0__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.
- dotted_map/__init__.py +26 -0
- dotted_map/__main__.py +5 -0
- dotted_map/cli.py +274 -0
- dotted_map/core.py +589 -0
- dotted_map/data/countries.geojson.gz +0 -0
- dotted_map/data/land.geojson.gz +0 -0
- dotted_map/export.py +50 -0
- dotted_map/py.typed +0 -0
- dotted_map/regions.py +159 -0
- dotted_map-0.1.0.dist-info/METADATA +85 -0
- dotted_map-0.1.0.dist-info/RECORD +14 -0
- dotted_map-0.1.0.dist-info/WHEEL +4 -0
- dotted_map-0.1.0.dist-info/entry_points.txt +2 -0
- dotted_map-0.1.0.dist-info/licenses/LICENSE +28 -0
dotted_map/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Draw maps on which land is a regular lattice of dots.
|
|
2
|
+
|
|
3
|
+
from dotted_map import DottedMap
|
|
4
|
+
|
|
5
|
+
m = DottedMap(region="Japan", width=600, spacing=8)
|
|
6
|
+
m.save("japan.svg", colour="#bc002d")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
|
|
11
|
+
from .core import WORLD, DottedMap, auto_projection
|
|
12
|
+
from .export import ExportError
|
|
13
|
+
from .regions import Country, countries, land, read_geojson, region
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"WORLD",
|
|
17
|
+
"Country",
|
|
18
|
+
"DottedMap",
|
|
19
|
+
"ExportError",
|
|
20
|
+
"auto_projection",
|
|
21
|
+
"countries",
|
|
22
|
+
"land",
|
|
23
|
+
"read_geojson",
|
|
24
|
+
"region",
|
|
25
|
+
]
|
|
26
|
+
__version__ = version("dotted-map")
|
dotted_map/__main__.py
ADDED
dotted_map/cli.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""The command-line interface: a thin layer over DottedMap."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__, countries, read_geojson
|
|
9
|
+
from .core import GRIDS, SHAPES, DottedMap
|
|
10
|
+
from .export import ExportError
|
|
11
|
+
from .regions import _lookup
|
|
12
|
+
|
|
13
|
+
KINDS = (".svg", ".png", ".pdf", ".json")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def positive(kind):
|
|
17
|
+
def convert(text):
|
|
18
|
+
value = kind(text)
|
|
19
|
+
if value <= 0:
|
|
20
|
+
raise argparse.ArgumentTypeError("must be positive")
|
|
21
|
+
return value
|
|
22
|
+
convert.__name__ = kind.__name__
|
|
23
|
+
return convert
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fraction(text):
|
|
27
|
+
value = float(text)
|
|
28
|
+
if not 0 < value <= 1:
|
|
29
|
+
raise argparse.ArgumentTypeError("must be greater than 0 and at "
|
|
30
|
+
"most 1")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def region_name(text):
|
|
35
|
+
try:
|
|
36
|
+
_lookup(text)
|
|
37
|
+
except ValueError as error:
|
|
38
|
+
raise argparse.ArgumentTypeError(str(error)) from None
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def highlight(text):
|
|
43
|
+
"""Parse NAME=COLOUR, as in GBR=#e4572e."""
|
|
44
|
+
name, _, colour = text.partition("=")
|
|
45
|
+
if not (name and colour):
|
|
46
|
+
raise argparse.ArgumentTypeError(
|
|
47
|
+
"use NAME=COLOUR, as in GBR=#e4572e")
|
|
48
|
+
return region_name(name), colour
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def lon_lat(text):
|
|
52
|
+
"""Return (lon, lat) from LON,LAT."""
|
|
53
|
+
try:
|
|
54
|
+
lon, lat = (float(v) for v in text.split(","))
|
|
55
|
+
except ValueError:
|
|
56
|
+
raise ValueError(f"{text!r} is not LON,LAT") from None
|
|
57
|
+
if not (-180 <= lon <= 180 and -90 <= lat <= 90):
|
|
58
|
+
raise ValueError(f"{text!r} is not a valid longitude and latitude")
|
|
59
|
+
return lon, lat
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pin(text):
|
|
63
|
+
"""Parse [NAME=]LON,LAT, as in London=-0.13,51.51."""
|
|
64
|
+
name, _, coords = text.rpartition("=")
|
|
65
|
+
try:
|
|
66
|
+
return name, *lon_lat(coords), None
|
|
67
|
+
except ValueError as error:
|
|
68
|
+
raise argparse.ArgumentTypeError(
|
|
69
|
+
f"{error}: use [NAME=]LON,LAT, as in London=-0.13,51.51"
|
|
70
|
+
) from None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_pins(path):
|
|
74
|
+
"""Return the pins in a CSV file with columns lon, lat and, optionally,
|
|
75
|
+
name and colour, as (name, lon, lat, colour) tuples."""
|
|
76
|
+
with open(path, newline="", encoding="utf-8-sig") as f:
|
|
77
|
+
rows = list(csv.DictReader(f))
|
|
78
|
+
pins = []
|
|
79
|
+
for n, row in enumerate(rows, start=2):
|
|
80
|
+
row = {k.strip().lower(): (v or "").strip()
|
|
81
|
+
for k, v in row.items() if k}
|
|
82
|
+
try:
|
|
83
|
+
lon, lat = lon_lat(f"{row['lon']},{row['lat']}")
|
|
84
|
+
except (KeyError, ValueError) as error:
|
|
85
|
+
raise ValueError(f"{path}, line {n}: needs columns lon and lat "
|
|
86
|
+
f"with valid values ({error})") from None
|
|
87
|
+
pins.append((row.get("name", ""), lon, lat,
|
|
88
|
+
row.get("colour") or row.get("color") or None))
|
|
89
|
+
return pins
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def list_regions():
|
|
93
|
+
rows = countries()
|
|
94
|
+
for c in rows:
|
|
95
|
+
print(f"{c.code:4} {c.iso_a2:3} {c.name:32} "
|
|
96
|
+
f"{c.continent}: {c.subregion}")
|
|
97
|
+
print("\nContinents:", ", ".join(sorted({c.continent for c in rows})))
|
|
98
|
+
print("Subregions:", ", ".join(sorted({c.subregion for c in rows})))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_parser():
|
|
102
|
+
parser = argparse.ArgumentParser(
|
|
103
|
+
prog="dotted-map",
|
|
104
|
+
description="Draw a map on which land is a regular lattice of dots.",
|
|
105
|
+
epilog="Region names can be ISO 3166-1 codes (GBR or GB), English "
|
|
106
|
+
"names (\"United Kingdom\"), continents (Africa) or "
|
|
107
|
+
"subregions (\"Western Europe\"): --list-regions lists them. "
|
|
108
|
+
"Colours can be any SVG colour; put a colour such as #e4572e "
|
|
109
|
+
"in quotes. Documentation: https://dotted-map.readthedocs.io")
|
|
110
|
+
parser.add_argument("--version", action="version",
|
|
111
|
+
version=f"%(prog)s {__version__}")
|
|
112
|
+
|
|
113
|
+
group = parser.add_argument_group("output")
|
|
114
|
+
group.add_argument("-o", "--output", default="map.svg", metavar="FILE",
|
|
115
|
+
help="the file to write, of a type given by its "
|
|
116
|
+
"extension: .svg, .png, .pdf or .json; or - "
|
|
117
|
+
"for SVG on standard output (default: map.svg)")
|
|
118
|
+
group.add_argument("--scale", type=positive(float), default=2,
|
|
119
|
+
help="the size of a PNG image relative to its size "
|
|
120
|
+
"in px (default: 2)")
|
|
121
|
+
group.add_argument("--list-regions", action="store_true",
|
|
122
|
+
help="list the countries, continents and subregions "
|
|
123
|
+
"that can be named, and exit")
|
|
124
|
+
|
|
125
|
+
group = parser.add_argument_group(
|
|
126
|
+
"area", "The whole world, unless --extent or --region is given.")
|
|
127
|
+
area = group.add_mutually_exclusive_group()
|
|
128
|
+
area.add_argument("--extent", type=float, nargs=4,
|
|
129
|
+
metavar=("LON_MIN", "LON_MAX", "LAT_MIN", "LAT_MAX"),
|
|
130
|
+
help="the area to map, in degrees")
|
|
131
|
+
area.add_argument("--region", type=region_name, nargs="+",
|
|
132
|
+
metavar="NAME",
|
|
133
|
+
help="frame the map on these countries, continents or "
|
|
134
|
+
"subregions")
|
|
135
|
+
group.add_argument("--projection", metavar="PROJ",
|
|
136
|
+
help="the map projection, as an EPSG code or PROJ "
|
|
137
|
+
"string (default: Lambert azimuthal equal-area, "
|
|
138
|
+
"or Equal Earth for the world)")
|
|
139
|
+
group.add_argument("--padding", type=float, default=0.05,
|
|
140
|
+
help="with --region, the space around the region, as "
|
|
141
|
+
"a fraction of the longer side (default: 0.05)")
|
|
142
|
+
|
|
143
|
+
group = parser.add_argument_group("lattice")
|
|
144
|
+
group.add_argument("--width", type=positive(int), default=800,
|
|
145
|
+
metavar="PX", help="image width (default: 800)")
|
|
146
|
+
group.add_argument("--spacing", type=positive(float), default=8,
|
|
147
|
+
metavar="PX",
|
|
148
|
+
help="distance between neighbouring dots (default: 8)")
|
|
149
|
+
group.add_argument("--grid", choices=GRIDS, default="hex",
|
|
150
|
+
help="the lattice (default: hex)")
|
|
151
|
+
group.add_argument("--margin", type=float, metavar="PX",
|
|
152
|
+
help="space around the lattice (default: the "
|
|
153
|
+
"spacing)")
|
|
154
|
+
group.add_argument("--samples", type=positive(int), default=5,
|
|
155
|
+
metavar="N",
|
|
156
|
+
help="find each dot's land fraction from N x N "
|
|
157
|
+
"points (default: 5)")
|
|
158
|
+
|
|
159
|
+
group = parser.add_argument_group(
|
|
160
|
+
"land", "Natural Earth's 1:50m land, without lakes, by default.")
|
|
161
|
+
land = group.add_mutually_exclusive_group()
|
|
162
|
+
land.add_argument("--only", type=region_name, nargs="+", metavar="NAME",
|
|
163
|
+
help="draw only the land in these regions")
|
|
164
|
+
land.add_argument("--land", metavar="FILE",
|
|
165
|
+
help="a GeoJSON file of the land to draw instead")
|
|
166
|
+
|
|
167
|
+
group = parser.add_argument_group("style")
|
|
168
|
+
group.add_argument("--colour", "--color", default="#222",
|
|
169
|
+
help="land dot colour (default: #222)")
|
|
170
|
+
group.add_argument("--size", type=positive(float), default=0.6,
|
|
171
|
+
help="dot diameter, as a fraction of the spacing "
|
|
172
|
+
"(default: 0.6)")
|
|
173
|
+
group.add_argument("--shape", choices=SHAPES, default="circle",
|
|
174
|
+
help="dot shape; all have the same area "
|
|
175
|
+
"(default: circle)")
|
|
176
|
+
group.add_argument("--halftone", action="store_true",
|
|
177
|
+
help="size each dot by its land fraction, for "
|
|
178
|
+
"smoother coastlines")
|
|
179
|
+
group.add_argument("--threshold", type=fraction,
|
|
180
|
+
help="smallest land fraction drawn (default: 0.5, "
|
|
181
|
+
"or 0.15 with --halftone)")
|
|
182
|
+
group.add_argument("--highlight", type=highlight, action="append",
|
|
183
|
+
default=[], metavar="NAME=COLOUR",
|
|
184
|
+
help="colour the land in a region; repeat for more")
|
|
185
|
+
group.add_argument("--sea", metavar="COLOUR",
|
|
186
|
+
help="draw dots in the sea, in this colour")
|
|
187
|
+
group.add_argument("--sea-size", type=positive(float), metavar="SIZE",
|
|
188
|
+
help="sea dot diameter (default: --size)")
|
|
189
|
+
group.add_argument("--background", metavar="COLOUR",
|
|
190
|
+
help="background colour (default: transparent)")
|
|
191
|
+
group.add_argument("--title", default="Dotted map",
|
|
192
|
+
help="the SVG document's title (default: Dotted map)")
|
|
193
|
+
|
|
194
|
+
group = parser.add_argument_group("places")
|
|
195
|
+
group.add_argument("--pin", type=pin, action="append", default=[],
|
|
196
|
+
metavar="[NAME=]LON,LAT",
|
|
197
|
+
help="mark a place; repeat for more")
|
|
198
|
+
group.add_argument("--pins", metavar="FILE",
|
|
199
|
+
help="mark the places in a CSV file with columns "
|
|
200
|
+
"lon, lat and, optionally, name and colour")
|
|
201
|
+
group.add_argument("--pin-colour", "--pin-color", default="#e4572e",
|
|
202
|
+
metavar="COLOUR", help="(default: #e4572e)")
|
|
203
|
+
group.add_argument("--pin-size", type=positive(float), default=1.5,
|
|
204
|
+
metavar="SIZE",
|
|
205
|
+
help="pin diameter relative to a dot (default: 1.5)")
|
|
206
|
+
group.add_argument("--rings", type=int, default=2, metavar="N",
|
|
207
|
+
help="rings around each pin (default: 2)")
|
|
208
|
+
group.add_argument("--arc", nargs=2, action="append", default=[],
|
|
209
|
+
metavar="NAME",
|
|
210
|
+
help="join two named pins with a curve; repeat for "
|
|
211
|
+
"more")
|
|
212
|
+
group.add_argument("--bend", type=float, default=0.3,
|
|
213
|
+
help="how far the curves bow (default: 0.3)")
|
|
214
|
+
group.add_argument("--dash", metavar="PATTERN",
|
|
215
|
+
help="dash the curves, as in '4 3'")
|
|
216
|
+
return parser
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def draw(args):
|
|
220
|
+
if args.output != "-" and not args.output.lower().endswith(KINDS):
|
|
221
|
+
raise ValueError(f"{args.output}: the file name must end in "
|
|
222
|
+
f"{', '.join(KINDS[:-1])} or {KINDS[-1]}")
|
|
223
|
+
pins = list(args.pin)
|
|
224
|
+
if args.pins:
|
|
225
|
+
pins += read_pins(args.pins)
|
|
226
|
+
names = {name for name, *_ in pins if name}
|
|
227
|
+
for a, b in args.arc:
|
|
228
|
+
for name in (a, b):
|
|
229
|
+
if name not in names:
|
|
230
|
+
raise ValueError(f"--arc: no pin named {name!r}")
|
|
231
|
+
|
|
232
|
+
land = read_geojson(args.land) if args.land else args.only
|
|
233
|
+
m = DottedMap(args.extent, region=args.region,
|
|
234
|
+
projection=args.projection, padding=args.padding,
|
|
235
|
+
width=args.width, spacing=args.spacing, grid=args.grid,
|
|
236
|
+
margin=args.margin, samples=args.samples, land=land)
|
|
237
|
+
index = {}
|
|
238
|
+
for name, lon, lat, colour in pins:
|
|
239
|
+
index[name] = m.add_pin(lon, lat, colour=colour or args.pin_colour,
|
|
240
|
+
size=args.pin_size, rings=args.rings)
|
|
241
|
+
for a, b in args.arc:
|
|
242
|
+
m.add_arc(index[a], index[b], bend=args.bend, dash=args.dash)
|
|
243
|
+
|
|
244
|
+
style = dict(colour=args.colour, size=args.size, shape=args.shape,
|
|
245
|
+
halftone=args.halftone, threshold=args.threshold,
|
|
246
|
+
sea=args.sea, sea_size=args.sea_size,
|
|
247
|
+
highlight=dict(args.highlight))
|
|
248
|
+
if not args.output.lower().endswith(".json"):
|
|
249
|
+
style.update(background=args.background, title=args.title)
|
|
250
|
+
if args.output == "-":
|
|
251
|
+
sys.stdout.write(m.svg(**style))
|
|
252
|
+
sys.stdout.flush()
|
|
253
|
+
else:
|
|
254
|
+
m.save(args.output, scale=args.scale, **style)
|
|
255
|
+
print(f"wrote {args.output}", file=sys.stderr)
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main(argv=None):
|
|
260
|
+
"""Run the command line, returning the process's exit status."""
|
|
261
|
+
args = build_parser().parse_args(argv)
|
|
262
|
+
try:
|
|
263
|
+
if args.list_regions:
|
|
264
|
+
list_regions()
|
|
265
|
+
return 0
|
|
266
|
+
return draw(args)
|
|
267
|
+
except BrokenPipeError:
|
|
268
|
+
# The reader went away, as with `| head`. Stop quietly, and stop
|
|
269
|
+
# Python reporting the same error when it flushes stdout on exit.
|
|
270
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
271
|
+
return 1
|
|
272
|
+
except (OSError, ValueError, ExportError) as error:
|
|
273
|
+
print(f"error: {error}", file=sys.stderr)
|
|
274
|
+
return 1
|
dotted_map/core.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""The DottedMap class: a map on which land is a regular lattice of dots."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from html import escape
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import shapely
|
|
9
|
+
from pyproj import CRS, Transformer
|
|
10
|
+
|
|
11
|
+
from . import export, regions
|
|
12
|
+
|
|
13
|
+
#: The extent of the whole world, (lon_min, lon_max, lat_min, lat_max).
|
|
14
|
+
WORLD = (-180, 180, -90, 90)
|
|
15
|
+
GRIDS = ("hex", "square")
|
|
16
|
+
SHAPES = ("circle", "hexagon", "square")
|
|
17
|
+
#: The default diameter of a dot, as a fraction of the spacing.
|
|
18
|
+
DEFAULT_SIZE = 0.6
|
|
19
|
+
DEFAULT_PIN_COLOUR = "#e4572e"
|
|
20
|
+
#: Parts of a region farther than this from its largest landmass, or from
|
|
21
|
+
#: a part within this distance of it, are left out of its frame (metres).
|
|
22
|
+
REGION_GAP = 500e3
|
|
23
|
+
# The number of lattice sites whose land fraction is found at a time, which
|
|
24
|
+
# limits the memory used for large maps.
|
|
25
|
+
_CHUNK = 20000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def auto_projection(extent):
|
|
29
|
+
"""Return a PROJ string for a projection suited to an extent.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
extent: (lon_min, lon_max, lat_min, lat_max), in degrees. If lon_min
|
|
33
|
+
is greater than lon_max, the extent crosses the antimeridian
|
|
34
|
+
(180 degrees).
|
|
35
|
+
|
|
36
|
+
The projection is Equal Earth (an equal-area projection designed for
|
|
37
|
+
world maps) for extents wider than 180 degrees of longitude, and
|
|
38
|
+
otherwise the Lambert azimuthal equal-area projection centred on the
|
|
39
|
+
extent.
|
|
40
|
+
"""
|
|
41
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
42
|
+
width = (lon_max - lon_min) % 360 or 360
|
|
43
|
+
lon_0 = (lon_min + width / 2 + 180) % 360 - 180
|
|
44
|
+
if width > 180:
|
|
45
|
+
return "+proj=eqearth" + (f" +lon_0={lon_0:g}" if lon_0 else "")
|
|
46
|
+
return f"+proj=laea +lon_0={lon_0:g} +lat_0={(lat_min + lat_max) / 2:g}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extent_bounds(transformer, extent, n=201):
|
|
50
|
+
"""Return the bounds, in projected coordinates, of an extent.
|
|
51
|
+
|
|
52
|
+
The extent is sampled at n x n points, inside it as well as on its
|
|
53
|
+
edges, because its projection's extremes can lie inside it: the bottom
|
|
54
|
+
of an orthographic globe, for example. Points that the projection
|
|
55
|
+
cannot show, as on the far side of a globe, are ignored.
|
|
56
|
+
"""
|
|
57
|
+
lon_min, lon_max, lat_min, lat_max = extent
|
|
58
|
+
if lon_max <= lon_min:
|
|
59
|
+
lon_max += 360
|
|
60
|
+
lon, lat = np.meshgrid(np.linspace(lon_min, lon_max, n),
|
|
61
|
+
np.linspace(lat_min, lat_max, n))
|
|
62
|
+
lon = np.where(lon > 180, lon - 360, lon)
|
|
63
|
+
with np.errstate(invalid="ignore"):
|
|
64
|
+
X, Y = transformer.transform(lon, lat)
|
|
65
|
+
ok = np.isfinite(X) & np.isfinite(Y)
|
|
66
|
+
if not ok.any():
|
|
67
|
+
raise ValueError(f"the projection cannot show the extent {extent}")
|
|
68
|
+
return X[ok].min(), Y[ok].min(), X[ok].max(), Y[ok].max()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _project(geom, transformer):
|
|
72
|
+
"""Return geometries transformed by a pyproj Transformer."""
|
|
73
|
+
return shapely.transform(
|
|
74
|
+
geom, lambda xy: np.column_stack(transformer.transform(*xy.T)))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _frame(geom, padding, projection=None):
|
|
78
|
+
"""Return a projection and bounds that frame a region's main landmass.
|
|
79
|
+
|
|
80
|
+
The frame holds the region's largest part, and the parts within
|
|
81
|
+
REGION_GAP of it or of each other, with padding (a fraction of the
|
|
82
|
+
larger side) all round. The projection, unless given, is the Lambert
|
|
83
|
+
azimuthal equal-area projection centred on the largest part.
|
|
84
|
+
"""
|
|
85
|
+
parts = shapely.get_parts(geom)
|
|
86
|
+
# Find the largest part from its area in an equal-area projection.
|
|
87
|
+
ee = Transformer.from_crs("EPSG:4326", "+proj=eqearth", always_xy=True)
|
|
88
|
+
areas = shapely.area(_project(parts, ee))
|
|
89
|
+
main = int(np.argmax(areas))
|
|
90
|
+
if projection is None:
|
|
91
|
+
centre = shapely.centroid(_project(parts[main], ee))
|
|
92
|
+
lon_0, lat_0 = ee.transform(centre.x, centre.y, direction="INVERSE")
|
|
93
|
+
projection = f"+proj=laea +lon_0={lon_0:.3f} +lat_0={lat_0:.3f}"
|
|
94
|
+
fwd = Transformer.from_crs("EPSG:4326", projection, always_xy=True)
|
|
95
|
+
with np.errstate(invalid="ignore"):
|
|
96
|
+
parts = _project(parts, fwd)
|
|
97
|
+
# Leave out parts that the projection cannot show (e.g. on the far
|
|
98
|
+
# side of a globe).
|
|
99
|
+
finite = np.isfinite(shapely.bounds(parts)).all(axis=1)
|
|
100
|
+
chosen = np.zeros(len(parts), dtype=bool)
|
|
101
|
+
chosen[main] = True
|
|
102
|
+
while True:
|
|
103
|
+
cluster = shapely.multipolygons(parts[chosen])
|
|
104
|
+
near = (~chosen & finite
|
|
105
|
+
& (shapely.distance(parts, cluster) < REGION_GAP))
|
|
106
|
+
if not near.any():
|
|
107
|
+
break
|
|
108
|
+
chosen |= near
|
|
109
|
+
xmin, ymin, xmax, ymax = shapely.total_bounds(parts[chosen])
|
|
110
|
+
pad = padding * max(xmax - xmin, ymax - ymin)
|
|
111
|
+
return projection, (xmin - pad, ymin - pad, xmax + pad, ymax + pad)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _land_test(land):
|
|
115
|
+
"""Return a function of longitude and latitude arrays that returns
|
|
116
|
+
True for points on land, from the land argument of DottedMap."""
|
|
117
|
+
if callable(land):
|
|
118
|
+
return land
|
|
119
|
+
world = regions.land()
|
|
120
|
+
if land is None:
|
|
121
|
+
return lambda lon, lat: shapely.contains_xy(world, lon, lat)
|
|
122
|
+
if isinstance(land, shapely.Geometry):
|
|
123
|
+
geom = land
|
|
124
|
+
shapely.prepare(geom)
|
|
125
|
+
return lambda lon, lat: shapely.contains_xy(geom, lon, lat)
|
|
126
|
+
area = regions.region(land)
|
|
127
|
+
return lambda lon, lat: (shapely.contains_xy(world, lon, lat)
|
|
128
|
+
& shapely.contains_xy(area, lon, lat))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DottedMap:
|
|
132
|
+
"""A map on which land is drawn as a regular lattice of dots.
|
|
133
|
+
|
|
134
|
+
Making a DottedMap lays out the lattice and finds the fraction of each
|
|
135
|
+
lattice cell that is land; svg(), save() and to_dict() then draw it, in
|
|
136
|
+
any style.
|
|
137
|
+
|
|
138
|
+
The area mapped is set by one of `extent`, `region` or `bounds`. With
|
|
139
|
+
none of them, the map is of the whole world.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
extent: (lon_min, lon_max, lat_min, lat_max), in degrees. If
|
|
143
|
+
lon_min is greater than lon_max, the extent crosses the
|
|
144
|
+
antimeridian.
|
|
145
|
+
region: the name of a country, continent or subregion, or a list
|
|
146
|
+
of them, as for `region()`. The map is framed on the region's
|
|
147
|
+
largest landmass and any land within 500 km of it, so that a
|
|
148
|
+
map of France includes Corsica but not French Guiana.
|
|
149
|
+
projection: anything that pyproj.CRS accepts, such as an EPSG code
|
|
150
|
+
or a PROJ string. By default, `auto_projection` (extent), or a
|
|
151
|
+
Lambert azimuthal equal-area projection centred on the region.
|
|
152
|
+
bounds: (xmin, ymin, xmax, ymax), in the projection's coordinates
|
|
153
|
+
(usually metres), to set the area mapped directly. The
|
|
154
|
+
projection must be given too.
|
|
155
|
+
padding: with `region`, the space around the region's land, as a
|
|
156
|
+
fraction of the longer side of the frame.
|
|
157
|
+
width: the width of the image, px. Its height follows from the
|
|
158
|
+
area mapped.
|
|
159
|
+
spacing: the distance between neighbouring dots, px.
|
|
160
|
+
grid: "hex", a hexagonal lattice, on which each dot has six
|
|
161
|
+
equidistant neighbours, or "square".
|
|
162
|
+
margin: the space between the lattice and the edge of the image,
|
|
163
|
+
px (by default, the spacing).
|
|
164
|
+
samples: each cell's land fraction is found from a samples x
|
|
165
|
+
samples array of points within it.
|
|
166
|
+
land: what counts as land. By default, the land of Natural Earth's
|
|
167
|
+
1:50m outlines, without its lakes. It can be the name of a
|
|
168
|
+
region, or a list of them, to draw only the land within them;
|
|
169
|
+
a shapely geometry in longitude and latitude (see
|
|
170
|
+
`read_geojson()`); or a function that takes arrays of longitude
|
|
171
|
+
and latitude and returns an array that is True for points on
|
|
172
|
+
land.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: for inconsistent or invalid arguments, such as an
|
|
176
|
+
unknown region.
|
|
177
|
+
|
|
178
|
+
Attributes:
|
|
179
|
+
x: an array of the x coordinate of each lattice site, px.
|
|
180
|
+
y: an array of the y coordinate of each lattice site, px,
|
|
181
|
+
downwards from the top of the image.
|
|
182
|
+
land: an array of the fraction of each site's cell that is land.
|
|
183
|
+
inside: an array of the fraction of each site's cell within the
|
|
184
|
+
projection's domain (as within the outline of a world map).
|
|
185
|
+
lon: an array of the longitude of the point that stands for each
|
|
186
|
+
site, which colour and size functions are given: its cell's
|
|
187
|
+
sample on land nearest to its centre or, if it has none, its
|
|
188
|
+
nearest sample within the projection's domain (NaN if there is
|
|
189
|
+
none).
|
|
190
|
+
lat: an array of the latitude of the same points.
|
|
191
|
+
width: the width of the image, px.
|
|
192
|
+
height: the height of the image, px.
|
|
193
|
+
crs: the projection, as a pyproj.CRS.
|
|
194
|
+
pins: a list of the pins added, as dicts.
|
|
195
|
+
arcs: a list of the arcs added, as dicts.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, extent=None, *, region=None, projection=None,
|
|
199
|
+
bounds=None, padding=0.05, width=800, spacing=8,
|
|
200
|
+
grid="hex", margin=None, samples=5, land=None):
|
|
201
|
+
if sum(x is not None for x in (extent, region, bounds)) > 1:
|
|
202
|
+
raise ValueError("give only one of extent, region and bounds")
|
|
203
|
+
if grid not in GRIDS:
|
|
204
|
+
raise ValueError(f"unknown grid {grid!r}: use hex or square")
|
|
205
|
+
if not (width > 0 and spacing > 0 and samples >= 1):
|
|
206
|
+
raise ValueError("width, spacing and samples must be positive")
|
|
207
|
+
if margin is not None and margin < 0:
|
|
208
|
+
raise ValueError("margin must not be negative")
|
|
209
|
+
|
|
210
|
+
if region is not None:
|
|
211
|
+
projection, bounds = _frame(regions.region(region), padding,
|
|
212
|
+
projection)
|
|
213
|
+
elif bounds is not None:
|
|
214
|
+
if projection is None:
|
|
215
|
+
raise ValueError("bounds are in the projection's "
|
|
216
|
+
"coordinates: give the projection too")
|
|
217
|
+
else:
|
|
218
|
+
extent = WORLD if extent is None else extent
|
|
219
|
+
projection = projection or auto_projection(extent)
|
|
220
|
+
self.crs = CRS(projection)
|
|
221
|
+
self._fwd = Transformer.from_crs("EPSG:4326", self.crs,
|
|
222
|
+
always_xy=True)
|
|
223
|
+
self._inv = Transformer.from_crs(self.crs, "EPSG:4326",
|
|
224
|
+
always_xy=True)
|
|
225
|
+
if bounds is None:
|
|
226
|
+
bounds = _extent_bounds(self._fwd, extent)
|
|
227
|
+
self.bounds = tuple(float(b) for b in bounds)
|
|
228
|
+
self.xmin, self.ymin, self.xmax, self.ymax = self.bounds
|
|
229
|
+
if not (self.xmax > self.xmin and self.ymax > self.ymin):
|
|
230
|
+
raise ValueError(f"empty bounds: {self.bounds}")
|
|
231
|
+
|
|
232
|
+
self.spacing = s = spacing
|
|
233
|
+
self.grid = grid
|
|
234
|
+
self.margin = m = spacing if margin is None else margin
|
|
235
|
+
# The image scale (px per unit of the projection, usually metres)
|
|
236
|
+
# and size (px).
|
|
237
|
+
self.scale = (width - 2 * m) / (self.xmax - self.xmin)
|
|
238
|
+
self.width = width
|
|
239
|
+
self.height = round((self.ymax - self.ymin) * self.scale + 2 * m)
|
|
240
|
+
|
|
241
|
+
# The lattice: rows dy apart; on a hexagonal lattice, odd rows are
|
|
242
|
+
# offset by s/2 so that each dot has six equidistant neighbours.
|
|
243
|
+
dy = s * np.sqrt(3) / 2 if grid == "hex" else s
|
|
244
|
+
nx = max(int((width - 2 * m) // s), 1)
|
|
245
|
+
ny = max(int((self.height - 2 * m) // dy), 1)
|
|
246
|
+
i, j = np.meshgrid(np.arange(nx, dtype=float), np.arange(ny))
|
|
247
|
+
x = i * s
|
|
248
|
+
if grid == "hex":
|
|
249
|
+
x += (j % 2) * s / 2
|
|
250
|
+
y = j * dy
|
|
251
|
+
# Centre the lattice in the image.
|
|
252
|
+
self.x = (x + (self.width - x.max()) / 2).ravel()
|
|
253
|
+
self.y = (y + (self.height - y.max()) / 2).ravel()
|
|
254
|
+
self._find_land(_land_test(land), samples, dy)
|
|
255
|
+
self.pins = []
|
|
256
|
+
self.arcs = []
|
|
257
|
+
|
|
258
|
+
def _find_land(self, is_land, samples, dy):
|
|
259
|
+
"""Find the land fraction of each cell, and the point that stands
|
|
260
|
+
for it.
|
|
261
|
+
|
|
262
|
+
Each cell is a rectangle spacing wide and dy high, so that the
|
|
263
|
+
cells tile the plane, and is sampled at samples x samples points.
|
|
264
|
+
The point that stands for a cell, whose longitude and latitude are
|
|
265
|
+
given to colour and size functions, is its sample on land nearest
|
|
266
|
+
to its centre or, if it has none, its nearest sample within the
|
|
267
|
+
projection's domain.
|
|
268
|
+
"""
|
|
269
|
+
u = (np.arange(samples) + 0.5) / samples - 0.5
|
|
270
|
+
du, dv = (a.ravel() for a in np.meshgrid(u * self.spacing, u * dy))
|
|
271
|
+
order = np.argsort(np.hypot(du, dv), kind="stable")
|
|
272
|
+
du, dv = du[order], dv[order]
|
|
273
|
+
n = len(self.x)
|
|
274
|
+
self.land = np.empty(n)
|
|
275
|
+
self.inside = np.empty(n)
|
|
276
|
+
self.lon = np.full(n, np.nan)
|
|
277
|
+
self.lat = np.full(n, np.nan)
|
|
278
|
+
for start in range(0, n, _CHUNK):
|
|
279
|
+
cells = slice(start, start + _CHUNK)
|
|
280
|
+
lon, lat, valid = self.px_to_lonlat(self.x[cells, None] + du,
|
|
281
|
+
self.y[cells, None] + dv)
|
|
282
|
+
on = np.zeros_like(valid)
|
|
283
|
+
on[valid] = np.asarray(is_land(lon[valid], lat[valid]),
|
|
284
|
+
dtype=bool)
|
|
285
|
+
self.land[cells] = on.mean(axis=1)
|
|
286
|
+
# The fraction of each cell within the projection's domain
|
|
287
|
+
# (e.g. within the outline of a world map or the disc of a
|
|
288
|
+
# globe).
|
|
289
|
+
self.inside[cells] = valid.mean(axis=1)
|
|
290
|
+
pick = np.where(on.any(axis=1), on.argmax(axis=1),
|
|
291
|
+
valid.argmax(axis=1))
|
|
292
|
+
rows = np.arange(len(pick))
|
|
293
|
+
ok = valid[rows, pick]
|
|
294
|
+
self.lon[cells] = np.where(ok, lon[rows, pick], np.nan)
|
|
295
|
+
self.lat[cells] = np.where(ok, lat[rows, pick], np.nan)
|
|
296
|
+
|
|
297
|
+
def px_to_lonlat(self, x, y):
|
|
298
|
+
"""Return the longitude and latitude of image points (x, y), and an
|
|
299
|
+
array that is True for the points within the projection's domain."""
|
|
300
|
+
X = self.xmin + (np.asarray(x) - self.margin) / self.scale
|
|
301
|
+
Y = self.ymax - (np.asarray(y) - self.margin) / self.scale
|
|
302
|
+
tolerance = 1e-6 * max(self.xmax - self.xmin, self.ymax - self.ymin)
|
|
303
|
+
with np.errstate(invalid="ignore"):
|
|
304
|
+
lon, lat = self._inv.transform(X, Y)
|
|
305
|
+
# Some inverse projections return finite but meaningless values
|
|
306
|
+
# outside their domain: a round trip identifies these.
|
|
307
|
+
X2, Y2 = self._fwd.transform(lon, lat)
|
|
308
|
+
valid = (np.isfinite(lon) & np.isfinite(lat)
|
|
309
|
+
& (np.hypot(X2 - X, Y2 - Y) < tolerance)
|
|
310
|
+
& (np.abs(lat) <= 90) & (np.abs(lon) <= 180))
|
|
311
|
+
return lon, lat, valid
|
|
312
|
+
|
|
313
|
+
def lonlat_to_px(self, lon, lat):
|
|
314
|
+
"""Return the image coordinates of the point (lon, lat)."""
|
|
315
|
+
X, Y = self._fwd.transform(lon, lat)
|
|
316
|
+
return (self.margin + (X - self.xmin) * self.scale,
|
|
317
|
+
self.margin + (self.ymax - Y) * self.scale)
|
|
318
|
+
|
|
319
|
+
def nearest(self, lon, lat):
|
|
320
|
+
"""Return the index of the lattice site nearest to (lon, lat)."""
|
|
321
|
+
x, y = self.lonlat_to_px(lon, lat)
|
|
322
|
+
if not (np.isfinite(x) and np.isfinite(y)):
|
|
323
|
+
raise ValueError(f"({lon}, {lat}) is not on the map")
|
|
324
|
+
return int(np.argmin(np.hypot(self.x - x, self.y - y)))
|
|
325
|
+
|
|
326
|
+
def add_pin(self, lon, lat, *, colour=DEFAULT_PIN_COLOUR, size=1.5,
|
|
327
|
+
rings=2):
|
|
328
|
+
"""Mark a place with a pin: the dot nearest to it, enlarged, with
|
|
329
|
+
concentric rings around it.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
lon, lat: the place's longitude and latitude, in degrees.
|
|
333
|
+
colour: the colour of the pin and its rings.
|
|
334
|
+
size: the pin's diameter, relative to that of a land dot.
|
|
335
|
+
rings: the number of rings, each fainter than the one inside.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
An index for the pin, for use with `add_arc()`.
|
|
339
|
+
"""
|
|
340
|
+
self.pins.append({"site": self.nearest(lon, lat), "colour": colour,
|
|
341
|
+
"size": size, "rings": rings, "lon": lon,
|
|
342
|
+
"lat": lat})
|
|
343
|
+
return len(self.pins) - 1
|
|
344
|
+
|
|
345
|
+
def add_arc(self, pin1, pin2, *, colour=None, width=None, bend=0.3,
|
|
346
|
+
dash=None):
|
|
347
|
+
"""Join two pins with a curve that bows upwards.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
pin1, pin2: indexes returned by `add_pin()`.
|
|
351
|
+
colour: the colour of the curve (by default, that of pin1).
|
|
352
|
+
width: its stroke width, px (by default, a fifth of the
|
|
353
|
+
spacing).
|
|
354
|
+
bend: the curve is a quadratic Bezier curve whose control point
|
|
355
|
+
is displaced from the midpoint of the chord by bend times
|
|
356
|
+
the chord's length.
|
|
357
|
+
dash: an SVG stroke-dasharray, as in "4 3", for a dashed curve.
|
|
358
|
+
"""
|
|
359
|
+
for pin in (pin1, pin2):
|
|
360
|
+
if not 0 <= pin < len(self.pins):
|
|
361
|
+
raise ValueError(f"no pin {pin}")
|
|
362
|
+
self.arcs.append({"pins": (pin1, pin2),
|
|
363
|
+
"colour": colour or self.pins[pin1]["colour"],
|
|
364
|
+
"width": width or self.spacing / 5, "bend": bend,
|
|
365
|
+
"dash": dash})
|
|
366
|
+
|
|
367
|
+
def _layout(self, colour="#222", size=DEFAULT_SIZE, shape="circle",
|
|
368
|
+
halftone=False, threshold=None, sea=None, sea_size=None,
|
|
369
|
+
highlight=None):
|
|
370
|
+
"""Return the land dots and sea dots to draw, each as arrays of
|
|
371
|
+
lattice site indexes, radii (px) and colours."""
|
|
372
|
+
if shape not in SHAPES:
|
|
373
|
+
raise ValueError(f"unknown shape {shape!r}: use "
|
|
374
|
+
f"{', '.join(SHAPES)}")
|
|
375
|
+
if threshold is None:
|
|
376
|
+
threshold = 0.15 if halftone else 0.5
|
|
377
|
+
if not 0 < threshold <= 1:
|
|
378
|
+
raise ValueError("threshold must be greater than 0 and at "
|
|
379
|
+
"most 1")
|
|
380
|
+
pinned = np.zeros(len(self.x), dtype=bool)
|
|
381
|
+
pinned[[pin["site"] for pin in self.pins]] = True
|
|
382
|
+
on_land = (self.land >= threshold) & ~pinned
|
|
383
|
+
|
|
384
|
+
idx = np.flatnonzero(on_land)
|
|
385
|
+
lon, lat = self.lon[idx], self.lat[idx]
|
|
386
|
+
d = (np.asarray(size(lon, lat), dtype=float) if callable(size)
|
|
387
|
+
else np.full(len(idx), float(size)))
|
|
388
|
+
radii = d * self.spacing / 2
|
|
389
|
+
if halftone:
|
|
390
|
+
radii *= np.sqrt(self.land[idx])
|
|
391
|
+
colours = np.empty(len(idx), dtype=object)
|
|
392
|
+
colours[:] = colour(lon, lat) if callable(colour) else colour
|
|
393
|
+
for key, c in (highlight or {}).items():
|
|
394
|
+
geom = (key if isinstance(key, shapely.Geometry)
|
|
395
|
+
else regions.region(key))
|
|
396
|
+
colours[shapely.contains_xy(geom, lon, lat)] = c
|
|
397
|
+
keep = radii > 0
|
|
398
|
+
land = idx[keep], radii[keep], colours[keep]
|
|
399
|
+
|
|
400
|
+
if sea is None:
|
|
401
|
+
sea_dots = np.array([], dtype=int), np.array([]), np.array([])
|
|
402
|
+
else:
|
|
403
|
+
if sea_size is None:
|
|
404
|
+
sea_size = DEFAULT_SIZE if callable(size) else size
|
|
405
|
+
idx = np.flatnonzero(~on_land & ~pinned & (self.inside >= 0.5))
|
|
406
|
+
sea_dots = (idx, np.full(len(idx), sea_size * self.spacing / 2),
|
|
407
|
+
np.full(len(idx), sea, dtype=object))
|
|
408
|
+
pin_r = (DEFAULT_SIZE if callable(size) else size) * self.spacing / 2
|
|
409
|
+
return land, sea_dots, pin_r, shape
|
|
410
|
+
|
|
411
|
+
def svg(self, colour="#222", *, size=DEFAULT_SIZE, shape="circle",
|
|
412
|
+
halftone=False, threshold=None, sea=None, sea_size=None,
|
|
413
|
+
highlight=None, background=None, title="Dotted map"):
|
|
414
|
+
"""Return the map as an SVG document.
|
|
415
|
+
|
|
416
|
+
The document's groups have ids for each layer, from the bottom up:
|
|
417
|
+
"sea", "land", "arcs" and "pins". Within "land", there is a group
|
|
418
|
+
for each colour.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
colour: the colour of the land dots, or a function that takes
|
|
422
|
+
arrays of longitude and latitude and returns a colour for
|
|
423
|
+
each.
|
|
424
|
+
size: the diameter of a land dot, as a fraction of the spacing,
|
|
425
|
+
or a function that takes arrays of longitude and latitude
|
|
426
|
+
and returns a diameter for each.
|
|
427
|
+
shape: "circle", "hexagon" or "square"; each has the area of a
|
|
428
|
+
circle of the given diameter.
|
|
429
|
+
halftone: if True, make the area of each dot proportional to
|
|
430
|
+
its cell's land fraction, which smooths the coastlines and
|
|
431
|
+
keeps small islands.
|
|
432
|
+
threshold: the smallest land fraction of a cell drawn as land
|
|
433
|
+
(by default, 0.5, or 0.15 with `halftone`).
|
|
434
|
+
sea: if given, the colour of dots drawn in the sea.
|
|
435
|
+
sea_size: the diameter of a sea dot, as a fraction of the
|
|
436
|
+
spacing (by default, that of a land dot, or 0.6 if `size`
|
|
437
|
+
is a function).
|
|
438
|
+
highlight: a dict from region names (as for `region()`), or
|
|
439
|
+
shapely geometries, to colours: the land dots within each
|
|
440
|
+
are drawn in its colour. Later entries take precedence.
|
|
441
|
+
background: if given, the background colour; otherwise the
|
|
442
|
+
background is transparent.
|
|
443
|
+
title: the document's title, for accessibility.
|
|
444
|
+
"""
|
|
445
|
+
land, sea_dots, pin_r, shape = self._layout(
|
|
446
|
+
colour, size, shape, halftone, threshold, sea, sea_size,
|
|
447
|
+
highlight)
|
|
448
|
+
out = [f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
449
|
+
f'viewBox="0 0 {self.width} {self.height}" '
|
|
450
|
+
f'width="{self.width}" height="{self.height}">',
|
|
451
|
+
f'<title>{escape(title)}</title>']
|
|
452
|
+
if background:
|
|
453
|
+
out.append(f'<rect width="100%" height="100%" '
|
|
454
|
+
f'fill="{escape(background)}"/>')
|
|
455
|
+
if sea is not None:
|
|
456
|
+
idx, radii, _ = sea_dots
|
|
457
|
+
out.append(f'<g id="sea" fill="{escape(sea)}">')
|
|
458
|
+
out += [_marker(self.x[k], self.y[k], r, shape)
|
|
459
|
+
for k, r in zip(idx, radii, strict=True)]
|
|
460
|
+
out.append('</g>')
|
|
461
|
+
|
|
462
|
+
# Land dots, in a group for each colour.
|
|
463
|
+
groups = {}
|
|
464
|
+
for k, r, c in zip(*land, strict=True):
|
|
465
|
+
groups.setdefault(str(c), []).append(
|
|
466
|
+
_marker(self.x[k], self.y[k], r, shape))
|
|
467
|
+
out.append('<g id="land">')
|
|
468
|
+
for c, markers in groups.items():
|
|
469
|
+
out += [f'<g fill="{escape(c)}">', *markers, '</g>']
|
|
470
|
+
out.append('</g>')
|
|
471
|
+
|
|
472
|
+
out.append('<g id="arcs" fill="none" stroke-linecap="round">')
|
|
473
|
+
for arc in self.arcs:
|
|
474
|
+
dash = (f' stroke-dasharray="{escape(arc["dash"])}"'
|
|
475
|
+
if arc["dash"] else "")
|
|
476
|
+
out.append(f'<path d="{self._arc_path(arc)}" '
|
|
477
|
+
f'stroke="{escape(arc["colour"])}" '
|
|
478
|
+
f'stroke-width="{arc["width"]:.2f}"{dash}/>')
|
|
479
|
+
out.append('</g>')
|
|
480
|
+
|
|
481
|
+
out.append('<g id="pins">')
|
|
482
|
+
for pin in self.pins:
|
|
483
|
+
x, y = self.x[pin["site"]], self.y[pin["site"]]
|
|
484
|
+
r = pin_r * pin["size"]
|
|
485
|
+
fill = escape(pin["colour"])
|
|
486
|
+
# Concentric rings, fainter with distance from the pin.
|
|
487
|
+
for n in range(pin["rings"], 0, -1):
|
|
488
|
+
out.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" '
|
|
489
|
+
f'r="{r * (1 + 0.9 * n):.2f}" fill="none" '
|
|
490
|
+
f'stroke="{fill}" stroke-width="{pin_r / 2:.2f}" '
|
|
491
|
+
f'stroke-opacity="{0.9 / (n + 1):.2f}"/>')
|
|
492
|
+
out.append(f'<g fill="{fill}">{_marker(x, y, r, shape)}</g>')
|
|
493
|
+
out.append('</g>')
|
|
494
|
+
out.append('</svg>')
|
|
495
|
+
return "\n".join(out) + "\n"
|
|
496
|
+
|
|
497
|
+
def _arc_path(self, arc):
|
|
498
|
+
"""Return the SVG path data of an arc."""
|
|
499
|
+
k1, k2 = (self.pins[p]["site"] for p in arc["pins"])
|
|
500
|
+
x1, y1, x2, y2 = self.x[k1], self.y[k1], self.x[k2], self.y[k2]
|
|
501
|
+
# Displace the control point perpendicular to the chord, upwards
|
|
502
|
+
# (towards negative y).
|
|
503
|
+
dx, dy = x2 - x1, y2 - y1
|
|
504
|
+
nx, ny = (dy, -dx) if dx >= 0 else (-dy, dx)
|
|
505
|
+
cx = (x1 + x2) / 2 + arc["bend"] * nx
|
|
506
|
+
cy = (y1 + y2) / 2 + arc["bend"] * ny
|
|
507
|
+
return f"M{x1:.2f},{y1:.2f} Q{cx:.2f},{cy:.2f} {x2:.2f},{y2:.2f}"
|
|
508
|
+
|
|
509
|
+
def to_dict(self, colour="#222", **style):
|
|
510
|
+
"""Return the map as a dict that can be written as JSON, for use
|
|
511
|
+
with other software (e.g. to draw or animate the map on a web page).
|
|
512
|
+
|
|
513
|
+
It takes the same arguments as `svg()`, apart from `background`
|
|
514
|
+
and `title`. The dict has the image's "width" and "height" (px),
|
|
515
|
+
the "spacing", the "shape" of the dots, and lists of "dots", "pins"
|
|
516
|
+
and "arcs". Each dot has its centre, "x" and "y", its radius "r"
|
|
517
|
+
(that of a circle of the same area, for hexagons and squares), its
|
|
518
|
+
"fill" colour, its "layer" ("land" or "sea"), and the "lon" and
|
|
519
|
+
"lat" of the point that stands for it (see `lon`).
|
|
520
|
+
"""
|
|
521
|
+
land, sea_dots, pin_r, shape = self._layout(colour, **style)
|
|
522
|
+
|
|
523
|
+
def number(v):
|
|
524
|
+
return None if np.isnan(v) else round(float(v), 4)
|
|
525
|
+
|
|
526
|
+
dots = []
|
|
527
|
+
for layer, (idx, radii, colours) in (("sea", sea_dots),
|
|
528
|
+
("land", land)):
|
|
529
|
+
dots += [{"x": round(float(self.x[k]), 2),
|
|
530
|
+
"y": round(float(self.y[k]), 2),
|
|
531
|
+
"r": round(float(r), 2), "fill": str(c),
|
|
532
|
+
"layer": layer, "lon": number(self.lon[k]),
|
|
533
|
+
"lat": number(self.lat[k])}
|
|
534
|
+
for k, r, c in zip(idx, radii, colours, strict=True)]
|
|
535
|
+
pins = [{"x": round(float(self.x[p["site"]]), 2),
|
|
536
|
+
"y": round(float(self.y[p["site"]]), 2),
|
|
537
|
+
"r": round(pin_r * p["size"], 2), "fill": p["colour"],
|
|
538
|
+
"rings": p["rings"], "lon": p["lon"], "lat": p["lat"]}
|
|
539
|
+
for p in self.pins]
|
|
540
|
+
arcs = [{"pins": list(a["pins"]), "d": self._arc_path(a),
|
|
541
|
+
"stroke": a["colour"], "width": round(a["width"], 2),
|
|
542
|
+
"dash": a["dash"]} for a in self.arcs]
|
|
543
|
+
return {"width": self.width, "height": self.height,
|
|
544
|
+
"spacing": self.spacing, "shape": shape, "dots": dots,
|
|
545
|
+
"pins": pins, "arcs": arcs}
|
|
546
|
+
|
|
547
|
+
def save(self, path, *, scale=2, **style):
|
|
548
|
+
"""Write the map to a file, of a type given by its extension.
|
|
549
|
+
|
|
550
|
+
PNG and PDF output needs the CairoSVG package (installed with
|
|
551
|
+
``pip install "dotted-map[cairo]"``) or the rsvg-convert program.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
path: a file name ending in .svg, .png, .pdf or .json.
|
|
555
|
+
scale: the size of a PNG image, relative to the SVG image's
|
|
556
|
+
size in px; 2 suits high-resolution screens.
|
|
557
|
+
**style: as for `svg()`, or `to_dict()` for JSON.
|
|
558
|
+
|
|
559
|
+
Raises:
|
|
560
|
+
ExportError: if a PNG or PDF image cannot be made.
|
|
561
|
+
"""
|
|
562
|
+
path = Path(path)
|
|
563
|
+
kind = path.suffix.lower().lstrip(".")
|
|
564
|
+
if kind == "json":
|
|
565
|
+
path.write_text(json.dumps(self.to_dict(**style)) + "\n",
|
|
566
|
+
encoding="utf-8")
|
|
567
|
+
elif kind == "svg":
|
|
568
|
+
path.write_text(self.svg(**style), encoding="utf-8")
|
|
569
|
+
elif kind in ("png", "pdf"):
|
|
570
|
+
export.convert(self.svg(**style), path, kind, scale)
|
|
571
|
+
else:
|
|
572
|
+
raise ValueError(f"unknown file type {path.suffix!r}: use .svg, "
|
|
573
|
+
".png, .pdf or .json")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _marker(x, y, r, shape):
|
|
577
|
+
"""Return an SVG element for a marker of area pi r^2 centred at (x, y)."""
|
|
578
|
+
if shape == "circle":
|
|
579
|
+
return f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{r:.2f}"/>'
|
|
580
|
+
if shape == "hexagon":
|
|
581
|
+
# Pointy-topped, so that hexagons nest on a hexagonal lattice.
|
|
582
|
+
R = r * np.sqrt(2 * np.pi / (3 * np.sqrt(3)))
|
|
583
|
+
theta = np.radians(np.arange(30, 360, 60))
|
|
584
|
+
pts = " ".join(f"{x + R * np.cos(t):.2f},{y + R * np.sin(t):.2f}"
|
|
585
|
+
for t in theta)
|
|
586
|
+
return f'<polygon points="{pts}"/>'
|
|
587
|
+
a = r * np.sqrt(np.pi)
|
|
588
|
+
return (f'<rect x="{x - a / 2:.2f}" y="{y - a / 2:.2f}" '
|
|
589
|
+
f'width="{a:.2f}" height="{a:.2f}"/>')
|
|
Binary file
|
|
Binary file
|
dotted_map/export.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Conversion of SVG documents to PNG and PDF images."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExportError(RuntimeError):
|
|
8
|
+
"""Raised when an SVG document cannot be converted."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def convert(svg, path, kind, scale=2):
|
|
12
|
+
"""Write an SVG document to a file as a PNG or PDF image.
|
|
13
|
+
|
|
14
|
+
Uses the CairoSVG package if it is installed, or else the rsvg-convert
|
|
15
|
+
program (part of librsvg).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
svg: the SVG document, as a string.
|
|
19
|
+
path: the file to write.
|
|
20
|
+
kind: "png" or "pdf".
|
|
21
|
+
scale: the size of a PNG image relative to the SVG image's size in
|
|
22
|
+
px. A PDF image is vector graphics, 0.75 pt for each px (at 96
|
|
23
|
+
px to the inch), whatever the scale.
|
|
24
|
+
"""
|
|
25
|
+
if kind not in ("png", "pdf"):
|
|
26
|
+
raise ValueError(f"unknown image type {kind!r}: use png or pdf")
|
|
27
|
+
if scale <= 0:
|
|
28
|
+
raise ValueError("scale must be positive")
|
|
29
|
+
zoom = scale if kind == "png" else 1
|
|
30
|
+
try:
|
|
31
|
+
import cairosvg
|
|
32
|
+
except (ImportError, OSError):
|
|
33
|
+
# OSError: CairoSVG is installed but cannot find the Cairo library.
|
|
34
|
+
cairosvg = None
|
|
35
|
+
if cairosvg is not None:
|
|
36
|
+
write = cairosvg.svg2png if kind == "png" else cairosvg.svg2pdf
|
|
37
|
+
write(bytestring=svg.encode(), write_to=str(path), scale=zoom)
|
|
38
|
+
return
|
|
39
|
+
program = shutil.which("rsvg-convert")
|
|
40
|
+
if program is None:
|
|
41
|
+
raise ExportError(
|
|
42
|
+
f"{kind.upper()} output needs CairoSVG "
|
|
43
|
+
"(pip install \"dotted-map[cairo]\") or rsvg-convert (part of "
|
|
44
|
+
"librsvg); or save the map as SVG")
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
[program, "-f", kind, "-z", str(zoom), "-o", str(path)],
|
|
47
|
+
input=svg.encode(), capture_output=True)
|
|
48
|
+
if result.returncode:
|
|
49
|
+
raise ExportError(f"rsvg-convert failed: "
|
|
50
|
+
f"{result.stderr.decode().strip()}")
|
dotted_map/py.typed
ADDED
|
File without changes
|
dotted_map/regions.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The land and country outlines of Natural Earth, bundled with dotted-map.
|
|
2
|
+
|
|
3
|
+
Natural Earth (https://www.naturalearthdata.com) is in the public domain.
|
|
4
|
+
The files are made from its 1:50m data by tools/make_data.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import difflib
|
|
8
|
+
import gzip
|
|
9
|
+
import json
|
|
10
|
+
import unicodedata
|
|
11
|
+
from functools import cache
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from typing import NamedTuple
|
|
14
|
+
|
|
15
|
+
import shapely
|
|
16
|
+
from shapely.geometry import shape
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Country(NamedTuple):
|
|
20
|
+
"""A country, or another area that Natural Earth treats as one."""
|
|
21
|
+
|
|
22
|
+
code: str #: ISO 3166-1 alpha-3 code, or Natural Earth's own if none.
|
|
23
|
+
iso_a2: str #: ISO 3166-1 alpha-2 code, or "" if none.
|
|
24
|
+
name: str #: Short English name, as in "United Kingdom".
|
|
25
|
+
continent: str #: Natural Earth's continent, as in "Europe".
|
|
26
|
+
subregion: str #: UN geoscheme subregion, as in "Northern Europe".
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read(name):
|
|
30
|
+
"""Return the features of a bundled GeoJSON file."""
|
|
31
|
+
data = (files("dotted_map") / "data" / f"{name}.geojson.gz").read_bytes()
|
|
32
|
+
return json.loads(gzip.decompress(data))["features"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _code(value):
|
|
36
|
+
return "" if value == "-99" else value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cache
|
|
40
|
+
def land():
|
|
41
|
+
"""Return the land, without its lakes, as a shapely geometry.
|
|
42
|
+
|
|
43
|
+
The outlines are those of Natural Earth's 1:50m land and lakes, which
|
|
44
|
+
show coastlines to within a few kilometres.
|
|
45
|
+
"""
|
|
46
|
+
geom = shapely.multipolygons([shape(f["geometry"])
|
|
47
|
+
for f in _read("land")])
|
|
48
|
+
shapely.prepare(geom)
|
|
49
|
+
return geom
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cache
|
|
53
|
+
def _countries():
|
|
54
|
+
"""Return a dict from each Natural Earth ADM0_A3 code to a tuple of the
|
|
55
|
+
country's properties and outline."""
|
|
56
|
+
return {f["properties"]["ADM0_A3"]: (f["properties"],
|
|
57
|
+
shape(f["geometry"]))
|
|
58
|
+
for f in _read("countries")}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cache
|
|
62
|
+
def _index():
|
|
63
|
+
"""Return a dict from each lower-case name or code to the ADM0_A3 codes
|
|
64
|
+
of the countries it names."""
|
|
65
|
+
index = {}
|
|
66
|
+
items = _countries().items()
|
|
67
|
+
for adm0, (p, _) in items:
|
|
68
|
+
for key in ("CONTINENT", "SUBREGION"):
|
|
69
|
+
index.setdefault(p[key].lower(), set()).add(adm0)
|
|
70
|
+
# Later entries replace earlier ones: countries' names and codes take
|
|
71
|
+
# precedence over the names of continents and subregions (as with
|
|
72
|
+
# "Antarctica"), and a code belongs to the country whose own code it is
|
|
73
|
+
# rather than to its territories (as with "AUS" for Australia, not for
|
|
74
|
+
# the Ashmore and Cartier Islands).
|
|
75
|
+
items = sorted(items, key=lambda item: item[0] == item[1][0]["ISO_A3_EH"])
|
|
76
|
+
for key in ("NAME_LONG", "ADMIN", "NAME", "ISO_A2_EH", "ISO_A3_EH",
|
|
77
|
+
"ADM0_A3"):
|
|
78
|
+
for adm0, (p, _) in items:
|
|
79
|
+
if value := _code(p[key]):
|
|
80
|
+
index[value.lower()] = {adm0}
|
|
81
|
+
return index
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def countries():
|
|
85
|
+
"""Return a list of the countries that can be named in a region, as
|
|
86
|
+
Country tuples, in alphabetical order of name."""
|
|
87
|
+
def own(code, adm0):
|
|
88
|
+
"""Return code if it names this country (and no other), else ""."""
|
|
89
|
+
return code if _index().get(code.lower()) == {adm0} else ""
|
|
90
|
+
|
|
91
|
+
out = []
|
|
92
|
+
for adm0, (p, _) in _countries().items():
|
|
93
|
+
out.append(Country(own(_code(p["ISO_A3_EH"]), adm0) or adm0,
|
|
94
|
+
own(_code(p["ISO_A2_EH"]), adm0), p["NAME"],
|
|
95
|
+
p["CONTINENT"], p["SUBREGION"]))
|
|
96
|
+
# Sort on the names without accents or case, so that Åland comes
|
|
97
|
+
# first and eSwatini among the Es.
|
|
98
|
+
return sorted(out, key=lambda c: unicodedata.normalize(
|
|
99
|
+
"NFKD", c.name).encode("ascii", "ignore").decode().casefold())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _lookup(name):
|
|
103
|
+
try:
|
|
104
|
+
return _index()[name.strip().lower()]
|
|
105
|
+
except KeyError:
|
|
106
|
+
names = [c.name for c in countries()]
|
|
107
|
+
names += sorted({c.continent for c in countries()}
|
|
108
|
+
| {c.subregion for c in countries()})
|
|
109
|
+
close = difflib.get_close_matches(name, names, n=3)
|
|
110
|
+
hint = f"; did you mean {' or '.join(close)}?" if close else ""
|
|
111
|
+
raise ValueError(f"unknown region {name!r}{hint}") from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def region(names):
|
|
115
|
+
"""Return the outline of one or more regions as a shapely geometry.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
names: a name, or a list of names. Each can be a country's ISO
|
|
119
|
+
3166-1 alpha-3 or alpha-2 code (as in "GBR" or "GB"), its name
|
|
120
|
+
in English (as in "United Kingdom"), or the name of a continent
|
|
121
|
+
or a UN geoscheme subregion (as in "Africa" or "Western
|
|
122
|
+
Europe"), in any case. The countries are listed by
|
|
123
|
+
`countries()`.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: for an unknown name.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(names, str):
|
|
129
|
+
names = [names]
|
|
130
|
+
codes = set()
|
|
131
|
+
for name in names:
|
|
132
|
+
codes |= _lookup(name)
|
|
133
|
+
return _union(frozenset(codes))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cache
|
|
137
|
+
def _union(codes):
|
|
138
|
+
geoms = [_countries()[code][1] for code in sorted(codes)]
|
|
139
|
+
geom = shapely.union_all(geoms) if len(geoms) > 1 else geoms[0]
|
|
140
|
+
shapely.prepare(geom)
|
|
141
|
+
return geom
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def read_geojson(path):
|
|
145
|
+
"""Return the union of the geometries in a GeoJSON file, which can hold
|
|
146
|
+
a FeatureCollection, a Feature or a bare geometry, with coordinates in
|
|
147
|
+
degrees of longitude and latitude."""
|
|
148
|
+
with open(path, encoding="utf-8") as f:
|
|
149
|
+
data = json.load(f)
|
|
150
|
+
if data.get("type") == "FeatureCollection":
|
|
151
|
+
geoms = [shape(f["geometry"]) for f in data["features"]
|
|
152
|
+
if f.get("geometry")]
|
|
153
|
+
elif data.get("type") == "Feature":
|
|
154
|
+
geoms = [shape(data["geometry"])]
|
|
155
|
+
else:
|
|
156
|
+
geoms = [shape(data)]
|
|
157
|
+
geom = shapely.union_all(geoms)
|
|
158
|
+
shapely.prepare(geom)
|
|
159
|
+
return geom
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dotted-map
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Draw maps on which land is a regular lattice of dots, as SVG, PNG or PDF images
|
|
5
|
+
Project-URL: Documentation, https://dotted-map.readthedocs.io
|
|
6
|
+
Project-URL: Repository, https://github.com/xnx/dotted-map
|
|
7
|
+
Project-URL: Issues, https://github.com/xnx/dotted-map/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/xnx/dotted-map/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Christian Hill
|
|
10
|
+
License-Expression: BSD-3-Clause
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cartography,dots,graphic design,halftone,map,svg
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: numpy>=1.24
|
|
20
|
+
Requires-Dist: pyproj>=3.6
|
|
21
|
+
Requires-Dist: shapely>=2.0
|
|
22
|
+
Provides-Extra: cairo
|
|
23
|
+
Requires-Dist: cairosvg>=2.7; extra == 'cairo'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# dotted-map
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/dotted-map/)
|
|
29
|
+
[](https://pypi.org/project/dotted-map/)
|
|
30
|
+
[](https://github.com/xnx/dotted-map/actions/workflows/tests.yml)
|
|
31
|
+
[](https://dotted-map.readthedocs.io/en/stable/)
|
|
32
|
+
|
|
33
|
+
Draw maps on which land is a regular lattice of dots, as SVG, PNG and PDF
|
|
34
|
+
images for graphic design and infographics.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
The maps are drawn in an equal-area projection, or any other that
|
|
39
|
+
[PROJ](https://proj.org) supports, with coastlines found from the fraction of
|
|
40
|
+
each dot's cell that is land. The land and country outlines of
|
|
41
|
+
[Natural Earth](https://www.naturalearthdata.com) are built in, so a country,
|
|
42
|
+
continent or subregion can be named to frame the map, to highlight it or to
|
|
43
|
+
draw it alone. The documentation is at https://dotted-map.readthedocs.io.
|
|
44
|
+
|
|
45
|
+
## Command line
|
|
46
|
+
|
|
47
|
+
```console
|
|
48
|
+
$ dotted-map -o world.svg
|
|
49
|
+
$ dotted-map --region France --highlight 'France=#0055a4' --colour '#c5cad3' -o france.png
|
|
50
|
+
$ dotted-map --region Italy --only Italy --shape hexagon --halftone -o italy.pdf
|
|
51
|
+
$ dotted-map --region GBR IRL --pin London=-0.13,51.51 --pin Dublin=-6.26,53.35 --arc London Dublin -o uk.svg
|
|
52
|
+
$ dotted-map --projection '+proj=ortho +lon_0=-30 +lat_0=30' --sea '#dde1e8' -o globe.svg
|
|
53
|
+
$ dotted-map --list-regions
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Python
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from dotted_map import DottedMap
|
|
60
|
+
|
|
61
|
+
m = DottedMap(region="Japan", width=600, spacing=8)
|
|
62
|
+
m.add_pin(139.69, 35.69) # Tokyo.
|
|
63
|
+
m.save("japan.svg", colour="#bc002d", sea="#dde1e8", halftone=True)
|
|
64
|
+
|
|
65
|
+
data = m.to_dict() # Every dot, for use elsewhere.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Colours and sizes can be functions of longitude and latitude, for maps of
|
|
69
|
+
data.
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```console
|
|
74
|
+
$ pip install dotted-map
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The dotted-map package needs Python 3.10 or later, NumPy, pyproj and Shapely.
|
|
78
|
+
PNG and PDF output also needs [CairoSVG](https://cairosvg.org)
|
|
79
|
+
(`pip install "dotted-map[cairo]"`) or the `rsvg-convert` program of
|
|
80
|
+
[librsvg](https://gitlab.gnome.org/GNOME/librsvg).
|
|
81
|
+
|
|
82
|
+
## Licence
|
|
83
|
+
|
|
84
|
+
BSD-3-Clause. The land and country outlines are made from Natural Earth's
|
|
85
|
+
1:50m data, which is in the public domain.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
dotted_map/__init__.py,sha256=MGnK4XXtxBlWA4ZvvFl2aPgeL97gR9yK7oAwdSm3tDU,592
|
|
2
|
+
dotted_map/__main__.py,sha256=_ugeMi3j-XFoI7IP7YEcARvJ2WOZFRfXRr1UmtzsY8o,87
|
|
3
|
+
dotted_map/cli.py,sha256=zW0peFRM8TnFGUjx9yb4Argn7qHgmJBDXJv2tvpULHs,12163
|
|
4
|
+
dotted_map/core.py,sha256=tjB0ZT5xx_mcrieGuWrUFmkLy0hA9wlimS9HPO5B2hA,27731
|
|
5
|
+
dotted_map/export.py,sha256=-E19kAVgXz4zrOhAWShYAdiJi-TgTIOV00aBQehiBlo,1847
|
|
6
|
+
dotted_map/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
dotted_map/regions.py,sha256=hAufFCqjJwyI78xthcNywtJ3Hc76Ky_k1FkLC4sRx-k,5616
|
|
8
|
+
dotted_map/data/countries.geojson.gz,sha256=GbQwLDeZpcXMwgE5zXf8UheSy57aq2G02YdzL37FjWc,573369
|
|
9
|
+
dotted_map/data/land.geojson.gz,sha256=0eEgI8L8Tqvq1Twp9jUZMpqDBNtkUr49e3SCJlyZ8og,440200
|
|
10
|
+
dotted_map-0.1.0.dist-info/METADATA,sha256=OvSbymJvBV18Ag4oqGVyCuiGE1zY-X1kwWziAAuQJh0,3530
|
|
11
|
+
dotted_map-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
dotted_map-0.1.0.dist-info/entry_points.txt,sha256=rZpaqg0Tm9g6_kGKzlMllvyex3yNz_gZwjbQ7mABe4E,51
|
|
13
|
+
dotted_map-0.1.0.dist-info/licenses/LICENSE,sha256=ATVszkWIu32IrI6wo8Sdxbr05TILannMDG-0wD_lNKQ,1501
|
|
14
|
+
dotted_map-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Christian Hill
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|