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 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
@@ -0,0 +1,5 @@
1
+ """Support `python -m dotted_map`."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
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
+ [![PyPI](https://img.shields.io/pypi/v/dotted-map)](https://pypi.org/project/dotted-map/)
29
+ [![Python versions](https://img.shields.io/pypi/pyversions/dotted-map)](https://pypi.org/project/dotted-map/)
30
+ [![Tests](https://github.com/xnx/dotted-map/actions/workflows/tests.yml/badge.svg)](https://github.com/xnx/dotted-map/actions/workflows/tests.yml)
31
+ [![Documentation](https://app.readthedocs.org/projects/dotted-map/badge/?version=stable)](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
+ ![A dotted map of the world, with eight cities marked in orange and joined by curved routes](https://raw.githubusercontent.com/xnx/dotted-map/main/docs/_static/world.png)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dotted-map = dotted_map.cli:main
@@ -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.