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 +15 -0
- mapscii_py/__main__.py +6 -0
- mapscii_py/canvas.py +179 -0
- mapscii_py/cli.py +120 -0
- mapscii_py/py.typed +1 -0
- mapscii_py/renderer.py +164 -0
- mapscii_py/rich_map.py +332 -0
- mapscii_py/style.py +117 -0
- mapscii_py/styles/bright.json +2191 -0
- mapscii_py/styles/dark.json +1560 -0
- mapscii_py/tiles.py +198 -0
- mapscii_py/utils.py +78 -0
- mapscii_py-0.1.0.dist-info/METADATA +147 -0
- mapscii_py-0.1.0.dist-info/RECORD +18 -0
- mapscii_py-0.1.0.dist-info/WHEEL +5 -0
- mapscii_py-0.1.0.dist-info/entry_points.txt +2 -0
- mapscii_py-0.1.0.dist-info/licenses/LICENSE +23 -0
- mapscii_py-0.1.0.dist-info/top_level.txt +1 -0
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
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
|
+
|