mapscii-py 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.
mapscii_py/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """A small Python port of MapSCII's vector-tile renderer."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .renderer import Renderer
6
+ from .rich_map import MapMarker, MapView
7
+ from .tiles import HttpTileSource
8
+
9
+ __all__ = [
10
+ "HttpTileSource",
11
+ "MapMarker",
12
+ "MapView",
13
+ "Renderer",
14
+ "__version__",
15
+ ]
mapscii_py/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
mapscii_py/canvas.py ADDED
@@ -0,0 +1,179 @@
1
+ import math
2
+ from typing import Iterable, List, Optional, Sequence, Tuple
3
+
4
+
5
+ BRAILLE_BITS = (
6
+ (0x01, 0x08),
7
+ (0x02, 0x10),
8
+ (0x04, 0x20),
9
+ (0x40, 0x80),
10
+ )
11
+ RESET = "\x1b[39;49m"
12
+
13
+
14
+ class BrailleBuffer:
15
+ def __init__(self, width: int, height: int, use_braille: bool = True) -> None:
16
+ self.width = max(2, width - width % 2)
17
+ self.height = max(4, height - height % 4)
18
+ self.columns = self.width // 2
19
+ self.rows = self.height // 4
20
+ self.use_braille = use_braille
21
+ self.global_background: Optional[int] = None
22
+ self.clear()
23
+
24
+ def clear(self) -> None:
25
+ size = self.columns * self.rows
26
+ self.pixels = bytearray(size)
27
+ self.characters: List[Optional[str]] = [None] * size
28
+ self.foreground: List[Optional[int]] = [None] * size
29
+ self.background: List[Optional[int]] = [None] * size
30
+
31
+ def _index(self, x: int, y: int) -> int:
32
+ return x // 2 + self.columns * (y // 4)
33
+
34
+ def set_pixel(self, x: int, y: int, color: Optional[int]) -> None:
35
+ if not (0 <= x < self.width and 0 <= y < self.height):
36
+ return
37
+ index = self._index(x, y)
38
+ self.pixels[index] |= BRAILLE_BITS[y & 3][x & 1]
39
+ if color is not None:
40
+ self.foreground[index] = color
41
+
42
+ def set_character(self, character: str, x: int, y: int, color: Optional[int]) -> None:
43
+ if not (0 <= x < self.width and 0 <= y < self.height):
44
+ return
45
+ index = self._index(x, y)
46
+ self.characters[index] = character
47
+ if color is not None:
48
+ self.foreground[index] = color
49
+
50
+ def write_text(self, text: str, x: int, y: int, color: Optional[int], center: bool = False) -> None:
51
+ if center:
52
+ x -= len(text)
53
+ for offset, character in enumerate(text):
54
+ self.set_character(character, x + offset * 2, y, color)
55
+
56
+ def set_background(self, color: Optional[int]) -> None:
57
+ self.global_background = color
58
+
59
+ def _color_sequence(self, foreground: Optional[int], background: Optional[int]) -> str:
60
+ background = background if background is not None else self.global_background
61
+ if foreground is not None and background is not None:
62
+ return "\x1b[38;5;{};48;5;{}m".format(foreground, background)
63
+ if foreground is not None:
64
+ return "\x1b[49;38;5;{}m".format(foreground)
65
+ if background is not None:
66
+ return "\x1b[39;48;5;{}m".format(background)
67
+ return RESET
68
+
69
+ def frame(self) -> str:
70
+ output: List[str] = []
71
+ current_color: Optional[str] = None
72
+ for row in range(self.rows):
73
+ skip = 0
74
+ for column in range(self.columns):
75
+ index = row * self.columns + column
76
+ color = self._color_sequence(self.foreground[index], self.background[index])
77
+ if color != current_color:
78
+ output.append(color)
79
+ current_color = color
80
+
81
+ character = self.characters[index]
82
+ if character is not None:
83
+ output.append(character)
84
+ skip += max(0, len(character) - 1)
85
+ elif skip:
86
+ skip -= 1
87
+ elif self.use_braille:
88
+ output.append(chr(0x2800 + self.pixels[index]))
89
+ else:
90
+ output.append(self._block_character(self.pixels[index]))
91
+ output.append(RESET + "\n")
92
+ return "".join(output)
93
+
94
+ @staticmethod
95
+ def _block_character(mask: int) -> str:
96
+ if mask == 0:
97
+ return " "
98
+ upper = mask & (0x01 | 0x02 | 0x08 | 0x10)
99
+ lower = mask & (0x04 | 0x40 | 0x20 | 0x80)
100
+ if upper and lower:
101
+ return "█"
102
+ if upper:
103
+ return "▀"
104
+ return "▄"
105
+
106
+
107
+ class Canvas:
108
+ def __init__(self, width: int, height: int, use_braille: bool = True) -> None:
109
+ self.width = width
110
+ self.height = height
111
+ self.buffer = BrailleBuffer(width, height, use_braille)
112
+
113
+ def clear(self) -> None:
114
+ self.buffer.clear()
115
+
116
+ def frame(self) -> str:
117
+ return self.buffer.frame()
118
+
119
+ def set_background(self, color: Optional[int]) -> None:
120
+ self.buffer.set_background(color)
121
+
122
+ def text(self, text: str, x: int, y: int, color: Optional[int], center: bool = False) -> None:
123
+ self.buffer.write_text(text, x, y, color, center)
124
+
125
+ def polyline(self, points: Sequence[Tuple[int, int]], color: Optional[int], width: int = 1) -> None:
126
+ for start, end in zip(points, points[1:]):
127
+ self.line(start, end, color, width)
128
+
129
+ def line(self, start: Tuple[int, int], end: Tuple[int, int], color: Optional[int], width: int = 1) -> None:
130
+ x0, y0 = start
131
+ x1, y1 = end
132
+ radius = max(0, int(round(width)) - 1) // 2
133
+ for x, y in self._bresenham(x0, y0, x1, y1):
134
+ for dy in range(-radius, radius + 1):
135
+ for dx in range(-radius, radius + 1):
136
+ self.buffer.set_pixel(x + dx, y + dy, color)
137
+
138
+ def polygon(self, rings: Sequence[Sequence[Tuple[int, int]]], color: Optional[int]) -> None:
139
+ edges = []
140
+ for ring in rings:
141
+ if len(ring) < 3:
142
+ continue
143
+ closed = list(ring)
144
+ if closed[0] != closed[-1]:
145
+ closed.append(closed[0])
146
+ edges.extend(zip(closed, closed[1:]))
147
+
148
+ for y in range(self.height):
149
+ intersections: List[float] = []
150
+ scan_y = y + 0.5
151
+ for (x1, y1), (x2, y2) in edges:
152
+ if y1 == y2:
153
+ continue
154
+ if min(y1, y2) <= scan_y < max(y1, y2):
155
+ intersections.append(x1 + (scan_y - y1) * (x2 - x1) / (y2 - y1))
156
+ intersections.sort()
157
+ for left, right in zip(intersections[0::2], intersections[1::2]):
158
+ for x in range(max(0, math.ceil(left)), min(self.width - 1, math.floor(right)) + 1):
159
+ self.buffer.set_pixel(x, y, color)
160
+
161
+ @staticmethod
162
+ def _bresenham(x0: int, y0: int, x1: int, y1: int) -> Iterable[Tuple[int, int]]:
163
+ dx = abs(x1 - x0)
164
+ sx = 1 if x0 < x1 else -1
165
+ dy = -abs(y1 - y0)
166
+ sy = 1 if y0 < y1 else -1
167
+ error = dx + dy
168
+ while True:
169
+ yield x0, y0
170
+ if x0 == x1 and y0 == y1:
171
+ return
172
+ twice_error = 2 * error
173
+ if twice_error >= dy:
174
+ error += dy
175
+ x0 += sx
176
+ if twice_error <= dx:
177
+ error += dx
178
+ y0 += sy
179
+
mapscii_py/cli.py ADDED
@@ -0,0 +1,120 @@
1
+ import argparse
2
+ import math
3
+ import os
4
+ import select
5
+ import shutil
6
+ import sys
7
+ import termios
8
+ import tty
9
+ from pathlib import Path
10
+ from typing import Optional, Sequence, Tuple
11
+
12
+ from .renderer import Renderer
13
+ from .style import Styler
14
+ from .tiles import HttpTileSource
15
+ from .utils import base_zoom, lonlat_to_tile, normalize, tile_size_at_zoom, tile_to_lonlat
16
+
17
+
18
+ DEFAULT_SOURCE = "http://mapscii.me/"
19
+ DEFAULT_LATITUDE = 52.51298
20
+ DEFAULT_LONGITUDE = 13.42012
21
+ DEFAULT_STYLE = Path(__file__).resolve().parent / "styles" / "dark.json"
22
+
23
+
24
+ def parse_arguments(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
25
+ parser = argparse.ArgumentParser(description="MapSCII's vector-tile renderer ported to Python")
26
+ parser.add_argument("--latitude", "--lat", type=float, default=DEFAULT_LATITUDE)
27
+ parser.add_argument("--longitude", "--lon", type=float, default=DEFAULT_LONGITUDE)
28
+ parser.add_argument("--zoom", "-z", type=float)
29
+ parser.add_argument("--width", "-w", type=int, help="map width in terminal columns")
30
+ parser.add_argument("--height", type=int, help="map height in terminal rows")
31
+ parser.add_argument("--source", default=DEFAULT_SOURCE)
32
+ parser.add_argument("--style", type=Path, default=DEFAULT_STYLE)
33
+ parser.add_argument("--ascii", action="store_true", help="use block characters instead of Braille")
34
+ parser.add_argument("--no-cache", action="store_true")
35
+ parser.add_argument("--cache-directory", type=Path)
36
+ parser.add_argument("--once", action="store_true", help="render one frame and exit")
37
+ return parser.parse_args(argv)
38
+
39
+
40
+ class Application:
41
+ def __init__(self, arguments: argparse.Namespace) -> None:
42
+ columns, rows = shutil.get_terminal_size((80, 24))
43
+ self.columns = arguments.width or columns
44
+ self.rows = arguments.height or max(2, rows - 1)
45
+ self.latitude = arguments.latitude
46
+ self.longitude = arguments.longitude
47
+ pixel_width = self.columns * 2
48
+ pixel_height = self.rows * 4
49
+ self.minimum_zoom = 4 - math.log2(4096 / pixel_width)
50
+ self.zoom = arguments.zoom if arguments.zoom is not None else self.minimum_zoom
51
+ self.zoom = max(self.minimum_zoom, min(18.0, self.zoom))
52
+
53
+ styler = Styler.from_file(arguments.style)
54
+ source = HttpTileSource(
55
+ arguments.source,
56
+ styler,
57
+ cache_directory=arguments.cache_directory,
58
+ persist=not arguments.no_cache,
59
+ )
60
+ self.renderer = Renderer(source, styler, pixel_width, pixel_height, not arguments.ascii)
61
+
62
+ def frame(self) -> str:
63
+ return self.renderer.draw(self.latitude, self.longitude, self.zoom)
64
+
65
+ def draw(self, clear: bool = True) -> None:
66
+ prefix = "\x1b[2J\x1b[H" if clear else ""
67
+ footer = "center: {:.3f}, {:.3f} zoom: {:.2f} arrows/hjkl move a/z zoom q quit © OSM\n".format(
68
+ self.latitude, self.longitude, self.zoom
69
+ )
70
+ sys.stdout.write(prefix + self.frame() + "\x1b[0m" + footer)
71
+ sys.stdout.flush()
72
+
73
+ def move(self, dx: float, dy: float) -> None:
74
+ z = base_zoom(self.zoom)
75
+ center_x, center_y = lonlat_to_tile(self.longitude, self.latitude, z)
76
+ size = tile_size_at_zoom(self.zoom)
77
+ lon, lat = tile_to_lonlat(center_x + dx / size, center_y + dy / size, z)
78
+ self.longitude, self.latitude = normalize(lon, lat)
79
+
80
+ def run(self) -> None:
81
+ old_settings = termios.tcgetattr(sys.stdin.fileno())
82
+ try:
83
+ tty.setcbreak(sys.stdin.fileno())
84
+ self.draw()
85
+ while True:
86
+ key = os.read(sys.stdin.fileno(), 1)
87
+ if key == b"\x1b":
88
+ while select.select([sys.stdin], [], [], 0.01)[0]:
89
+ key += os.read(sys.stdin.fileno(), 1)
90
+ if key in (b"q", b"Q"):
91
+ return
92
+ if key in (b"a",):
93
+ self.zoom = min(18.0, self.zoom + 0.2)
94
+ elif key in (b"z", b"y"):
95
+ self.zoom = max(self.minimum_zoom, self.zoom - 0.2)
96
+ elif key in (b"h", b"\x1b[D"):
97
+ self.move(-16, 0)
98
+ elif key in (b"l", b"\x1b[C"):
99
+ self.move(16, 0)
100
+ elif key in (b"k", b"\x1b[A"):
101
+ self.move(0, -12)
102
+ elif key in (b"j", b"\x1b[B"):
103
+ self.move(0, 12)
104
+ else:
105
+ continue
106
+ self.draw()
107
+ finally:
108
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_settings)
109
+ sys.stdout.write("\x1b[0m\n")
110
+
111
+
112
+ def main(argv: Optional[Sequence[str]] = None) -> None:
113
+ arguments = parse_arguments(argv)
114
+ application = Application(arguments)
115
+ if arguments.once:
116
+ application.draw(clear=False)
117
+ else:
118
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
119
+ raise SystemExit("interactive mode requires a TTY; use --once for redirected output")
120
+ application.run()
mapscii_py/py.typed ADDED
@@ -0,0 +1 @@
1
+
mapscii_py/renderer.py ADDED
@@ -0,0 +1,164 @@
1
+ import math
2
+ from dataclasses import dataclass
3
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
4
+
5
+ from .canvas import Canvas
6
+ from .style import Styler
7
+ from .tiles import Feature, HttpTileSource, Tile
8
+ from .utils import base_zoom, lonlat_to_tile, tile_size_at_zoom, xterm_color
9
+
10
+
11
+ DRAW_ORDER_LOW = ("admin", "water", "country_label", "marine_label")
12
+ DRAW_ORDER = (
13
+ "landuse", "water", "marine_label", "building", "road", "admin",
14
+ "country_label", "state_label", "water_label", "place_label",
15
+ "rail_station_label", "poi_label", "road_label", "housenum_label",
16
+ )
17
+
18
+
19
+ @dataclass
20
+ class VisibleTile:
21
+ xyz: Tuple[int, int, int]
22
+ zoom: float
23
+ position: Tuple[float, float]
24
+ size: float
25
+ data: Optional[Tile] = None
26
+
27
+
28
+ class LabelBuffer:
29
+ def __init__(self) -> None:
30
+ self.areas: List[Tuple[float, float, float, float]] = []
31
+
32
+ def clear(self) -> None:
33
+ self.areas.clear()
34
+
35
+ def reserve(self, text: str, x: int, y: int, margin: int = 5) -> bool:
36
+ column, row = x // 2, y // 4
37
+ area = (column - margin, row - margin / 2, column + len(text) + margin, row + margin / 2)
38
+ if any(self._intersects(area, existing) for existing in self.areas):
39
+ return False
40
+ self.areas.append(area)
41
+ return True
42
+
43
+ @staticmethod
44
+ def _intersects(left: Tuple[float, ...], right: Tuple[float, ...]) -> bool:
45
+ return not (left[2] < right[0] or left[0] > right[2] or left[3] < right[1] or left[1] > right[3])
46
+
47
+
48
+ class Renderer:
49
+ def __init__(
50
+ self,
51
+ tile_source: HttpTileSource,
52
+ styler: Styler,
53
+ width: int,
54
+ height: int,
55
+ use_braille: bool = True,
56
+ ) -> None:
57
+ self.tile_source = tile_source
58
+ self.styler = styler
59
+ self.canvas = Canvas(width, height, use_braille)
60
+ self.width = self.canvas.buffer.width
61
+ self.height = self.canvas.buffer.height
62
+ self.labels = LabelBuffer()
63
+ self.seen_labels: Set[str] = set()
64
+
65
+ def draw(self, latitude: float, longitude: float, zoom: float) -> str:
66
+ self.canvas.clear()
67
+ self.labels.clear()
68
+ self.seen_labels.clear()
69
+ background = self.styler.background()
70
+ self.canvas.set_background(xterm_color(background) if background else None)
71
+
72
+ tiles = self.visible_tiles(latitude, longitude, zoom)
73
+ for tile in tiles:
74
+ tile.data = self.tile_source.get_tile(*tile.xyz)
75
+ self._render_tiles(tiles)
76
+ return self.canvas.frame()
77
+
78
+ def visible_tiles(self, latitude: float, longitude: float, zoom: float) -> List[VisibleTile]:
79
+ z = base_zoom(zoom)
80
+ center_x, center_y = lonlat_to_tile(longitude, latitude, z)
81
+ tile_size = tile_size_at_zoom(zoom)
82
+ radius_x = math.ceil(self.width / tile_size / 2) + 1
83
+ radius_y = math.ceil(self.height / tile_size / 2) + 1
84
+ grid_size = 2 ** z
85
+ visible: List[VisibleTile] = []
86
+
87
+ for raw_y in range(math.floor(center_y) - radius_y, math.floor(center_y) + radius_y + 1):
88
+ for raw_x in range(math.floor(center_x) - radius_x, math.floor(center_x) + radius_x + 1):
89
+ position_x = self.width / 2 - (center_x - raw_x) * tile_size
90
+ position_y = self.height / 2 - (center_y - raw_y) * tile_size
91
+ if raw_y < 0 or raw_y >= grid_size:
92
+ continue
93
+ if position_x + tile_size < 0 or position_y + tile_size < 0:
94
+ continue
95
+ if position_x > self.width or position_y > self.height:
96
+ continue
97
+ visible.append(VisibleTile(
98
+ xyz=(z, raw_x % grid_size, raw_y),
99
+ zoom=zoom,
100
+ position=(position_x, position_y),
101
+ size=tile_size,
102
+ ))
103
+ return visible
104
+
105
+ def _render_tiles(self, tiles: Sequence[VisibleTile]) -> None:
106
+ draw_order = DRAW_ORDER_LOW if tiles and tiles[0].zoom < 2 else DRAW_ORDER
107
+ labels: List[Tuple[VisibleTile, Feature, int]] = []
108
+ for layer_name in draw_order:
109
+ for tile in tiles:
110
+ if tile.data is None or layer_name not in tile.data.layers:
111
+ continue
112
+ extent, features = tile.data.layers[layer_name]
113
+ for feature in features:
114
+ if feature.kind == "symbol":
115
+ labels.append((tile, feature, extent))
116
+ else:
117
+ self._draw_feature(tile, feature, extent)
118
+
119
+ for tile, feature, extent in sorted(labels, key=lambda item: item[1].sort):
120
+ self._draw_feature(tile, feature, extent)
121
+
122
+ def _draw_feature(self, tile: VisibleTile, feature: Feature, extent: int) -> None:
123
+ if feature.min_zoom is not None and tile.zoom < feature.min_zoom:
124
+ return
125
+ if feature.max_zoom is not None and tile.zoom > feature.max_zoom:
126
+ return
127
+ scale = extent / tile.size
128
+
129
+ if feature.kind == "line":
130
+ points = self._scale_points(tile, feature.geometry, scale)
131
+ if len(points) >= 2:
132
+ self.canvas.polyline(points, feature.color, feature.width)
133
+ elif feature.kind == "fill":
134
+ rings = [self._scale_points(tile, ring, scale) for ring in feature.geometry]
135
+ self.canvas.polygon([ring for ring in rings if len(ring) >= 3], feature.color)
136
+ elif feature.kind == "symbol":
137
+ text = feature.label or "◉"
138
+ if text in self.seen_labels and feature.label:
139
+ return
140
+ for x, y in self._scale_points(tile, feature.geometry, scale):
141
+ start_x = x - len(text)
142
+ if self.labels.reserve(text, start_x, y):
143
+ self.canvas.text(text, start_x, y, feature.color)
144
+ self.seen_labels.add(text)
145
+ return
146
+
147
+ def _scale_points(
148
+ self,
149
+ tile: VisibleTile,
150
+ points: Sequence[Tuple[float, float]],
151
+ scale: float,
152
+ ) -> List[Tuple[int, int]]:
153
+ output: List[Tuple[int, int]] = []
154
+ previous: Optional[Tuple[int, int]] = None
155
+ for source_x, source_y in points:
156
+ point = (
157
+ math.floor(tile.position[0] + source_x / scale),
158
+ math.floor(tile.position[1] + source_y / scale),
159
+ )
160
+ if point != previous:
161
+ output.append(point)
162
+ previous = point
163
+ return output
164
+