adafruit-circuitpython-maptiles 1.0.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.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: adafruit-circuitpython-maptiles
3
+ Version: 1.0.0
4
+ Summary: Download and display map tiles from XYZ tile servers such as Open Street Maps. Tiles are cached on SD card.
5
+ Author-email: Adafruit Industries <circuitpython@adafruit.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/adafruit/Adafruit_CircuitPython_MapTiles
8
+ Keywords: adafruit,blinka,circuitpython,micropython,maptiles,slippy,map,tiles,maptiles,maptile,slippymap,openstreetmap,osm
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Software Development :: Libraries
11
+ Classifier: Topic :: Software Development :: Embedded Systems
12
+ Classifier: Topic :: System :: Hardware
13
+ Classifier: Programming Language :: Python :: 3
14
+ Description-Content-Type: text/x-rst
15
+ License-File: LICENSE
16
+ Requires-Dist: Adafruit-Blinka
17
+ Requires-Dist: adafruit-circuitpython-requests
18
+ Requires-Dist: adafruit-circuitpython-connectionmanager
19
+ Provides-Extra: optional
20
+ Requires-Dist: adafruit-circuitpython-esp32spi; extra == "optional"
21
+ Dynamic: license-file
22
+
23
+ Introduction
24
+ ============
25
+
26
+
27
+ .. image:: https://readthedocs.org/projects/adafruit-circuitpython-maptiles/badge/?version=latest
28
+ :target: https://docs.circuitpython.org/projects/maptiles/en/latest/
29
+ :alt: Documentation Status
30
+
31
+
32
+ .. image:: https://raw.githubusercontent.com/adafruit/Adafruit_CircuitPython_Bundle/main/badges/adafruit_discord.svg
33
+ :target: https://adafru.it/discord
34
+ :alt: Discord
35
+
36
+
37
+ .. image:: https://github.com/adafruit/Adafruit_CircuitPython_MapTiles/workflows/Build%20CI/badge.svg
38
+ :target: https://github.com/adafruit/Adafruit_CircuitPython_MapTiles/actions
39
+ :alt: Build Status
40
+
41
+
42
+ .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
43
+ :target: https://github.com/astral-sh/ruff
44
+ :alt: Code Style: Ruff
45
+
46
+ Slippy-map (OpenStreetMap style) tiles for CircuitPython displays: a map
47
+ widget, and a tile cache on the SD card that every program using this
48
+ library shares.
49
+
50
+
51
+ Dependencies
52
+ =============
53
+ This driver depends on:
54
+
55
+ * `Adafruit CircuitPython <https://github.com/adafruit/circuitpython>`_
56
+
57
+ Please ensure all dependencies are available on the CircuitPython filesystem.
58
+ This is easily achieved by downloading
59
+ `the Adafruit library and driver bundle <https://circuitpython.org/libraries>`_
60
+ or individual libraries can be installed using
61
+ `circup <https://github.com/adafruit/circup>`_.
62
+
63
+
64
+ `Adafruit Fruit Jam <https://www.adafruit.com/product/6200>`_
65
+
66
+ Installing from PyPI
67
+ =====================
68
+
69
+ On supported GNU/Linux systems like the Raspberry Pi, you can install the driver locally `from
70
+ PyPI <https://pypi.org/project/adafruit-circuitpython-maptiles/>`_.
71
+ To install for current user:
72
+
73
+ .. code-block:: shell
74
+
75
+ pip3 install adafruit-circuitpython-maptiles
76
+
77
+ To install system-wide (this may be required in some cases):
78
+
79
+ .. code-block:: shell
80
+
81
+ sudo pip3 install adafruit-circuitpython-maptiles
82
+
83
+ To install in a virtual environment in your current project:
84
+
85
+ .. code-block:: shell
86
+
87
+ mkdir project-name && cd project-name
88
+ python3 -m venv .venv
89
+ source .env/bin/activate
90
+ pip3 install adafruit-circuitpython-maptiles
91
+
92
+ Installing to a Connected CircuitPython Device with Circup
93
+ ==========================================================
94
+
95
+ Make sure that you have ``circup`` installed in your Python environment.
96
+ Install it with the following command if necessary:
97
+
98
+ .. code-block:: shell
99
+
100
+ pip3 install circup
101
+
102
+ With ``circup`` installed and your CircuitPython device connected use the
103
+ following command to install:
104
+
105
+ .. code-block:: shell
106
+
107
+ circup install adafruit_maptiles
108
+
109
+ Or the following command to update an existing version:
110
+
111
+ .. code-block:: shell
112
+
113
+ circup update
114
+
115
+ Usage Example
116
+ =============
117
+
118
+ .. code-block:: python
119
+
120
+ import sys
121
+
122
+ import supervisor
123
+ from adafruit_fruitjam.peripherals import request_display_config
124
+
125
+ from adafruit_maptiles.cache import TileCache
126
+ from adafruit_maptiles.map_view import MapView
127
+
128
+ CENTRE = (40.7128, -74.0060) # (lat, lon)
129
+ ZOOM = 12
130
+
131
+ request_display_config(320, 240, color_depth=8)
132
+ display = supervisor.runtime.display
133
+ display.auto_refresh = False
134
+
135
+ # Name your program in the User-Agent: the OpenStreetMap tile servers ask for it.
136
+ cache = TileCache(user_agent="maptiles_simpletest/1.0 CircuitPython")
137
+ view = MapView(display.width, display.height, CENTRE, ZOOM, cache=cache)
138
+ display.root_group = view
139
+
140
+
141
+ def show():
142
+ view.update(progress=lambda done, total: print(f"tile {done + 1} of {total}"))
143
+ if view.net_error:
144
+ print("Download failed:", view.net_error)
145
+ view.redraw() # the tiles and the scale bar and credit; draw markers before dirty()
146
+ lat, lon = view.centre
147
+ x, y = view.xy(lat, lon)
148
+ view.cross(x, y)
149
+ view.dirty()
150
+ display.refresh()
151
+
152
+
153
+ show()
154
+ while True:
155
+ if supervisor.runtime.serial_bytes_available:
156
+ key = sys.stdin.read(1)
157
+ if key in "wasd":
158
+ view.pan(*{"w": (0, -0.25), "a": (-0.25, 0), "s": (0, 0.25), "d": (0.25, 0)}[key])
159
+ elif key in "+=":
160
+ view.zoom_in()
161
+ elif key == "-":
162
+ view.zoom_out()
163
+ else:
164
+ continue
165
+ show()
166
+
167
+
168
+ Documentation
169
+ =============
170
+ API documentation for this library can be found on `Read the Docs <https://docs.circuitpython.org/projects/maptiles/en/latest/>`_.
171
+
172
+ For information on building library documentation, please check out
173
+ `this guide <https://learn.adafruit.com/creating-and-sharing-a-circuitpython-library/sharing-our-docs-on-readthedocs#sphinx-5-1>`_.
174
+
175
+ Contributing
176
+ ============
177
+
178
+ Contributions are welcome! Please read our `Code of Conduct
179
+ <https://github.com/adafruit/Adafruit_CircuitPython_MapTiles/blob/HEAD/CODE_OF_CONDUCT.md>`_
180
+ before contributing to help this project stay welcoming.
@@ -0,0 +1,9 @@
1
+ adafruit_circuitpython_maptiles-1.0.0.dist-info/licenses/LICENSE,sha256=IrDmGxfX4xdm-jZka6acotFMss63SMR3pHWyFp7jA3o,1100
2
+ adafruit_maptiles/__init__.py,sha256=bbBnTFO_Ss1FLKVNVjEnzh61hDmi1vYGfmoZN7E-d_s,3458
3
+ adafruit_maptiles/cache.py,sha256=h2jaWt4F60mh05NAok4-TjG2X3mYG4Jjf4sVvz6Dzgk,10253
4
+ adafruit_maptiles/map_view.py,sha256=NieAEcd44V-s7fOW8IooO5Vr2T7sCLFJKK-So1rq-MQ,19465
5
+ adafruit_maptiles/png.py,sha256=nThOWXVxWj6ESHqqq05Pir6cXwz1CibvGdj32kQMjKY,5728
6
+ adafruit_circuitpython_maptiles-1.0.0.dist-info/METADATA,sha256=TN20_mNsPYWQzp7G3uk9Qt7wIZtxQFySbX1VQGxWWl8,5905
7
+ adafruit_circuitpython_maptiles-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ adafruit_circuitpython_maptiles-1.0.0.dist-info/top_level.txt,sha256=2G_NVbS9ck3MTtwBqfntHAxIf-8c9rLG9eJNpLtED6o,18
9
+ adafruit_circuitpython_maptiles-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Tim Cocks for Adafruit Industries
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ adafruit_maptiles
@@ -0,0 +1,104 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ `adafruit_maptiles`
6
+ ================================================================================
7
+
8
+ Slippy-map (OpenStreetMap style) tiles for CircuitPython displays: a map
9
+ widget, and a tile cache on the SD card that every program using this
10
+ library shares.
11
+
12
+ * Author(s): Tim Cocks
13
+
14
+ The package:
15
+
16
+ * :mod:`adafruit_maptiles` (this module): the tile maths (Web Mercator,
17
+ 256x256 tiles, ``{z}/{x}/{y}``) and the RGB332 colours the cached tiles
18
+ are stored in. Plain Python.
19
+ * :mod:`adafruit_maptiles.png`: PNG -> 8-bit RGB332 BMP conversion. Plain
20
+ Python (``zlib``).
21
+ * :mod:`adafruit_maptiles.cache`: :class:`~adafruit_maptiles.cache.TileCache`,
22
+ the shared cache (``/sd/tiles/<source>/{z}/{x}/{y}.bmp``), downloads, and
23
+ mounting the SD card.
24
+ * :mod:`adafruit_maptiles.map_view`: :class:`~adafruit_maptiles.map_view.MapView`,
25
+ a :class:`displayio.Group` showing the map, with helpers for drawing on
26
+ it.
27
+
28
+ Implementation Notes
29
+ --------------------
30
+
31
+ **Hardware:**
32
+
33
+ * A board with an SD card slot (``board.SD_*`` pins, or a card mounted at
34
+ ``/sd`` by the program) and a network connection, e.g. the
35
+ `Adafruit Fruit Jam <https://www.adafruit.com/product/6200>`_.
36
+
37
+ **Software and Dependencies:**
38
+
39
+ * Adafruit CircuitPython firmware for the supported boards:
40
+ https://circuitpython.org/downloads
41
+ * Adafruit's Requests library: https://github.com/adafruit/Adafruit_CircuitPython_Requests
42
+ * Adafruit's Connection Manager library:
43
+ https://github.com/adafruit/Adafruit_CircuitPython_ConnectionManager
44
+ * Adafruit's ESP32SPI library (boards with an ESP32 co-processor, such as
45
+ the Fruit Jam): https://github.com/adafruit/Adafruit_CircuitPython_ESP32SPI
46
+ """
47
+
48
+ import math
49
+
50
+ __version__ = "1.0.0"
51
+ __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MapTiles.git"
52
+
53
+ TILE = 256
54
+ """Tile width and height, pixels."""
55
+
56
+
57
+ # ----------------------------------------------------------------- maths
58
+
59
+
60
+ def world_x(lon, zoom):
61
+ """Longitude -> x in pixels on the whole map at this zoom."""
62
+ return (lon + 180.0) / 360.0 * (TILE << zoom)
63
+
64
+
65
+ def mercator(lat):
66
+ """Latitude -> Mercator y, -0.5 (85.05N) .. 0.5 (85.05S)."""
67
+ lat = max(-85.05, min(85.05, lat))
68
+ s = math.sin(math.radians(lat))
69
+ return -math.log((1 + s) / (1 - s)) / (4 * math.pi)
70
+
71
+
72
+ def unmercator(y):
73
+ """Mercator y -> latitude (:func:`mercator`'s inverse)."""
74
+ k = math.exp(-4 * math.pi * max(-0.5, min(0.5, y)))
75
+ return math.degrees(math.asin((k - 1) / (k + 1)))
76
+
77
+
78
+ def world_y(lat, zoom):
79
+ """Latitude -> y in pixels on the whole map at this zoom."""
80
+ return (0.5 + mercator(lat)) * (TILE << zoom)
81
+
82
+
83
+ def tile_xy(lat, lon, zoom):
84
+ """The ``(x, y)`` numbers of the tile that (lat, lon) is on."""
85
+ n = 1 << zoom
86
+ return int(world_x(lon, zoom)) // TILE % n, min(n - 1, int(world_y(lat, zoom)) // TILE)
87
+
88
+
89
+ def metres_per_pixel(lat, zoom):
90
+ """Ground distance across one map pixel at this latitude and zoom."""
91
+ return 156543.034 * math.cos(math.radians(lat)) / (1 << zoom)
92
+
93
+
94
+ # ------------------------------------------------------------ colours
95
+
96
+
97
+ def rgb332(r, g, b):
98
+ """8-bit r, g, b -> an RGB332 value (the cached tiles' pixels)."""
99
+ return (r & 0xE0) | (g & 0xE0) >> 3 | b >> 6
100
+
101
+
102
+ def rgb332_colour(v):
103
+ """RGB332 value -> 0xRRGGBB."""
104
+ return ((v >> 5) * 255 // 7) << 16 | ((v >> 2 & 7) * 255 // 7) << 8 | (v & 3) * 85
@@ -0,0 +1,286 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ `adafruit_maptiles.cache`
6
+ ================================================================================
7
+
8
+ The tile cache on the SD card, shared by every program that uses this
9
+ library: a tile downloaded once (by any of them) is there for all of
10
+ them. Each tile source has its own folder, in the source's own
11
+ ``{z}/{x}/{y}`` layout::
12
+
13
+ /sd/tiles/osm/9/122/196.png as downloaded
14
+ /sd/tiles/osm/9/122/196.bmp the same, 8-bit RGB332, rows top-down
15
+
16
+ Only the BMP is read to draw the map (:mod:`adafruit_maptiles.png`).
17
+ Downloads and conversions each take seconds, so they happen once per
18
+ tile; files are written under a temporary name and renamed when complete,
19
+ so a reset part way leaves no broken tile. Tiles copied to the card from
20
+ a PC (PNG or BMP, same layout) are used as they are.
21
+
22
+ The default source is OpenStreetMap's standard tile server. Its usage
23
+ policy (https://operations.osmfoundation.org/policies/tiles/) asks for a
24
+ User-Agent that identifies the program, that downloaded tiles are kept
25
+ (which the cache does) and that the map shows "(C) OpenStreetMap
26
+ contributors" (:class:`~adafruit_maptiles.map_view.MapView` does). Pass
27
+ your program's name as ``user_agent``.
28
+
29
+ Plain Python apart from mounting the card and downloading.
30
+ """
31
+
32
+ import os
33
+
34
+ from adafruit_maptiles import TILE
35
+ from adafruit_maptiles.png import png_to_bmp
36
+
37
+ CACHE_ROOT = "/sd/tiles"
38
+ """Where every program's tiles go, a folder per source."""
39
+
40
+ USER_AGENT = "adafruit_maptiles/1.0 CircuitPython"
41
+
42
+
43
+ class TileSource:
44
+ """Where tiles come from.
45
+
46
+ :param str name: the cache folder's name (under ``CACHE_ROOT``); one per
47
+ tile style, shared by everything that uses that style
48
+ :param str url: the tile URL, with ``{z}``, ``{x}`` and ``{y}``
49
+ :param str credit: the attribution that must be shown on the map
50
+ :param int max_zoom: the closest zoom the server has
51
+ """
52
+
53
+ def __init__(self, name, url, credit, max_zoom=19):
54
+ self.name = name
55
+ self.url = url
56
+ self.credit = credit
57
+ self.max_zoom = max_zoom
58
+
59
+
60
+ OSM = TileSource(
61
+ "osm", "https://tile.openstreetmap.org/{z}/{x}/{y}.png", "(C) OpenStreetMap contributors"
62
+ )
63
+ """OpenStreetMap's standard tiles (the default)."""
64
+
65
+
66
+ def _exists(path):
67
+ try:
68
+ os.stat(path)
69
+ return True
70
+ except OSError:
71
+ return False
72
+
73
+
74
+ def _makedirs(path):
75
+ at = ""
76
+ for part in path.strip("/").split("/"):
77
+ at += "/" + part
78
+ try:
79
+ os.mkdir(at)
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ def mount_sd(path="/sd"):
85
+ """True if a card is mounted at path, mounting the board's SD card
86
+ (``board.SD_SCK``, ``SD_MOSI``, ``SD_MISO``, ``SD_CS``, or ``board.SPI()``
87
+ and ``SD_CS`` on boards without SD bus pins) there if the supervisor or
88
+ the program has not already."""
89
+ try:
90
+ if os.statvfs(path) != os.statvfs("/"):
91
+ return True
92
+ except OSError:
93
+ pass
94
+ spi = None # a bus made here, released if the card does not mount
95
+ try:
96
+ import board # noqa: PLC0415
97
+ import busio # noqa: PLC0415
98
+ import sdcardio # noqa: PLC0415
99
+ import storage # noqa: PLC0415
100
+
101
+ if hasattr(board, "SD_SCK"):
102
+ spi = busio.SPI(board.SD_SCK, board.SD_MOSI, board.SD_MISO)
103
+ bus = spi
104
+ else:
105
+ # The card shares the board's SPI bus (e.g. PyPortal, Metro
106
+ # ESP32-S3) with other devices, so the bus is left as it is.
107
+ bus = board.SPI()
108
+ storage.mount(storage.VfsFat(sdcardio.SDCard(bus, board.SD_CS)), path)
109
+ return os.statvfs(path) != os.statvfs("/")
110
+ except Exception as error:
111
+ if spi is not None:
112
+ try:
113
+ spi.deinit()
114
+ except Exception:
115
+ pass
116
+ print("SD_MOUNT_ERROR", type(error).__name__, error)
117
+ return False
118
+
119
+
120
+ _radio = None
121
+ _session = None
122
+
123
+
124
+ def _find_radio():
125
+ """The board's native Wi-Fi (``wifi.radio``), otherwise its ESP32
126
+ co-processor: shared through the core when the core is managing it (web
127
+ workflow), otherwise driven over the ``board.ESP_*`` pins from here."""
128
+ try:
129
+ import wifi # noqa: PLC0415
130
+
131
+ return wifi.radio
132
+ except ImportError:
133
+ pass
134
+ from adafruit_esp32spi import adafruit_esp32spi # noqa: PLC0415
135
+
136
+ try:
137
+ import _airlift # noqa: PLC0415, PLC2701
138
+
139
+ shared = _airlift.active()
140
+ except ImportError:
141
+ shared = False
142
+ if shared:
143
+ return adafruit_esp32spi.ESP_SPIcontrol()
144
+ import board # noqa: PLC0415
145
+ from digitalio import DigitalInOut # noqa: PLC0415
146
+
147
+ gpio0 = getattr(board, "ESP_GPIO0", None)
148
+ return adafruit_esp32spi.ESP_SPIcontrol(
149
+ board.SPI(),
150
+ DigitalInOut(board.ESP_CS),
151
+ DigitalInOut(board.ESP_BUSY),
152
+ DigitalInOut(board.ESP_RESET),
153
+ DigitalInOut(gpio0) if gpio0 else None,
154
+ )
155
+
156
+
157
+ def default_session():
158
+ """An ``adafruit_requests`` session, made once and shared by every cache:
159
+ over the board's native Wi-Fi (``wifi.radio``), otherwise through an
160
+ ESP32 co-processor (``adafruit_esp32spi``; on the Fruit Jam the core
161
+ shares its ESP32-C6 when web workflow is running). If the radio is not
162
+ connected yet, it joins ``CIRCUITPY_WIFI_SSID`` with
163
+ ``CIRCUITPY_WIFI_PASSWORD`` from settings.toml."""
164
+ global _radio, _session # noqa: PLW0603
165
+ if _session is None:
166
+ import adafruit_connection_manager # noqa: PLC0415
167
+ import adafruit_requests # noqa: PLC0415
168
+
169
+ if _radio is None:
170
+ _radio = _find_radio()
171
+ if not _radio.connected:
172
+ ssid = os.getenv("CIRCUITPY_WIFI_SSID")
173
+ if not ssid:
174
+ raise RuntimeError("Wi-Fi is not connected, and CIRCUITPY_WIFI_SSID is not set")
175
+ _radio.connect(ssid, os.getenv("CIRCUITPY_WIFI_PASSWORD", ""))
176
+ pool = adafruit_connection_manager.get_radio_socketpool(_radio)
177
+ ssl = adafruit_connection_manager.get_radio_ssl_context(_radio)
178
+ _session = adafruit_requests.Session(pool, ssl)
179
+ return _session
180
+
181
+
182
+ class TileCache:
183
+ """The cached tiles of one source, fetched when missing.
184
+
185
+ :param TileSource source: where tiles come from (default: :data:`OSM`)
186
+ :param str root: the cache's top folder (default: :data:`CACHE_ROOT`);
187
+ a root on ``/sd`` mounts the SD card when the cache is first used
188
+ :param str user_agent: identifies the program to the tile server
189
+ :param session: an ``adafruit_requests.Session`` to download with
190
+ (default: :func:`default_session`, made at the first download)
191
+ """
192
+
193
+ def __init__(self, source=OSM, root=CACHE_ROOT, user_agent=USER_AGENT, session=None):
194
+ self.source = source
195
+ root = root.rstrip("/")
196
+ self.folder = f"{root}/{source.name}"
197
+ self.user_agent = user_agent
198
+ self._session = session
199
+ self._mounted = None
200
+ self.fetched = 0
201
+ """Tiles downloaded since the cache was made."""
202
+ self.converted = 0
203
+ """Tiles converted to BMP since the cache was made."""
204
+
205
+ @property
206
+ def available(self):
207
+ """True if the card the cache is on is there (mounting it the first
208
+ time, if needed)."""
209
+ if self._mounted is None:
210
+ self._mounted = mount_sd() if self.folder.startswith("/sd/") else True
211
+ return self._mounted
212
+
213
+ def path(self, z, x, y, ext="bmp"):
214
+ """The file of tile (z, x, y): ``ext`` ``"bmp"`` or ``"png"``."""
215
+ return f"{self.folder}/{z}/{x}/{y}.{ext}"
216
+
217
+ def ready(self, z, x, y):
218
+ """True if the tile's BMP is in the cache."""
219
+ return self.available and _exists(self.path(z, x, y))
220
+
221
+ def has_png(self, z, x, y):
222
+ """True if the tile's PNG is in the cache (no download needed)."""
223
+ return self.available and _exists(self.path(z, x, y, "png"))
224
+
225
+ def open(self, z, x, y):
226
+ """The tile's BMP, open for reading (pixels from ``png.BMP_HEADER``),
227
+ or None if it is not in the cache."""
228
+ try:
229
+ return open(self.path(z, x, y), "rb")
230
+ except OSError:
231
+ return None
232
+
233
+ def session(self):
234
+ """The ``adafruit_requests`` session downloads use."""
235
+ if self._session is None:
236
+ self._session = default_session()
237
+ return self._session
238
+
239
+ def fetch(self, z, x, y):
240
+ """Download the tile's PNG to the cache; returns its bytes."""
241
+ url = self.source.url.format(z=z, x=x, y=y)
242
+ r = self.session().get(url, headers={"User-Agent": self.user_agent})
243
+ try:
244
+ if r.status_code != 200:
245
+ raise OSError(f"HTTP {r.status_code} for {url}")
246
+ data = r.content
247
+ finally:
248
+ r.close()
249
+ path = self.path(z, x, y, "png")
250
+ _makedirs(path[: path.rfind("/")])
251
+ with open(path + ".tmp", "wb") as f:
252
+ f.write(data)
253
+ os.rename(path + ".tmp", path)
254
+ self.fetched += 1
255
+ return data
256
+
257
+ def convert(self, z, x, y, data=None):
258
+ """The tile's PNG (data, or the cached file) -> its BMP."""
259
+ if data is None:
260
+ with open(self.path(z, x, y, "png"), "rb") as f:
261
+ data = f.read()
262
+ path = self.path(z, x, y)
263
+ _makedirs(path[: path.rfind("/")])
264
+ with open(path + ".tmp", "wb") as f:
265
+ size = png_to_bmp(data, f)
266
+ if size != (TILE, TILE):
267
+ os.remove(path + ".tmp")
268
+ raise ValueError(f"tile is {size[0]}x{size[1]}")
269
+ os.rename(path + ".tmp", path)
270
+ self.converted += 1
271
+
272
+ def get(self, z, x, y):
273
+ """Make sure the tile's BMP is in the cache: downloads the PNG if it
274
+ is not there either, then converts it. Seconds. Returns True if it
275
+ downloaded.
276
+
277
+ :raises OSError: no card, no network, or the server refused
278
+ :raises ValueError: the PNG cannot be read
279
+ """
280
+ if self.ready(z, x, y):
281
+ return False
282
+ if not self.available:
283
+ raise OSError("no SD card for the tile cache")
284
+ fetch = not self.has_png(z, x, y)
285
+ self.convert(z, x, y, self.fetch(z, x, y) if fetch else None)
286
+ return fetch
@@ -0,0 +1,509 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ `adafruit_maptiles.map_view`
6
+ ================================================================================
7
+
8
+ :class:`MapView`, a :class:`displayio.Group` showing a slippy map from the
9
+ shared tile cache (:mod:`adafruit_maptiles.cache`), and the pieces for
10
+ drawing over it.
11
+
12
+ The map is one 256-colour bitmap (:attr:`MapView.bitmap`, palette: the
13
+ RGB332 colours), over a second one holding just the tiles (the
14
+ underlay). Tiles are read from their BMPs a strip of rows at a time into
15
+ the underlay. Drawing a frame, the program:
16
+
17
+ 1. copies the underlay back over the map (:meth:`MapView.restore`, of the
18
+ whole map or one rectangle),
19
+ 2. draws its markers with :meth:`MapView.fill`, :meth:`MapView.line`,
20
+ :meth:`MapView.blit` and :meth:`MapView.cross` (clipped to the map, or
21
+ to the rectangle), at :meth:`MapView.xy` of their positions,
22
+ 3. draws the scale bar and the tiles' credit (:meth:`MapView.draw_legend`),
23
+ 4. marks the bitmap changed (:meth:`MapView.dirty`) and refreshes the display.
24
+
25
+ Colours are RGB332 values (:func:`adafruit_maptiles.rgb332`); on an 8-bit
26
+ ``picodvi`` framebuffer they are the framebuffer's own.
27
+
28
+ Missing tiles are grey until they are fetched. Setting the view queues
29
+ them (:attr:`MapView.missing`, nearest the centre first);
30
+ :meth:`MapView.fetch_step` downloads and converts one (seconds: a
31
+ program that must stay responsive calls it when it can wait), then
32
+ :meth:`MapView.load` puts it on the map. Programs that do not mind
33
+ waiting call :meth:`MapView.update` instead.
34
+
35
+ Loading: :meth:`MapView.load_step` reads one strip of rows (a few ms),
36
+ :meth:`MapView.load` all the strips still to read.
37
+
38
+ CircuitPython note: assigning to a slice of a memoryview of a big buffer
39
+ moves the buffer's whole tail, so pixels are copied with ``bitmaptools``,
40
+ never by slices.
41
+ """
42
+
43
+ import bitmaptools
44
+ import displayio
45
+ import terminalio
46
+
47
+ from adafruit_maptiles import (
48
+ TILE,
49
+ mercator,
50
+ metres_per_pixel,
51
+ rgb332_colour,
52
+ unmercator,
53
+ world_x,
54
+ world_y,
55
+ )
56
+ from adafruit_maptiles.cache import TileCache
57
+ from adafruit_maptiles.png import BMP_HEADER
58
+
59
+ # Colours are RGB332 values.
60
+ BLACK = 0x00
61
+ WHITE = 0xFF
62
+ NO_TILE = 0xB6
63
+ """Where a tile is missing."""
64
+ WAITING = 0x49
65
+ """The whole map, while it has no centre."""
66
+
67
+ _palette = None
68
+
69
+
70
+ def palette():
71
+ """The RGB332 palette (256 colours), shared by every map."""
72
+ global _palette # noqa: PLW0603
73
+ if _palette is None:
74
+ _palette = displayio.Palette(256)
75
+ for v in range(256):
76
+ _palette[v] = rgb332_colour(v)
77
+ return _palette
78
+
79
+
80
+ _glyphs = {}
81
+
82
+
83
+ def text_bitmap(text, background, width=None, outline=False):
84
+ """text on background in terminalio's font (6x12; 1 pixel margin), as a
85
+ 256-colour bitmap to :meth:`MapView.blit` onto the map. With outline:
86
+ a black border, 14 pixels high and 2 pixels either side of the text.
87
+ width: wider than the text (the text goes on the right)."""
88
+ # (a glyph's own blank column is on its right)
89
+ b = displayio.Bitmap(width or 6 * len(text) + (5 if outline else 2), 14 if outline else 13, 256)
90
+ if outline:
91
+ b.fill(BLACK)
92
+ bitmaptools.fill_region(b, 1, 1, b.width - 1, b.height - 1, background)
93
+ else:
94
+ b.fill(background)
95
+ for k, ch in enumerate(text):
96
+ g = _glyphs.get(ch)
97
+ if g is None:
98
+ glyph = terminalio.FONT.get_glyph(ord(ch))
99
+ g = _glyphs[ch] = glyph.tile_index * 6 if glyph else -1
100
+ if g >= 0:
101
+ bitmaptools.blit(
102
+ b,
103
+ terminalio.FONT.bitmap,
104
+ b.width - 6 * len(text) - (2 if outline else 1) + 6 * k,
105
+ 1,
106
+ x1=g,
107
+ y1=0,
108
+ x2=g + 6,
109
+ y2=12,
110
+ skip_source_index=0,
111
+ )
112
+ return b
113
+
114
+
115
+ def overlaps(a, b):
116
+ """True if rectangles a and b, ``(x1, y1, x2, y2)``, overlap."""
117
+ return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
118
+
119
+
120
+ class MapView(displayio.Group): # noqa: PLR0904
121
+ """A slippy map, width x height pixels, centred on centre at zoom.
122
+
123
+ :param int width: the map's width in pixels
124
+ :param int height: the map's height in pixels
125
+ :param tuple centre: ``(lat, lon)``, or None for no map yet (the map is
126
+ dark grey until :attr:`centre` is set)
127
+ :param int zoom: 0 (the world in one tile) .. ``zooms[1]``
128
+ :param TileCache cache: where the tiles come from (default: a
129
+ :class:`~adafruit_maptiles.cache.TileCache` of OpenStreetMap
130
+ tiles in the shared cache)
131
+ :param tuple zooms: ``(closest out, closest in)`` that :attr:`zoom` keeps to
132
+ (default: 0 .. the source's maximum)
133
+ :param int x: the map's position in its parent group
134
+ :param int y: the map's position in its parent group
135
+ :param int strip: rows read per :meth:`load_step`
136
+ :param bool scale_bar: draw a scale bar at the bottom left
137
+ :param bool credit: draw the source's credit at the bottom right (the
138
+ OpenStreetMap tiles' usage policy requires it)
139
+ """
140
+
141
+ def __init__( # noqa: PLR0913, PLR0917
142
+ self,
143
+ width,
144
+ height,
145
+ centre=None,
146
+ zoom=8,
147
+ cache=None,
148
+ zooms=None,
149
+ x=0,
150
+ y=0,
151
+ strip=8,
152
+ scale_bar=True,
153
+ credit=True,
154
+ ):
155
+ super().__init__(x=x, y=y)
156
+ self.width = width
157
+ self.height = height
158
+ self.cache = cache or TileCache()
159
+ self.zooms = zooms or (0, self.cache.source.max_zoom)
160
+ self.strip = strip
161
+ self.bitmap = displayio.Bitmap(width, height, 256)
162
+ """The map as shown: the tiles and what is drawn over them."""
163
+ self.under = displayio.Bitmap(width, height, 256)
164
+ """Just the tiles."""
165
+ self._rows = displayio.Bitmap(TILE, strip, 256) # a tile's rows, read from its file
166
+ self._rows_mv = memoryview(self._rows)
167
+ self.append(displayio.TileGrid(self.bitmap, pixel_shader=palette()))
168
+ self.credit = text_bitmap(self.cache.source.credit, WHITE) if credit else None
169
+ self._show_scale = scale_bar
170
+ self.scale_bar = None
171
+ """The scale bar's bitmap (None: no scale bar, or no centre)."""
172
+ self.missing = []
173
+ """``(z, x, y)`` tiles to fetch, nearest the centre first."""
174
+ self.net_error = None
175
+ """Why the last download failed (until the view changes), or None."""
176
+ self._todo = [] # strips to (re)load
177
+ self._files = {} # (x, y) -> open tile BMP, or None, while strips load
178
+ self._centre = centre
179
+ self._zoom = self._clamp(zoom)
180
+ self.set_view()
181
+
182
+ # ------------------------------------------------------------ view
183
+
184
+ def _clamp(self, zoom):
185
+ return max(self.zooms[0], min(self.zooms[1], zoom))
186
+
187
+ @property
188
+ def centre(self):
189
+ """The map's centre, ``(lat, lon)``, or None. Setting it moves the map."""
190
+ return self._centre
191
+
192
+ @centre.setter
193
+ def centre(self, centre):
194
+ self.set_view(centre=centre)
195
+
196
+ @property
197
+ def zoom(self):
198
+ """The zoom (kept to :attr:`zooms`). Setting it zooms the map."""
199
+ return self._zoom
200
+
201
+ @zoom.setter
202
+ def zoom(self, zoom):
203
+ self.set_view(zoom=zoom)
204
+
205
+ def set_view(self, centre=None, zoom=None):
206
+ """Move the map to centre and / or zoom (or just reload it): queues
207
+ the tiles not in the cache (:attr:`missing`) and every strip for
208
+ loading. Nothing changes on the screen until :meth:`load` (or
209
+ :meth:`load_step`) and a redraw."""
210
+ if centre is not None:
211
+ self._centre = (centre[0], centre[1])
212
+ if zoom is not None:
213
+ self._zoom = self._clamp(zoom)
214
+ self._close_files()
215
+ self._todo = list(range((self.height + self.strip - 1) // self.strip))
216
+ self.missing = []
217
+ self.net_error = None
218
+ if self._centre is None:
219
+ self.scale_bar = None
220
+ return
221
+ z = self._zoom
222
+ lat, lon = self._centre
223
+ w, h = self.width, self.height
224
+ self.x0 = int(world_x(lon, z)) - w // 2
225
+ self.y0 = int(world_y(lat, z)) - h // 2
226
+ self.clon = lon
227
+ self.cmerc = mercator(lat)
228
+ self.world = TILE << z
229
+ n = 1 << z
230
+ ctx, cty = (self.x0 + w // 2) >> 8, (self.y0 + h // 2) >> 8
231
+ need = []
232
+ seen = set()
233
+ for ty in range(self.y0 >> 8, ((self.y0 + h - 1) >> 8) + 1):
234
+ for tx in range(self.x0 >> 8, ((self.x0 + w - 1) >> 8) + 1):
235
+ # (at low zooms the map wraps round: the same tile twice)
236
+ if 0 <= ty < n and (tx % n, ty) not in seen:
237
+ seen.add((tx % n, ty))
238
+ if not self.cache.ready(z, tx % n, ty):
239
+ need.append(((tx - ctx) ** 2 + (ty - cty) ** 2, tx % n, ty))
240
+ self.missing = [(z, x, y) for _, x, y in sorted(need)]
241
+ self.scale_bar = self._make_scale(lat, z) if self._show_scale else None
242
+
243
+ @staticmethod
244
+ def _make_scale(lat, z):
245
+ """The scale bar: the longest 1, 2 or 5 x 10^k that fits in 80 pixels."""
246
+ mpp = metres_per_pixel(lat, z)
247
+ step = 1
248
+ while step * 10 <= mpp * 80:
249
+ step *= 10
250
+ metres = max(m for m in (step, 2 * step, 5 * step) if m <= mpp * 80 or m == step)
251
+ px = max(1, int(metres / mpp))
252
+ text = f"{metres // 1000} km" if metres >= 1000 else f"{metres} m"
253
+ scale = text_bitmap(text, WHITE, px + 8 + 6 * len(text))
254
+ bitmaptools.fill_region(scale, 3, 9, 3 + px, 11, BLACK)
255
+ bitmaptools.fill_region(scale, 3, 4, 4, 11, BLACK)
256
+ bitmaptools.fill_region(scale, 2 + px, 4, 3 + px, 11, BLACK)
257
+ return scale
258
+
259
+ def xy(self, lat, lon):
260
+ """(lat, lon) -> ``(x, y)`` on the map, in pixels (outside
261
+ 0 .. width, 0 .. height when it is off the map)."""
262
+ return (
263
+ self.width // 2 + round((lon - self.clon) / 360 * self.world),
264
+ self.height // 2 + round((mercator(lat) - self.cmerc) * self.world),
265
+ )
266
+
267
+ def latlon(self, x, y):
268
+ """``(x, y)`` on the map -> (lat, lon)."""
269
+ lon = self.clon + (x - self.width // 2) / self.world * 360
270
+ lat = unmercator(self.cmerc + (y - self.height // 2) / self.world)
271
+ return lat, (lon + 540) % 360 - 180
272
+
273
+ def pan(self, dx, dy):
274
+ """Move the map by dx widths and dy heights (e.g. ``pan(0.25, 0)``:
275
+ a quarter of the map to the right)."""
276
+ if self._centre is not None:
277
+ self.centre = self.latlon(
278
+ self.width // 2 + dx * self.width, self.height // 2 + dy * self.height
279
+ )
280
+
281
+ def zoom_in(self):
282
+ """One zoom closer; True if the zoom changed."""
283
+ z = self._zoom
284
+ self.zoom = z + 1
285
+ return self._zoom != z
286
+
287
+ def zoom_out(self):
288
+ """One zoom further out; True if the zoom changed."""
289
+ z = self._zoom
290
+ self.zoom = z - 1
291
+ return self._zoom != z
292
+
293
+ def fit(self, points, max_zoom=None, margin=24):
294
+ """Centre the map on the ``(lat, lon)`` points, at the closest zoom
295
+ (up to max_zoom) that shows them all, margin pixels inside the map."""
296
+ lons = [lon for _, lon in points]
297
+ ys = [mercator(lat) for lat, _ in points]
298
+ z = self._clamp(self.zooms[1] if max_zoom is None else max_zoom)
299
+ while z > self.zooms[0] and (
300
+ (max(lons) - min(lons)) / 360 * (TILE << z) > self.width - 2 * margin
301
+ or (max(ys) - min(ys)) * (TILE << z) > self.height - 2 * margin
302
+ ):
303
+ z -= 1
304
+ self.set_view(((unmercator((max(ys) + min(ys)) / 2)), (max(lons) + min(lons)) / 2), z)
305
+
306
+ # ---------------------------------------------------------- loading
307
+
308
+ @property
309
+ def loading(self):
310
+ """True while strips of the map are still to be read."""
311
+ return bool(self._todo)
312
+
313
+ def _tile_file(self, x, y):
314
+ key = (x, y)
315
+ if key not in self._files:
316
+ self._files[key] = self.cache.open(self._zoom, x, y)
317
+ return self._files[key]
318
+
319
+ def _close_files(self):
320
+ for f in self._files.values():
321
+ if f:
322
+ f.close()
323
+ self._files = {}
324
+
325
+ def load_step(self):
326
+ """Read the next strip of the map into the underlay. Returns the
327
+ rectangle loaded, ``(x1, y1, x2, y2)`` (to redraw), or None when
328
+ the map is loaded."""
329
+ if not self._todo:
330
+ self._close_files()
331
+ return None
332
+ y1 = self._todo.pop(0) * self.strip
333
+ y2 = min(self.height, y1 + self.strip)
334
+ w = self.width
335
+ if self._centre is None:
336
+ bitmaptools.fill_region(self.under, 0, y1, w, y2, WAITING)
337
+ return (0, y1, w, y2)
338
+ n = 1 << self._zoom
339
+ y = y1
340
+ while y < y2:
341
+ # Rows y .. y + h are in the same row of tiles.
342
+ gy = self.y0 + y
343
+ ty = gy >> 8
344
+ h = min(y2 - y, TILE - (gy & 255))
345
+ sx = 0
346
+ while sx < w:
347
+ gx = self.x0 + sx
348
+ off = gx & 255
349
+ m = min(TILE - off, w - sx)
350
+ f = self._tile_file((gx >> 8) % n, ty) if 0 <= ty < n else None
351
+ if f is None:
352
+ bitmaptools.fill_region(self.under, sx, y, sx + m, y + h, NO_TILE)
353
+ else:
354
+ f.seek(BMP_HEADER + (gy & 255) * TILE)
355
+ f.readinto(self._rows_mv[: h * TILE])
356
+ bitmaptools.blit(self.under, self._rows, sx, y, x1=off, y1=0, x2=off + m, y2=h)
357
+ sx += m
358
+ y += h
359
+ return (0, y1, w, y2)
360
+
361
+ def load(self):
362
+ """Read every strip still to load. True if there were any."""
363
+ if not self._todo:
364
+ return False
365
+ while self.load_step():
366
+ pass
367
+ return True
368
+
369
+ # ---------------------------------------------------------- fetching
370
+
371
+ def fetch_step(self):
372
+ """Download (if needed) and convert the next missing tile: seconds.
373
+ Returns False if no tile was waiting. A download that fails sets
374
+ :attr:`net_error` and drops the tiles that need downloading (the
375
+ rest still convert); setting the view again retries them. A tile
376
+ fetched for the current view is queued to load."""
377
+ if not self.missing:
378
+ return False
379
+ z, x, y = self.missing.pop(0)
380
+ fetch = not self.cache.has_png(z, x, y)
381
+ try:
382
+ self.cache.get(z, x, y)
383
+ except Exception as error:
384
+ print("TILE_ERROR", z, x, y, type(error).__name__, error)
385
+ if fetch:
386
+ self.net_error = f"{type(error).__name__} {error}"
387
+ self.missing = [t for t in self.missing if self.cache.has_png(*t)]
388
+ return True
389
+ if z == self._zoom and self._centre is not None:
390
+ f = self._files.pop((x, y), None)
391
+ if f:
392
+ f.close()
393
+ # Reload the strips the tile is in.
394
+ top = (y << 8) - self.y0
395
+ for k in range(
396
+ max(0, top // self.strip), min(self.height, top + TILE) // self.strip + 1
397
+ ):
398
+ if k * self.strip < self.height and k not in self._todo:
399
+ self._todo.append(k)
400
+ return True
401
+
402
+ def update(self, progress=None):
403
+ """Fetch every missing tile, then load the map: for programs that can
404
+ wait (seconds a tile). progress(done, total) is called before each
405
+ tile."""
406
+ total = len(self.missing)
407
+ while self.missing:
408
+ if progress:
409
+ progress(total - len(self.missing), total)
410
+ self.fetch_step()
411
+ self.load()
412
+
413
+ # ---------------------------------------------------------- drawing
414
+
415
+ def _clip(self, clip):
416
+ if clip is None:
417
+ return (0, 0, self.width, self.height)
418
+ return (
419
+ max(0, clip[0]),
420
+ max(0, clip[1]),
421
+ min(self.width, clip[2]),
422
+ min(self.height, clip[3]),
423
+ )
424
+
425
+ def restore(self, clip=None):
426
+ """Copy the tiles back over the map, in clip ``(x1, y1, x2, y2)``
427
+ (default: all of it), wiping what was drawn there."""
428
+ x1, y1, x2, y2 = self._clip(clip)
429
+ if x1 < x2 and y1 < y2:
430
+ bitmaptools.blit(self.bitmap, self.under, x1, y1, x1=x1, y1=y1, x2=x2, y2=y2)
431
+
432
+ def fill(self, x1, y1, x2, y2, colour, clip=None): # noqa: PLR0913, PLR0917
433
+ """A filled rectangle, x2 and y2 exclusive."""
434
+ c = self._clip(clip)
435
+ x1, y1, x2, y2 = max(x1, c[0]), max(y1, c[1]), min(x2, c[2]), min(y2, c[3])
436
+ if x1 < x2 and y1 < y2:
437
+ bitmaptools.fill_region(self.bitmap, x1, y1, x2, y2, colour)
438
+
439
+ def line(self, x1, y1, x2, y2, colour, clip=None): # noqa: PLR0913, PLR0917
440
+ """A line, clipped (Liang-Barsky)."""
441
+ c = self._clip(clip)
442
+ dx, dy = x2 - x1, y2 - y1
443
+ t0, t1 = 0, 1
444
+ for p, q in ((-dx, x1 - c[0]), (dx, c[2] - 1 - x1), (-dy, y1 - c[1]), (dy, c[3] - 1 - y1)):
445
+ if p == 0:
446
+ if q < 0:
447
+ return
448
+ elif p < 0:
449
+ t0 = max(t0, q / p)
450
+ else:
451
+ t1 = min(t1, q / p)
452
+ if t0 <= t1:
453
+ bitmaptools.draw_line(
454
+ self.bitmap,
455
+ round(x1 + t0 * dx),
456
+ round(y1 + t0 * dy),
457
+ round(x1 + t1 * dx),
458
+ round(y1 + t1 * dy),
459
+ colour,
460
+ )
461
+
462
+ def blit(self, src, x, y, clip=None):
463
+ """The bitmap src (256 colours, RGB332) with its top left at x, y."""
464
+ c = self._clip(clip)
465
+ x1, y1 = max(x, c[0]), max(y, c[1])
466
+ x2, y2 = min(x + src.width, c[2]), min(y + src.height, c[3])
467
+ if x1 < x2 and y1 < y2:
468
+ bitmaptools.blit(self.bitmap, src, x1, y1, x1=x1 - x, y1=y1 - y, x2=x2 - x, y2=y2 - y)
469
+
470
+ def cross(self, x, y, clip=None):
471
+ """A white cross with a black middle, 13 pixels across, centred on
472
+ x, y (e.g. home)."""
473
+ self.fill(x - 6, y - 2, x + 7, y + 3, WHITE, clip)
474
+ self.fill(x - 2, y - 6, x + 3, y + 7, WHITE, clip)
475
+ self.fill(x - 5, y - 1, x + 6, y + 2, BLACK, clip)
476
+ self.fill(x - 1, y - 5, x + 2, y + 6, BLACK, clip)
477
+
478
+ def draw_legend(self, clip=None):
479
+ """The scale bar (bottom left) and the credit (bottom right)."""
480
+ if self.scale_bar is not None:
481
+ self.blit(self.scale_bar, 0, self.height - self.scale_bar.height, clip)
482
+ if self.credit is not None and self._centre is not None:
483
+ self.blit(
484
+ self.credit,
485
+ self.width - self.credit.width,
486
+ self.height - self.credit.height,
487
+ clip,
488
+ )
489
+
490
+ def dirty(self, clip=None):
491
+ """Mark the map (or clip of it) changed, for the next display refresh."""
492
+ if clip is None:
493
+ self.bitmap.dirty()
494
+ else:
495
+ x1, y1, x2, y2 = self._clip(clip)
496
+ if x1 < x2 and y1 < y2:
497
+ self.bitmap.dirty(x1, y1, x2, y2)
498
+
499
+ def redraw(self):
500
+ """The tiles and the legend, nothing else (a plain map): loads what
501
+ is still to load, then draws."""
502
+ self.load()
503
+ self.restore()
504
+ self.draw_legend()
505
+ self.dirty()
506
+
507
+ def close(self):
508
+ """Close any tile files left open by a partly loaded map."""
509
+ self._close_files()
@@ -0,0 +1,156 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ `adafruit_maptiles.png`
6
+ ================================================================================
7
+
8
+ PNG -> 8-bit BMP conversion, in the RGB332 colours that a CircuitPython
9
+ 8-bit framebuffer (e.g. ``picodvi`` with ``color_depth=8``) shows
10
+ directly. The BMP's rows are top-down and, for a 256 pixel wide tile, need
11
+ no padding, so a row of the tile is at ``BMP_HEADER + row * 256`` in the
12
+ file and can be read straight into a :class:`displayio.Bitmap`.
13
+
14
+ Converting a 256x256 tile takes ~1.5 s on an RP2350 (zlib, then a palette
15
+ lookup per pixel in Python), so it is done once per tile and the BMP kept.
16
+
17
+ Plain Python: it runs on a PC too (the tests do).
18
+ """
19
+
20
+ import struct
21
+ import zlib
22
+
23
+ from adafruit_maptiles import rgb332, rgb332_colour
24
+
25
+ BMP_HEADER = 14 + 40 + 1024
26
+ """Bytes before the pixels: file header, BITMAPINFOHEADER, 256-colour palette."""
27
+
28
+
29
+ def bmp_header(width, height):
30
+ """8-bit top-down BMP header with the RGB332 palette."""
31
+ h = bytearray(BMP_HEADER)
32
+ stride = (width + 3) & ~3
33
+ struct.pack_into("<2sIHHI", h, 0, b"BM", BMP_HEADER + stride * height, 0, 0, BMP_HEADER)
34
+ struct.pack_into(
35
+ "<IiiHHIIiiII", h, 14, 40, width, -height, 1, 8, 0, stride * height, 2835, 2835, 256, 0
36
+ )
37
+ for v in range(256):
38
+ c = rgb332_colour(v)
39
+ struct.pack_into("<BBBB", h, 54 + 4 * v, c & 255, c >> 8 & 255, c >> 16, 0)
40
+ return h
41
+
42
+
43
+ def _unfilter(f, cur, prev, bpp):
44
+ """Undo one PNG row filter in place (prev: the row above, unfiltered)."""
45
+ n = len(cur)
46
+ if f == 1:
47
+ for i in range(bpp, n):
48
+ cur[i] = (cur[i] + cur[i - bpp]) & 255
49
+ elif f == 2:
50
+ for i in range(n):
51
+ cur[i] = (cur[i] + prev[i]) & 255
52
+ elif f == 3:
53
+ for i in range(n):
54
+ a = cur[i - bpp] if i >= bpp else 0
55
+ cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 255
56
+ elif f == 4:
57
+ for i in range(n):
58
+ a = cur[i - bpp] if i >= bpp else 0
59
+ c = prev[i - bpp] if i >= bpp else 0
60
+ b = prev[i]
61
+ p = a + b - c
62
+ pa = abs(p - a)
63
+ pb = abs(p - b)
64
+ pc = abs(p - c)
65
+ cur[i] = (cur[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
66
+ elif f:
67
+ raise ValueError(f"PNG filter {f}")
68
+
69
+
70
+ def png_to_bmp(data, out): # noqa: PLR0912, PLR0914, PLR0915
71
+ """A PNG (bytes) -> an RGB332 BMP written to the open file out.
72
+ Indexed (1-8 bit), grey (1-8 bit) and 8-bit RGB, RGBA and grey+alpha;
73
+ transparent pixels become white. Returns ``(width, height)``.
74
+
75
+ :raises ValueError: not a PNG this can read (interlaced, 16-bit)
76
+ """
77
+ if bytes(data[:8]) != b"\x89PNG\r\n\x1a\n":
78
+ raise ValueError("not a PNG")
79
+ mv = memoryview(data)
80
+ pos = 8
81
+ idat = bytearray()
82
+ plte = trns = None
83
+ width = 0
84
+ while pos + 8 <= len(data):
85
+ ln = int.from_bytes(bytes(mv[pos : pos + 4]), "big")
86
+ kind = bytes(mv[pos + 4 : pos + 8])
87
+ body = mv[pos + 8 : pos + 8 + ln]
88
+ if kind == b"IHDR":
89
+ width, height, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", bytes(body))
90
+ elif kind == b"PLTE":
91
+ plte = bytes(body)
92
+ elif kind == b"tRNS":
93
+ trns = bytes(body)
94
+ elif kind == b"IDAT":
95
+ idat.extend(body)
96
+ elif kind == b"IEND":
97
+ break
98
+ pos += 12 + ln
99
+ if not width:
100
+ raise ValueError("no IHDR")
101
+ channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(ctype)
102
+ if channels is None or interlace or depth > 8 or (depth < 8 and ctype not in {0, 3}):
103
+ raise ValueError(f"PNG type {ctype} depth {depth} interlace {interlace}")
104
+ raw = zlib.decompress(idat)
105
+ del idat
106
+ stride = (width * channels * depth + 7) // 8
107
+ if len(raw) < (stride + 1) * height:
108
+ raise ValueError("PNG data short")
109
+ bpp = max(1, channels * depth // 8)
110
+ # Indexed and grey: one lookup per pixel value.
111
+ lut = None
112
+ if ctype == 3:
113
+ lut = bytearray(b"\xff" * 256)
114
+ for i in range(min(256, len(plte or b"") // 3)):
115
+ if trns is None or i >= len(trns) or trns[i] >= 128:
116
+ lut[i] = rgb332(plte[3 * i], plte[3 * i + 1], plte[3 * i + 2])
117
+ elif ctype == 0:
118
+ levels = (1 << depth) - 1
119
+ lut = bytearray(256)
120
+ for i in range(levels + 1):
121
+ v = i * 255 // levels
122
+ lut[i] = rgb332(v, v, v)
123
+ pad = (width + 3) & ~3
124
+ row = bytearray(pad)
125
+ prev = bytearray(stride)
126
+ out.write(bmp_header(width, height))
127
+ per_byte = 8 // depth
128
+ mask = (1 << depth) - 1
129
+ for y in range(height):
130
+ at = y * (stride + 1)
131
+ f = raw[at]
132
+ cur = bytearray(raw[at + 1 : at + 1 + stride])
133
+ if f:
134
+ _unfilter(f, cur, prev, bpp)
135
+ if lut is not None and depth == 8:
136
+ for x in range(width):
137
+ row[x] = lut[cur[x]]
138
+ elif lut is not None:
139
+ for x in range(width):
140
+ shift = 8 - depth * (x % per_byte + 1)
141
+ row[x] = lut[cur[x // per_byte] >> shift & mask]
142
+ elif ctype == 2:
143
+ for x in range(width):
144
+ k = 3 * x
145
+ row[x] = rgb332(cur[k], cur[k + 1], cur[k + 2])
146
+ elif ctype == 6:
147
+ for x in range(width):
148
+ k = 4 * x
149
+ row[x] = rgb332(cur[k], cur[k + 1], cur[k + 2]) if cur[k + 3] >= 128 else 0xFF
150
+ else: # grey + alpha
151
+ for x in range(width):
152
+ v = cur[2 * x]
153
+ row[x] = rgb332(v, v, v) if cur[2 * x + 1] >= 128 else 0xFF
154
+ out.write(row)
155
+ prev = cur
156
+ return width, height