mapscii-py 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Michael Straßburger
4
+ Copyright (c) 2019 The MapSCII authors
5
+ Copyright (c) 2026 David Nemeth-Csoka HA7NCS
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: mapscii-py
3
+ Version: 0.1.0
4
+ Summary: A Python port of MapSCII's vector-tile terminal renderer
5
+ Author-email: Dávid Németh-Csóka <encse@csokavar.hu>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2017 Michael Straßburger
9
+ Copyright (c) 2019 The MapSCII authors
10
+ Copyright (c) 2026 David Nemeth-Csoka HA7NCS
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Project-URL: Source, https://github.com/encse/adbs-tui/tree/main/mapscii
31
+ Project-URL: Issues, https://github.com/encse/adbs-tui/issues
32
+ Keywords: mapscii,terminal,map,vector-tiles,rich
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Environment :: Console
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3 :: Only
39
+ Classifier: Topic :: Terminals
40
+ Requires-Python: >=3.9
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: mapbox-vector-tile<3,>=2.2
44
+ Requires-Dist: rich<15,>=13
45
+ Dynamic: license-file
46
+
47
+ # mapscii-py
48
+
49
+ A Python port of [MapSCII](https://github.com/rastapasta/mapscii) for rendering
50
+ OpenStreetMap vector tiles in terminals. It provides both an interactive CLI
51
+ and a Rich-compatible `MapView` renderable for embedding maps in terminal
52
+ applications.
53
+
54
+ ## Installation
55
+
56
+ ```sh
57
+ python3 -m pip install mapscii-py
58
+ ```
59
+
60
+ The distribution name is `mapscii-py`; the Python import package is
61
+ `mapscii_py`.
62
+
63
+ ## Command line
64
+
65
+ Start the interactive map:
66
+
67
+ ```sh
68
+ mapscii
69
+ ```
70
+
71
+ Arrow keys or `hjkl` move the map, `a` zooms in, `z` zooms out, and `q` exits.
72
+ Render one non-interactive frame with:
73
+
74
+ ```sh
75
+ mapscii --once --width 80 --height 24
76
+ ```
77
+
78
+ The module entry point is equivalent:
79
+
80
+ ```sh
81
+ python3 -m mapscii_py --once
82
+ ```
83
+
84
+ ## Rich integration
85
+
86
+ `MapView` adapts its width to the surrounding Rich layout and keeps a fixed
87
+ number of terminal rows:
88
+
89
+ ```python
90
+ from rich.console import Console
91
+
92
+ from mapscii_py import MapView
93
+
94
+ map_view = MapView(
95
+ latitude=47.4979,
96
+ longitude=19.0402,
97
+ zoom=8,
98
+ height=16,
99
+ )
100
+
101
+ Console().print(
102
+ map_view.panel(title="Budapest airspace")
103
+ )
104
+ ```
105
+
106
+ Markers can be overlaid without modifying the underlying map tiles:
107
+
108
+ ```python
109
+ from mapscii_py import MapMarker
110
+
111
+ map_view.set_markers([
112
+ MapMarker(
113
+ latitude=47.4979,
114
+ longitude=19.0402,
115
+ label="Budapest",
116
+ symbol="●",
117
+ ),
118
+ ])
119
+ ```
120
+
121
+ ## Development and publishing
122
+
123
+ Build and validate both the source distribution and wheel:
124
+
125
+ ```sh
126
+ python3 -m pip install build twine
127
+ python3 -m build
128
+ python3 -m twine check dist/*
129
+ ```
130
+
131
+ Upload a release to PyPI:
132
+
133
+ ```sh
134
+ python3 -m twine upload dist/*
135
+ ```
136
+
137
+ Increase the version in `pyproject.toml` and `mapscii_py/__init__.py` before
138
+ building a new release. PyPI does not allow an existing release file or
139
+ version to be replaced.
140
+
141
+ ## Attribution and license
142
+
143
+ This implementation is derived from MapSCII, originally created by Michael
144
+ Straßburger and the MapSCII contributors. Map data is © OpenStreetMap
145
+ contributors.
146
+
147
+ The project is distributed under the MIT License; see `LICENSE`.
@@ -0,0 +1,101 @@
1
+ # mapscii-py
2
+
3
+ A Python port of [MapSCII](https://github.com/rastapasta/mapscii) for rendering
4
+ OpenStreetMap vector tiles in terminals. It provides both an interactive CLI
5
+ and a Rich-compatible `MapView` renderable for embedding maps in terminal
6
+ applications.
7
+
8
+ ## Installation
9
+
10
+ ```sh
11
+ python3 -m pip install mapscii-py
12
+ ```
13
+
14
+ The distribution name is `mapscii-py`; the Python import package is
15
+ `mapscii_py`.
16
+
17
+ ## Command line
18
+
19
+ Start the interactive map:
20
+
21
+ ```sh
22
+ mapscii
23
+ ```
24
+
25
+ Arrow keys or `hjkl` move the map, `a` zooms in, `z` zooms out, and `q` exits.
26
+ Render one non-interactive frame with:
27
+
28
+ ```sh
29
+ mapscii --once --width 80 --height 24
30
+ ```
31
+
32
+ The module entry point is equivalent:
33
+
34
+ ```sh
35
+ python3 -m mapscii_py --once
36
+ ```
37
+
38
+ ## Rich integration
39
+
40
+ `MapView` adapts its width to the surrounding Rich layout and keeps a fixed
41
+ number of terminal rows:
42
+
43
+ ```python
44
+ from rich.console import Console
45
+
46
+ from mapscii_py import MapView
47
+
48
+ map_view = MapView(
49
+ latitude=47.4979,
50
+ longitude=19.0402,
51
+ zoom=8,
52
+ height=16,
53
+ )
54
+
55
+ Console().print(
56
+ map_view.panel(title="Budapest airspace")
57
+ )
58
+ ```
59
+
60
+ Markers can be overlaid without modifying the underlying map tiles:
61
+
62
+ ```python
63
+ from mapscii_py import MapMarker
64
+
65
+ map_view.set_markers([
66
+ MapMarker(
67
+ latitude=47.4979,
68
+ longitude=19.0402,
69
+ label="Budapest",
70
+ symbol="●",
71
+ ),
72
+ ])
73
+ ```
74
+
75
+ ## Development and publishing
76
+
77
+ Build and validate both the source distribution and wheel:
78
+
79
+ ```sh
80
+ python3 -m pip install build twine
81
+ python3 -m build
82
+ python3 -m twine check dist/*
83
+ ```
84
+
85
+ Upload a release to PyPI:
86
+
87
+ ```sh
88
+ python3 -m twine upload dist/*
89
+ ```
90
+
91
+ Increase the version in `pyproject.toml` and `mapscii_py/__init__.py` before
92
+ building a new release. PyPI does not allow an existing release file or
93
+ version to be replaced.
94
+
95
+ ## Attribution and license
96
+
97
+ This implementation is derived from MapSCII, originally created by Michael
98
+ Straßburger and the MapSCII contributors. Map data is © OpenStreetMap
99
+ contributors.
100
+
101
+ The project is distributed under the MIT License; see `LICENSE`.
@@ -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
+ ]
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
@@ -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
+
@@ -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()
@@ -0,0 +1 @@
1
+