diff --git a/Apps/Maps/CHANGELOG.md b/Apps/Maps/CHANGELOG.md new file mode 100644 index 0000000..5b622f3 --- /dev/null +++ b/Apps/Maps/CHANGELOG.md @@ -0,0 +1,254 @@ +# Changelog + +## 0.4.0 + +Offline navigation: routes for walking, cycling and driving, found on the device from the downloaded maps, with a +preview and turn-by-turn following. Prepared tiles are split into smaller files, so maps open and label faster. + +**Navigation** +- "Go" (Navigate here) on the sheet of a place, a house number, a waypoint and a point held on the map. The route + starts at your GPS position; without a fix the app asks: a point you tap (or the crosshair), the screen centre, or + the approximate Wi-Fi/IP position with its accuracy. +- Modes Walk, Bike and Car as icons at the top of the route preview: one tap (or W, B, C) searches the same route + for another mode, also during a search. New routes start with Settings → Route mode. Car routes follow road + types only, without turn restrictions or access rules, and the card says so. +- The map shows the whole route as a blue line with a white casing (a thick dark line on monochrome displays) and a + card with length, time and mode, and Start and Close. A failed search keeps the card with the reason. +- Start: heading-up following with a bar for the next turn and the street ("In 120 m turn left onto ..."), the + distance and time left; passed parts turn grey. Off the route for 10 s: a new route from where you are. Within + about 25 m: "You have arrived". +- The route is kept until Menu → Clear route, also across restarts. Menu → Show route and Stop navigation. +- The road graph is made per z14 tile in idle time from the tiles on the card (a `.prg` next to each tile). +- When the search runs out of memory it tries once more heading straight for the destination (the route may be a + little longer); only then do long routes stop with a message. On the T-Deck 47 of 50 Bratislava test routes on foot + and 48 of 50 by car are found (was 42 and 38). +- The map no longer blinks while a route is searched: tiles around the screen are not drawn meanwhile. +- Start and destination no longer snap to small pieces of road that lead nowhere for the mode (a car on a service road + in a pedestrian zone or on a one-way piece, a short path off the network): such searches read the whole city and + ended "too long" or "no route". A car may stop up to 500 m from the destination. +- Key hints (the letters on the buttons, "Enter: change" in lists) only on boards with a keyboard that types: + found with Tactility's device discovery, so buttons (Wio Tracker) and a trackball alone no longer count. +- While navigating your position sits low on the screen; the bar has an X to stop (Esc too), and the menu button is + below the bar. + +**Faster maps** +- Prepared tiles are three files: geometry, labels and the routing graph. Drawing reads only the geometry (a dense + city tile 127 KB → 69 KB); labels are read when they are placed, house numbers only from z17, and only the parts + of a tile near the screen. Older tiles are split in idle time ("Splitting tiles"); they keep working meanwhile. +- Label placement with POIs takes about half as long as before. + +**For developers** +- Debug script commands `routebench`, `navigate`, `waitroute` and `replay` (a GPX track as positions, to test + following indoors). Host tools `graph_tiles` and `route_tiles`, and `host/routing/check_prg.py`, which compares the + device graphs with the PC study. + +## 0.3.0 + +Points of interest and house numbers, a background work indicator, smoother turned maps, a smaller label font, and +browsed tiles that move into a download instead of being fetched again. Needs Tactility main from November 2026 +(app data in `appdata/`). + +**Points of interest and house numbers** +- Icons for cafés, shops, stops, stations, pharmacies, hotels, museums and about 120 other kinds of places, from + z15 (the most important from z14), with names next to them from z17. House numbers from z17. They follow the + Labels setting and never cover place or street names. +- Tap or click a place or number for a sheet with its name, kind, coordinates, distance and bearing, and + "Save as waypoint". With keys: show the controls, then Enter picks the place nearest the crosshair in the + middle, and each next Enter the next nearest. +- Map data from 0.2.0 is upgraded in place in idle time; no download is needed. + +**Background work** +- A small amber dot in the top-left corner while the app prepares tiles, makes fonts, updates maps, imports GPX + files or moves browsed tiles. Tap or click it for a list of what runs. + +**Map** +- A turned map is drawn smoothly (bilinear) once it stops moving; while it moves it stays fast. +- Tiles fetched while browsing move into a region you download over them instead of being downloaded again. The + download screen says how many are already on the card. + +**Touch only** +- Every function works with only a touch screen. On boards without a keyboard an on-screen keyboard opens with + every text field (new waypoint names, Rename), with Shift, digits and signs, and accented letters. +- The download frame has a round < to leave it without downloading. +- The card for tiles fetched while browsing shows only Close: they can't be paused or cancelled. + +**Downloads and background work** +- Tiles of a download are prepared later, once the map was left alone for 10 s, not right after the download. Tiles + drawn on screen are still prepared at once. +- When memory is short, preparing waits and tries again instead of giving up on the tile until the map reopens. The + tile cache and the render buffers make room for it, and get it back afterwards. +- Tiles a download moved out of the browsed tiles are no longer reported as failed preparations. +- Downloads use one connection instead of two: as fast on the T-Deck, without the TLS errors from running out of + internal RAM and the reconnects they caused, and with more internal RAM left for the rest of the app. +- Tiles fetched for the screen while browsing are written to the card at once, not only once the map is left alone. +- Tiles on screen no longer stay drawn from a coarser zoom level when PSRAM has no block large enough for their + data (low-zoom tiles are 300-450 KB before they are prepared). The tile cache makes room, or the tile is read + from the card a piece at a time; if neither works it is tried again a few seconds later. +- A tile that was drawn around the screen and then came into view with coarser data is fetched in full detail. +- The background-work dot is a solid dot without animation, and no longer flickers through the dimming of an + opened menu. + +**Label font** +- The app's label font holds just the characters map names use, plus whole Latin, Greek and Cyrillic alphabets: + 39 KB instead of 80 KB. The sizes on the card are made again once after the update. + +**Tactility** +- App data moved to `/sdcard/tactility/appdata/tactility.maps` with the new Tactility; the app moves anything + left in the old folder on first start. + +## 0.2.0 + +Labels, map rotation and heading-up, plus menu, list, trackball and download fixes. + +**Labels** +- Place, street, river, water and peak names, placed for each view so they never overlap; street names + follow the street and stay upright; a light halo. +- Settings → Labels (Off / Few / Normal), Settings → Latin names (the Latin-script name where the map data + has one). Names in scripts the label font has no letters for (Japanese, Chinese, Arabic and others) always + show their Latin-script name. +- Label fonts: the app ships a subset of Adwaita Sans with Latin (Extended-A and -B), Greek, Cyrillic and + Vietnamese letters, so names like Štefánikova, Łódź, İstanbul, Москва or Hà Nội show fully; the device + makes the fonts from it once (about 7 s in the background on first start) and keeps them on the card. +- Map data downloaded with 0.1.0 is upgraded in place in idle time, the tiles on screen first; no download + is needed. + +**Rotation** +- Q / E turn the map by 15°, N or the compass (top right, only shown on a turned map) turns it back to + north; two fingers turn it once twisted past 45° (15° when already turned), so pinching does not turn it + by accident. +- ⌖ / G now cycles: follow north up → follow with the direction of travel up (from the GPS course while + moving faster than about 1 m/s, smoothed, held when slower) → not following. +- Keys and dragging move a turned map in screen directions; labels stay upright; the download frame always + works north up. +- On monochrome and e-paper displays turning is not animated. + +**Menu and lists** +- Menu → Location switches Tactility's location service on or off (with a message). +- Enter or a tap on a row opens its actions; lists have a back arrow and a check mark in their header, + swipe right goes back; lists open without a highlight until a key, the trackball or the encoder moves. +- Menu names: Download map (Choose area, Choose region), Downloaded maps. + +**Trackball and touch** +- The trackball works as a mouse pointer on every screen, with acceleration; Settings → Pointer speed + (Slow / Normal / Fast). +- A click on the map shows or hides the controls; holding the button no longer opens the menu. Dragging a + list scrolls it. + +**Waypoints** +- A sheet to name a new waypoint (hold the map, hold ⌖, P, or Menu → Add waypoint); typing replaces the + suggested name. All pins are the same size. + +**Downloads** +- A progress pill with tiles and percentage, and a details card (tap the pill) with Pause, Resume and + Cancel, speed and time left. +- Detail fetched while browsing: tiles no longer needed after you move on are dropped from the queue (the + one downloading finishes). +- A download that fails (network or memory) tries again by itself after 30 s, up to 3 times; the message + stays 10 s. +- A framed area is named after the nearest town's Latin-script name, so the lists can show it. +- Framing an area a region already covers does not download it again: an unfinished region continues, a + finished one says so. + +**Fixes** +- The map could stop responding for many seconds while big tiles loaded. +- Zooming out kept a blank screen until tiles loaded (the previous map now stays, shrunk). +- Browsing downloads continued after panning only after a zoom change. +- The progress pill stayed after browsing ended. +- A large low-zoom tile is read from the card once instead of once per zoomed-in tile. +- Downloads failed late in a session: the large low-zoom tiles (over 1 MB) needed one free block of memory + that size. Tiles now download in 64 KB pieces, and the tile cache lends memory when even a piece does not fit. + +## 0.1.0 + +Idle updates, named regions, waypoints, track recording and GPX files, plus smooth zoom. + +**Zoom** +- + / −, I / O and double tap zoom smoothly (about a quarter of a second, around the screen centre or the + tapped point), then the sharp map appears. Pinch scales live as before. +- Off on monochrome and e-paper displays, or with Settings → Smooth zoom off (renamed from Smooth pinch). + +**Regions and downloads** +- Download area asks "This area" or "Choose region". Choose region lists continents, countries and regions + from Geofabrik's list, shipped with the app. "All of …" downloads a whole region. +- The highest zoom level that fits OpenFreeMap's 3,000-tile / 50 MB limit is chosen automatically, with the + tile count, size and zoom shown before you confirm. Only tiles inside the region's outline are fetched. +- Downloads get names: a chosen region its own name, a framed area the nearest town once downloaded. The + Regions screen shows the names; R renames (keyboard). Folders keep 8.3 names. +- Outdated regions update quietly after about 30 s without input, one tile at a time, the tiles you look at + first, with an "Updating 42%" label. Any input pauses it and it continues later, also after a restart. + Only on Wi-Fi, and while charging or above 40 % battery. +- Enter or tap on an outdated region offers "Update now". The region "browsed" is not updated. +- HTTPS connections are kept open and shared, so a new download or browsing no longer pays a 2-3 s TLS + handshake each time. + +**Waypoints** +- P, holding ⌖, Menu → Add waypoint, or holding the map on touch screens saves a waypoint. Named WP 001, + WP 002, …, shown as pins at every zoom level. +- Tap a pin for name, coordinates and distance and direction from you. Menu → Waypoints lists them by + distance; Enter or tap goes there, R renames, Del or hold deletes after asking. +- Kept in `waypoints.gpx` (GPX 1.1) in the app's folder on the card. + +**Tracks** +- Menu → Record track: start, pause, resume and stop. A red ● with the time shows top right, with the + distance when the controls are shown. +- Points every 5 m (or more than the GPS accuracy) or every 30 s while moving; approximate positions are + skipped. The track is drawn as a red line. +- Written to `tracks/YYYYMMDD-HHMM.gpx` every 30 s, so a crash loses at most 30 s. "Recorded tracks" lists + the files with their folder. +- Recording only runs while the app is open. + +**GPX files** +- Copy `.gpx` files into `/sdcard/gpx`. Menu → GPX files shows or hides them. +- Tracks blue, routes purple, waypoints as small dots. A compact `.gpc` copy is kept next to each file. + A 50,000-point track was tested on the PC, not yet on the device. + +**Trackball** +- In mouse mode the pointer shows only while the controls or the menu are open, the button under it is + highlighted, and it hides after 3 s without movement. +- A click brings it back, and a click on the map while the controls are hidden shows them. + +**Displays** +- A black-and-white style for 1-bit displays: black roads with white edges, water and parks as sparse + patterns, outlined buildings, no grey. Checked in renders on the PC only. + +**Requirements** +- Needs a board with 8 MB PSRAM for now. + +## 0.0.1 (proof of concept) + +The first public version: an offline vector map for Tactility, tested on the LilyGO T-Deck. + +**Map** +- Fullscreen map drawn by the app with LVGL stopped. LVGL comes back on every exit. +- OpenFreeMap vector tiles (OpenMapTiles schema) from the SD card: water, land cover and use, parks, buildings, + waterways, roads with casings, railways, boundaries and airports, anti-aliased. +- Zoom 0-19. Levels above 14 are drawn from the z14 vectors, so they stay sharp. +- Pan and zoom by keys (W A S D, I / O), trackball, drag, pinch, double tap and the + / − buttons. +- Your position from Tactility's location service, with an accuracy circle, a direction arrow while moving and + follow mode (G or ⌖). The app never changes location settings. +- Scale bar, and the attribution: shown in full for 5 s, then a © mark that opens it again and leads to + About and licences. +- Hidden controls (☰, + / −, ⌖) that show with a tap, Space or a click, with key hints on keyboards and a + pointer for the trackball in mouse mode. +- Opens at ESA ESTEC the first time, then where you left it. + +**Downloads** +- Download area: a fixed frame in the middle, the map moves and zooms under it. A live tile count, size estimate + and the highest zoom that fits OpenFreeMap's 3,000-tile / 50 MB limit. +- The downloader uses OpenFreeMap's TileJSON, 2 keep-alive HTTPS connections and its own User-Agent. Tiles are + written as `.tmp` and renamed. Downloads resume after a restart, areas share tiles, and the download stops at + 50 MB of real data. +- Download while browsing: with Wi-Fi, tiles on screen that have no detail yet are fetched one at a time and kept + in the region "browsed". +- Regions screen: size, date and status, with delete (tiles other regions use are kept). + +**Speed** +- Prepared tiles (`.prt`) next to each `.pbf`: only what the style draws, quantised to 2048 units per tile, + deflated. About a fifth of the size, half the render time. Converted in idle time. +- One I/O task for all SD card access, tiles on screen first. Two render lanes, a PSRAM cache of 32 rendered + tiles, and prefetch only after a second without input. +- Cold first z14 screen in dense central Budapest: 1.8-2.0 s on the T-Deck (5.6 s in the first device build). + +**Known limits** +- At most 3,000 tiles or 50 MB per download. No labels yet. Touch tested on the T-Deck only. No automatic + region updates yet. diff --git a/Apps/Maps/CMakeLists.txt b/Apps/Maps/CMakeLists.txt new file mode 100644 index 0000000..48e3904 --- /dev/null +++ b/Apps/Maps/CMakeLists.txt @@ -0,0 +1,13 @@ +# tactility-cmakelists-version: 1 +cmake_minimum_required(VERSION 3.20) + +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") +endif() + +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +tactility_project_pre(maps) +project(maps) +tactility_project_post(maps) diff --git a/Apps/Maps/LICENSE b/Apps/Maps/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/Apps/Maps/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Apps/Maps/README.md b/Apps/Maps/README.md new file mode 100644 index 0000000..8213038 --- /dev/null +++ b/Apps/Maps/README.md @@ -0,0 +1,298 @@ +# Tactility Maps + +Offline vector maps for [Tactility](https://github.com/TactilityProject/Tactility) devices. The device +downloads an area from [OpenFreeMap](https://openfreemap.org/) by itself and draws it fullscreen, with +anti-aliased roads, water, parks and buildings, from tiles on the SD card. + +Version 0.4.0, tested on a LilyGO T-Deck (ESP32-S3, 8 MB PSRAM); it also runs on ESP32-P4. Changes are listed in +[CHANGELOG.md](CHANGELOG.md). + +## Install + +Needs Tactility 0.8.0 or newer on an ESP32-S3 or ESP32-P4 board with 8 MB PSRAM, a FAT-formatted SD card, and Wi-Fi +for downloading maps. Tested on Tactility 0.8.0-dev (main, November 2026) on a LilyGO T-Deck. + +1. Install "Maps" from Tactility's App Hub. Or copy the `.app` file to the SD card, open it in the Files app and + confirm "Install". +2. Start "Maps" from the app list. The first time it opens at ESA ESTEC in Noordwijk with no map data yet: move + to your area, open the menu (M or ☰) and choose Download map. + +## How it works + +- Vector tiles (OpenMapTiles schema) are stored one file per tile in `maps////.pbf` on the SD card. +- The map stops LVGL and draws with Tactility's `pixel_buffer` API, like the built-in Terminal app. +- Tiles are inflated and decoded as a stream, never held whole in memory, and drawn with an exact-area + anti-aliasing rasteriser. Two render tasks (one per core) fill a PSRAM cache. Rendered tiles can also be + cached on the SD card (a setting, off by default). +- Zoom levels above 14 are drawn from the z14 vectors, so they stay sharp. +- Each downloaded tile also gets a prepared copy next to it (`.prt`): only what the style draws, with + quantised coordinates, grouped by draw order. It is about a fifth of the original's size and renders faster. + The original `.pbf` is never changed; it keeps names and house numbers for later features. +- One task does all SD card reads and writes, tiles on screen first. Tiles around the screen are only + prepared after a second without input, and any input cancels them. +- The position comes only from Tactility's location service. The app never changes location settings. +- Menus, lists and dialogs are drawn by the app as well, LVGL stays stopped while the app runs. + +## Using the app + +**Map screen.** The map fills the screen. Always visible: your position (●, with an accuracy +circle), the scale bar, and the © mark. + +| To… | Keyboard | Trackball | Touch | +| --- | --- | --- | --- | +| Move the map | W A S D | Roll | Drag | +| Zoom in / out | I / O | — | Pinch, double tap to zoom in, or the + / − buttons | +| Show / hide controls | Space | Click on empty map | Tap the map | +| Centre on my position and follow | G | — | ⌖ button | +| Stop following | Move the map | Roll | Drag | +| Turn the map | Q / E | — | Two fingers | +| Back to north | N | — | Compass button | +| Open a row's actions | Enter | Click | Tap the row | +| Add a waypoint | P | — | Hold ⌖ (your position) or hold the map (that point) | +| A place or house number | Show the controls, then Enter (nearest the crosshair; Enter again: next) | Click it | Tap it | +| Menu | M | Long click | ☰ button | +| Download an area | Shift + D | — | Menu → Download map | +| Leave the download frame | Esc | — | The round < in its top left corner | +| Type a name (waypoint, rename) | Keyboard | — | On-screen keyboard | +| Credits | — | Click © | Tap ©, tap again for About and licences | +| Leave the app | Esc or Backspace | Menu → Exit | Menu → Exit | + +**Touch only.** Every function works with only a touch screen. On boards without a keyboard an +on-screen keyboard opens with every text field (new waypoint names, Rename): letters with Shift (twice +for caps lock), a 123 page with digits and signs, an ÁÉ page with the accented letters common in names, +Del, Space and OK; the field moves up above it. Lists and menus scroll by dragging and go back with a +swipe to the right or their <; the download frame has its own < to leave without downloading. + +**Controls.** Hidden by default. When shown: ☰ menu top left, + and − in the middle of the right edge, ⌖ +position bottom right above the scale bar. With a keyboard each button shows its key. ⌖ is filled while +following, an outline when not, and crossed out when there is no position. With the trackball in mouse +mode the pointer shows only while the controls or the menu are open, the button under it is highlighted, +and it hides after 3 s without movement (a click brings it back). The pointer works on every screen and +speeds up with movement; Settings → Pointer speed (Slow / Normal / Fast). A click on the map shows or +hides the controls. On boards with buttons only (no keyboard, no touch), +the buttons move the focus through the controls and press the focused one. + +**Zoom.** + / −, I / O and double tap zoom smoothly (about a quarter of a second, around the screen centre, +or the tapped point for double tap), then the sharp map appears. Pinch scales live as before. This is off +on monochrome and e-paper displays, or with Settings → Smooth zoom off (the setting was called Smooth +pinch). + +**Rotation.** Q / E turn the map by 15°, N or the compass (top right, only shown on a turned map) turns +it back to north. Two fingers turn it once twisted past 45° (15° when the map is already turned), so +pinching does not turn it by accident. +⌖ / G cycles: follow north up → follow with the direction of travel up (from the GPS course while moving +faster than about 1 m/s, smoothed, held when slower) → not following. Keys and dragging move a turned map +in screen directions; labels stay upright; the download frame always works north up. On monochrome and +e-paper displays turning is not animated. + +**Labels.** Place, street, river, water and peak names are placed for each view so they never overlap; +street names follow the street and stay upright, with a light halo. Settings → Labels (Off / Few / Normal) +and Settings → Latin names (the Latin-script name where the map data has one). The app ships a subset of +Adwaita Sans with Latin, Greek, Cyrillic and Vietnamese letters, so names like Štefánikova, Łódź, +İstanbul, Москва or Hà Nội show fully; the device makes the fonts from it once (about 7 s in the +background on first start) and keeps them on the card, made again once when a new app version brings a +new font. The font holds the characters map names use (scanned from real map data of many countries) plus +whole Latin, Greek and Cyrillic alphabets, 39 KB. Map data downloaded with an older version is upgraded in +place in idle time, the tiles on screen first; no download is needed. + +**Places and house numbers.** Icons for cafés, shops, stops, stations, pharmacies, hotels, museums and +about 120 other kinds of places, coloured by kind (food orange, shops purple, transport blue, health red, +…), from z15 (stations, museums and the most important places from z14, benches and bins from z16), with +their names next to them from z17 when there is room. House numbers in small grey text from z17. Both +follow Settings → Labels (one zoom level later with Few) and never cover place or street names. Tap or +click one for a sheet with its name, kind, coordinates, distance and direction, "Save as waypoint" (the +name filled in) and Close. With keys: show the controls (a small crosshair marks the middle), then Enter +opens the place nearest the crosshair and each next Enter the next nearest; Esc closes the sheet. Enter +on the map without the controls still shows them. Icons: Maki by Mapbox (CC0). + +**Background work.** A small amber dot in the top-left corner shows while the app works in the +background: preparing or upgrading tiles, making label fonts, updating maps, importing GPX files, moving +browsed tiles into a download. It pulses on colour screens and is solid on monochrome and e-paper ones. +It hides while the progress label or the controls show. Tap or click it for a card listing what runs. +Not to be confused with the red ● of track recording. + +**Navigation.** Routes are found on the device from the maps you downloaded: no internet and no extra +download. A place, a waypoint, a house number or a point you hold on the map opens a sheet with **Go** +(Navigate here; key N or G on a place or waypoint). The route starts at your position when there is a GPS fix. +Without one the app asks where it starts: **Choose on the map** (tap the start, or move the map and press Enter +for the crosshair; Esc cancels), **Screen centre**, or **My position** when Tactility has an approximate position +(Wi-Fi or IP based; its accuracy is shown, e.g. ±25 km). It is searched in the background; the map then shows the whole route, +a bar with the three modes at the top and a card with its length, time and mode, and **Start** and **Close** (keys +S, Esc). + +- **Modes:** Walk, Bike and Car, as icons at the top. Tap one (or press W, B, C, or 1, 2, 3; P goes to the next) to + search the same route for that mode, also while a search still runs. A new route starts with the mode set in + Settings → Route mode (Walk unless changed). When a search fails the card says why and stays, so another mode can + be tried. Walk uses every road and path and ignores one-way streets; Bike leaves out motorways and + trunk roads and keeps to one-way streets; Car leaves out paths, footways, steps and tracks and keeps to one-way + streets. Time estimates: walk 4.5 km/h, bike 15 km/h, car by road type (110 km/h on motorways down to 20 km/h + on service roads). +- **Car routes follow road types only**, without turn restrictions or access rules (the map tiles have + neither): the card says so. Check signs. +- **Start** follows the route: the map turns with your direction (heading up), your position sits low on the screen + so you see further ahead, and a bar at the top shows the next turn with an arrow and the street, e.g. "In 120 m + turn left onto Štefánikova", and below it the distance and time left. The X at the right end of the bar (or Esc) + stops navigating; the menu button is below the bar. The route ahead is blue, the part you passed grey. More than 40 m off the route for 10 seconds: + "Off route — recalculating", and a new route from where you are (at most every 20 s). Within about 25 m of the + destination: "You have arrived"; following stops and the line stays. +- **Start and destination** snap to the nearest road the mode may use within 200 m (500 m for a car: the last bit is + on foot), leaving out small pieces of road that lead nowhere for that mode, such as a service road inside a + pedestrian zone for a car. +- **The route stays** until Menu → Clear route, also after leaving the app. Menu → Show route shows it and its + card again; Menu → Stop navigation stops following. +- **Limits:** a search may use about 2.5 MB of memory (less when the map holds more). When the memory runs out it + tries once more with a search that heads straight for the destination, which may give a slightly longer route; + only if that fails too, very long routes (across a big city, corner to corner) stop with "Route too long for this + device; try a shorter one". Routes are found only where the tiles were + downloaded at the highest detail (z14) and prepared: right after a download the app first prepares the new + tiles in idle time (the amber dot). Without a road within 200 m of the start or the destination, or without + a connection between them in the downloaded map, the app says so. + +**Menu:** Download map, Downloaded maps, Waypoints, Add waypoint, Record track, GPX files, Location, Settings, About and +licences, Exit. W/S, trackball or finger to move; Enter, click or tap to choose; Esc, Backspace or tap +outside to close. + +**Lists and actions.** Enter or a tap on a row opens its actions; lists have a back arrow and a check +mark in their header; swipe right or the back arrow goes back, and dragging a list scrolls it. Lists open +without a highlight until a key, the trackball or the encoder moves. + +**Downloading:** open Download map. It asks "Choose area" or "Choose region". "Choose area" keeps the frame +in the middle; move and zoom the map under it. The bar shows tiles, size and max zoom, and updates live. +Enter, click or Download starts it; Esc cancels. It downloads in the background and resumes after a +restart; areas share tiles. Tiles you already looked at while browsing are not fetched again: they move +from "browsed" into the new region (the bar says e.g. "1240 tiles, 310 already on the card"); a browsed +tile from an older map build is downloaded fresh. + +**Choose region** lists continents, countries and regions, from Geofabrik's region list shipped with the +app. Enter or tap opens a region's parts; the first row "All of …" downloads the whole of it. The app +picks the highest zoom level at which the whole region fits OpenFreeMap's limit of 3,000 tiles or 50 MB, +and shows the tile count, size and zoom level before you confirm. Only the tiles inside the region's +outline are fetched. + +**Downloaded maps:** list with name, size, date and status (complete, downloading, outdated). Select with W/S, +trackball or tap; delete with Del then confirm, or long press then Delete; R renames (needs a keyboard); +Esc goes back. Enter or tap on an outdated region offers "Update now". Deleting keeps tiles another +region still uses. + +**Region names:** a new download gets a name: a chosen region its own name; a framed area the name of the +nearest town in its map data once it has downloaded (before that, the smallest region of the list it lies +in). Downloaded maps shows the names. The folder on the card keeps a short name (8.3). + +**Browsing:** with Wi-Fi, tiles on screen that have no detail on the card yet are downloaded while you +look at them (a small "Downloading map" label shows) and kept in the region "browsed", so they work +offline later. Without Wi-Fi a short message says that detail is missing here. Only the tiles on screen +are fetched, one at a time. + +**Settings:** SD card cache on or off, prepared tiles deflated or raw, smooth zoom on or off, download +while browsing on or off. Defaults: SD card cache off (switched off, its files are deleted in idle time), +deflated, on, on. The map opens at ESA ESTEC, Noordwijk, the first time, then where you left it. + +**Updates:** OpenFreeMap rebuilds its map about weekly. A region downloaded from an older build shows +"outdated" in Downloaded maps. After about 30 s without input the app updates outdated regions quietly, one tile +at a time, the tiles you look at first; a small "Updating 42%" label shows meanwhile. Any key, touch or +trackball movement pauses it, and it continues later, also after a restart. Only on Wi-Fi, and only while +charging or above 40 % battery (boards that report no battery: Wi-Fi alone decides). Enter (or tap) on an +outdated region in Downloaded maps offers "Update now". The region "browsed" (tiles fetched while browsing) is +not updated. + +**Position:** comes from Tactility's location service (GPS or approximate). Menu → Location switches +Tactility's location service on or off, with a message. If it's off, the ⌖ button is crossed out and +pressing it says how to enable it in Tactility's settings. + +**Waypoints:** P, or holding ⌖, saves a waypoint at your position; Menu → Add waypoint saves one at the +map centre; on touch screens holding the map saves one where you hold. A sheet asks for the name of a +new waypoint; typing replaces the suggested name. Waypoints are named WP 001, WP 002, …; they show as +pins at every zoom level, all the same size. Tap a pin for its card: name, coordinates and distance and +direction from you. Esc closes the card. Menu → Waypoints lists them by distance: Enter or tap goes +there, R renames (keyboard), Del or hold deletes after asking. They are kept in `waypoints.gpx` (GPX 1.1) +in the app's folder on the card. + +**Track recording:** Menu → Record track → Start recording. A red ● with the time shows top right (with +the controls shown also the distance). The same menu entry pauses, resumes and stops. Points are taken +from the position when you moved more than 5 m (or more than the GPS accuracy), or every 30 s while +moving; approximate positions (from the network) are skipped. The track is drawn on the map as a red +line. It is written to `tracks/YYYYMMDD-HHMM.gpx` every 30 s, so a crash loses at most 30 s, and a file +left open is closed the next time the app starts. Recording only runs while the app is open (background +recording would need Tactility support; that is a question for later). "Recorded tracks" in the same menu +lists the files with their folder, so they can be copied off the card. + +**GPX files:** copy `.gpx` files into the folder `gpx` at the top of the SD card (`/sdcard/gpx`). Menu → +GPX files lists them; Enter or tap shows or hides one. Tracks are drawn blue, routes purple, waypoints +as small dots. A file is read once and a compact copy (`.gpc`) is kept next to it, so it shows quickly +next time. Files that are not GPX show an error and are skipped. Large tracks were tested on the PC (50,000 points +read in about 40 ms there), not yet on the device. + +## OpenFreeMap's rules + +OpenFreeMap is free and needs no key. Its [terms of service](https://openfreemap.org/tos/) for offline downloads +are enforced in the app: +at most 3,000 tiles and 50 MB per download (the highest zoom level that fits is chosen, the download also +stops at 50 MB of real data), at most 4 parallel requests (the app uses 2), and a User-Agent that names the +app. The tile URL is read from OpenFreeMap's TileJSON. openstreetmap.org tile servers are never used. + +## Known limits + +- One download is at most 3,000 tiles or 50 MB (OpenFreeMap's terms). In dense cities that is only a few hundred + z14 tiles, for example the centre of Budapest. Larger areas need several separate downloads, which you start + yourself. +- Labels show no characters outside Latin, Greek, Cyrillic and Vietnamese. Names in other scripts (for + example Japanese, Chinese or Arabic) show their Latin-script name instead, where the map data has one. +- Heading-up needs movement (no magnetometer: the course comes from GPS). +- Not yet tested outdoors with GPS. +- Touch, keyboard and trackball were tested on the LilyGO T-Deck only. Other boards, boards without PSRAM and + monochrome or e-paper displays are untested. +- 1-bit displays get their own black-and-white style, so far checked only in renders on the PC, not on a 1-bit device. +- The trackball in mouse mode is only lightly tested. +- Recording stops when the app closes (background recording would need Tactility support). + +## Credits + +- Map data: [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, available under the Open + Database License (ODbL). +- Vector tile schema: [OpenMapTiles](https://openmaptiles.org/). +- Tiles served free of charge by [OpenFreeMap](https://openfreemap.org/), see its + [terms](https://openfreemap.org/tos/). +- Region outlines © OpenStreetMap contributors, from Geofabrik GmbH (download.geofabrik.de), ODbL 1.0. +- Built for [Tactility](https://github.com/TactilityProject/Tactility). +- Label font: Adwaita Sans, under the SIL Open Font License 1.1; see [THIRD_PARTY.md](THIRD_PARTY.md). +- Place icons: [Maki](https://github.com/mapbox/maki) by Mapbox, CC0 1.0. + +## Attribution + +The full "© OpenMapTiles © OpenStreetMap contributors" shows for 5 seconds when the map opens and then +collapses to a small "©" mark in the bottom left corner. Tapping or clicking it shows the text again for 5 +seconds, tapping the text opens "About and licences", which is also in the menu. This follows the OSMF +attribution guidelines for small screens. + +## Building + +Needs ESP-IDF 6.1 and the Tactility SDK matching the firmware on the device. + +``` +python /tt.py app build +python /tt.py app install --host +python /tt.py app run --host +``` + +The PC build (`host/`, CMake) renders tiles to PNG and runs the unit tests: + +``` +cmake -S host -B host/build +cmake --build host/build --config Release +host/build/Release/render_tile 47.4979 19.0402 14 640 480 out.png +host/build/Release/tests +host/build/Release/prepare_tiles [--write] [--deflate] [--zoom 16] +host/build/Release/ui_preview [--mono] +``` + +`prepare_tiles` converts the tiles of a folder into prepared tiles and compares size, render time and pixels +with the originals (`--write` stores the `.prt` files, which `render_tile --prepared` then draws from). +`ui_preview` draws the app's screens over a fixture tile to PNG, with blocks in place of text. + +`host/fetch_tiles.py` downloads a test area on the PC (same OpenFreeMap rules), `host/push_region.py` +copies it to a device through the development server. + +## Licence + +GPL-3.0-or-later, the same as Tactility; see [LICENSE](LICENSE). Third-party code and data are listed in [THIRD_PARTY.md](THIRD_PARTY.md). No code was copied from +the projects listed as prior art in the plan. diff --git a/Apps/Maps/THIRD_PARTY.md b/Apps/Maps/THIRD_PARTY.md new file mode 100644 index 0000000..b70d25d --- /dev/null +++ b/Apps/Maps/THIRD_PARTY.md @@ -0,0 +1,21 @@ +# Third-party code + +| Component | Where | Licence | Notes | +|---|---|---|---| +| miniz 3.0.2 (Rich Geldreich, RAD Game Tools, Valve Software) | `host/third_party/miniz/` | MIT, see `host/third_party/miniz/LICENSE` | PC build only: inflate and PNG writing for the host tools and tests. The app uses the `tinfl_decompress` exported by Tactility. | +| Adwaita Sans (GNOME [adwaita-fonts](https://github.com/GNOME/adwaita-fonts), derived from Inter by the Inter Project Authors) | `assets/fonts/LabelSans.ttf` | SIL Open Font License 1.1, https://raw.githubusercontent.com/GNOME/adwaita-fonts/refs/heads/main/LICENSE | A subset (Basic Latin, Latin-1, Latin Extended-A, U+0218-021B, U+0259, Greek U+0386-03CE, Cyrillic U+0400-045F and U+0490-0491, plus the characters listed in `tools/label_codepoints.txt`, which map names use; weight 550, no hinting or layout tables) made by `host/make_label_font.py` from the font in Tactility's repository. The app makes its map label fonts from it on the device. The name table of the file names the licence. | + +| [Maki](https://github.com/mapbox/maki) icons (Mapbox) | `main/Source/render/PoiIconData.h` | CC0 1.0 Universal | 89 icons turned into 15 x 15 alpha masks by `host/make_poi_icons.py`. | + +`main/Source/app/RootCertificates.h` holds the public root certificates of Google Trust Services (GTS Root R1, R4) +and Let's Encrypt (ISRG Root X1, X2), as published at pki.goog and letsencrypt.org, used to verify the HTTPS +connection to OpenFreeMap. + +The rasteriser in `main/Source/render/Raster.cpp` is written for this project. It uses the published signed-area +coverage technique (as described for font-rs and stb_truetype), no code was copied. + +# Map data + +Test fixtures in `tests/fixtures/` are vector tiles from [OpenFreeMap](https://openfreemap.org/), in the +OpenMapTiles schema. © OpenMapTiles © OpenStreetMap contributors. OpenStreetMap data is available under the +Open Database License (ODbL). diff --git a/Apps/Maps/assets/fonts/LabelSans.ttf b/Apps/Maps/assets/fonts/LabelSans.ttf new file mode 100644 index 0000000..4c215af Binary files /dev/null and b/Apps/Maps/assets/fonts/LabelSans.ttf differ diff --git a/Apps/Maps/assets/regions.bin b/Apps/Maps/assets/regions.bin new file mode 100644 index 0000000..8afaac4 Binary files /dev/null and b/Apps/Maps/assets/regions.bin differ diff --git a/Apps/Maps/main/CMakeLists.txt b/Apps/Maps/main/CMakeLists.txt new file mode 100644 index 0000000..b11441b --- /dev/null +++ b/Apps/Maps/main/CMakeLists.txt @@ -0,0 +1,8 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) +tactility_component_register( + SRCS ${SOURCE_FILES} + REQUIRES graphics-module binfont-module location-module http-module esp_http_client esp_rom mbedtls +) +target_compile_options(${COMPONENT_LIB} PRIVATE -O2 -Wno-format-truncation) diff --git a/Apps/Maps/main/Source/app/BinFontText.cpp b/Apps/Maps/main/Source/app/BinFontText.cpp new file mode 100644 index 0000000..667d684 --- /dev/null +++ b/Apps/Maps/main/Source/app/BinFontText.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "BinFontText.h" + +#include "../platform/Platform.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace maps { + +namespace { + +struct FontSearch { + const char* prefix; + char name[96]; +}; + +bool findFont(const char* name, bool isDirectory, void* context) { + auto* search = static_cast(context); + if (!isDirectory && strncmp(name, search->prefix, strlen(search->prefix)) == 0) { + snprintf(search->name, sizeof(search->name), "%s", name); + return false; + } + return true; +} + +/** Decodes one UTF-8 character and advances the pointer. */ +uint32_t nextCodepoint(const char*& text) { + const auto first = static_cast(*text++); + if (first < 0x80) { + return first; + } + if ((first & 0xE0) == 0xC0 && *text != '\0') { + return ((first & 0x1Fu) << 6) | (static_cast(*text++) & 0x3Fu); + } + // Longer sequences are skipped and shown as '?' + while ((static_cast(*text) & 0xC0) == 0x80) { + text++; + } + return '?'; +} + +} // namespace + +bool BinFontText::open(const char* folder, const char* const* prefixes, int prefixCount) { + for (int i = 0; i < prefixCount; i++) { + FontSearch search { prefixes[i], "" }; + listDirectory(folder, findFont, &search); + if (search.name[0] == '\0') { + continue; + } + char path[260]; + snprintf(path, sizeof(path), "%s/%s", folder, search.name); + if (binfont_open_file(path, &font) == ERROR_NONE) { + owned = true; + BinFontMetrics metrics; + binfont_get_metrics(font, &metrics); + height = metrics.line_height; + ascentPixels = metrics.ascent; + logInfo("Font %s, line height %d", search.name, height); + return true; + } + font = nullptr; + } + return false; +} + +bool BinFontText::openFile(const char* path) { + close(); + if (binfont_open_file(path, &font) != ERROR_NONE) { + font = nullptr; + return false; + } + owned = true; + BinFontMetrics metrics; + binfont_get_metrics(font, &metrics); + height = metrics.line_height; + ascentPixels = metrics.ascent; + logInfo("Font file %s, line height %d", path, height); + return true; +} + +bool BinFontText::borrow(int lvglFontSize) { + const lv_font_t* lvFont = lvgl_get_text_font(static_cast(lvglFontSize)); + // Tactility's fonts come from lvgl_binfont_create(), which keeps the BinFont in dsc. Without a registered font + // LVGL's built-in one comes back, whose dsc is something else. + if (lvFont == nullptr || lvFont->dsc == nullptr || lvFont->get_glyph_dsc == lv_font_get_glyph_dsc_fmt_txt) { + return false; + } + font = static_cast(const_cast(lvFont->dsc)); + owned = false; + BinFontMetrics metrics; + binfont_get_metrics(font, &metrics); + height = metrics.line_height; + ascentPixels = metrics.ascent; + logInfo("Font from the system, size %d, line height %d", lvglFontSize, height); + return true; +} + +void BinFontText::close() { + if (target != nullptr) { + pixel_buffer_free(target); + target = nullptr; + targetPixels = nullptr; + } + if (font != nullptr && owned) { + binfont_close(font); + } + font = nullptr; + owned = false; +} + +int BinFontText::textWidth(const char* text) { + if (font == nullptr) { + return 0; + } + // The same advances and kerning binfont_draw_text_rgb565() applies + int32_t widthX16 = 0; + uint32_t previous = 0; + bool hasPrevious = false; + while (*text != '\0') { + BinFontGlyph glyph; + if (!binfont_get_glyph(font, nextCodepoint(text), &glyph)) { + continue; + } + if (hasPrevious) { + widthX16 += binfont_get_kerning_x16(font, previous, glyph.glyph_id); + } + widthX16 += static_cast(glyph.advance_x16); + previous = glyph.glyph_id; + hasPrevious = true; + } + return (widthX16 + 8) >> 4; +} + +void BinFontText::drawText(Canvas& canvas, int x, int y, const char* text, uint16_t color) { + if (font == nullptr) { + return; + } + if (target == nullptr || targetPixels != canvas.pixels) { + if (target != nullptr) { + pixel_buffer_free(target); + } + target = pixel_buffer_wrap(canvas.pixels, DISPLAY_COLOR_FORMAT_RGB565, canvas.width, canvas.height, 0); + targetPixels = target != nullptr ? canvas.pixels : nullptr; + if (target == nullptr) { + return; + } + } + binfont_draw_text_rgb565(target, x, y + ascentPixels, font, text, color, PIXEL_BUFFER_CONVERSION_AVERAGE); +} + +bool BinFontText::glyph(uint32_t codepoint, LabelGlyph& out, bool withMask) { + BinFontGlyph found; + if (font == nullptr || !binfont_get_glyph(font, codepoint, &found)) { + return false; + } + out.id = found.glyph_id; + out.advanceX16 = static_cast(found.advance_x16); + out.left = found.x; + // binfont gives the box's bottom above the baseline, labels want its top below it + out.top = -(found.y + found.height); + out.width = found.width; + out.height = found.height; + out.alpha = nullptr; + if (withMask && found.width > 0 && found.height > 0 && found.width * found.height <= MASK_BYTES && + binfont_get_glyph_bitmap(font, &found, mask, found.width) == ERROR_NONE) { + out.alpha = mask; + } + return true; +} + +int BinFontText::kerningX16(uint32_t leftId, uint32_t rightId) { + return font != nullptr ? binfont_get_kerning_x16(font, leftId, rightId) : 0; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/BinFontText.h b/Apps/Maps/main/Source/app/BinFontText.h new file mode 100644 index 0000000..627a24f --- /dev/null +++ b/Apps/Maps/main/Source/app/BinFontText.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../render/LabelLayout.h" +#include "../ui/Canvas.h" + +#include + +struct BinFont; +struct PixelBuffer; + +namespace maps { + +/** + * Text in one of Tactility's cached fonts (/cache/fonts), drawn with the binfont-module. As a LabelFont it gives + * glyph masks for map labels. + */ +class BinFontText final : public Font, public LabelFont { +public: + ~BinFontText() override { close(); } + + /** + * Opens the first font whose file name starts with one of the prefixes. Call it on the I/O task: the binfont-module + * loads the whole file into PSRAM, so drawing never reads the card. + */ + bool open(const char* folder, const char* const* prefixes, int prefixCount); + /** Opens one font file (read whole into PSRAM, so on the I/O task). */ + bool openFile(const char* path); + /** + * Uses the text font Tactility's UI uses (an LvglFontSize), without a copy. Firmware builds with the fonts inside + * have no font files on the card, so this comes first. Fails when that font is not a BinFont. + */ + bool borrow(int lvglFontSize); + void close(); + bool isOpen() const { return font != nullptr; } + + int lineHeight() const override { return font != nullptr ? height : 10; } + int ascent() const override { return ascentPixels; } + bool glyph(uint32_t codepoint, LabelGlyph& out, bool withMask) override; + int kerningX16(uint32_t leftId, uint32_t rightId) override; + int textWidth(const char* text) override; + void drawText(Canvas& canvas, int x, int y, const char* text, uint16_t color) override; + +private: + BinFont* font = nullptr; + /** Borrowed fonts belong to Tactility and are not closed. */ + bool owned = false; + int height = 0; + int ascentPixels = 0; + /** The mask of the last glyph asked for with withMask; labels use fonts of at most about 30 px. */ + static constexpr int MASK_BYTES = 48 * 48; + uint8_t mask[MASK_BYTES] {}; + /** The canvas the text was last drawn on, wrapped once as a PixelBuffer. */ + PixelBuffer* target = nullptr; + uint16_t* targetPixels = nullptr; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/BrowsedCounter.cpp b/Apps/Maps/main/Source/app/BrowsedCounter.cpp new file mode 100644 index 0000000..4366318 --- /dev/null +++ b/Apps/Maps/main/Source/app/BrowsedCounter.cpp @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "BrowsedCounter.h" + +#include "../platform/Platform.h" +#include "../tiles/TileStore.h" + +#include +#include + +#include + +#include +#include +#include + +namespace maps { + +namespace { + +constexpr uint32_t TASK_STACK = 4096; +constexpr UBaseType_t TASK_PRIORITY = 2; + +struct CountJob { + const char* root; + DownloadPlan plan; + int64_t count; + bool complete; +}; + +/** Reads a folder name or file stem as a tile number; -1 when it is not one. */ +int tileNumber(const char* name, const char* suffix) { + char* end = nullptr; + const long value = strtol(name, &end, 10); + if (end == name || value < 0 || strcmp(end, suffix) != 0) { + return -1; + } + return static_cast(value); +} + +bool countJob(IoTask& io, void* context) { + auto* job = static_cast(context); + job->count = 0; + job->complete = false; + for (int z = job->plan.minZoom; z <= job->plan.maxZoom; z++) { + const TileRange range = tileRange(job->plan.box, z); + char folder[220]; + snprintf(folder, sizeof(folder), "%s/maps/%s/%d", job->root, BROWSE_REGION, z); + DIR* columns = opendir(folder); + if (columns == nullptr) { + continue; + } + // The column folders first, then the files of the ones inside the plan: never a file opened + int inside[96]; + int insideCount = 0; + bool more = false; + for (dirent* entry = readdir(columns); entry != nullptr; entry = readdir(columns)) { + const int x = tileNumber(entry->d_name, ""); + if (x >= range.minX && x <= range.maxX) { + if (insideCount < 96) { + inside[insideCount++] = x; + } else { + more = true; + } + } + } + closedir(columns); + (void)more; + for (int i = 0; i < insideCount; i++) { + // Tiles on screen come first: give the card back and say the count is not complete + if (io.shouldYield()) { + return true; + } + char column[240]; + snprintf(column, sizeof(column), "%s/%d", folder, inside[i]); + DIR* files = opendir(column); + if (files == nullptr) { + continue; + } + for (dirent* entry = readdir(files); entry != nullptr; entry = readdir(files)) { + const int y = tileNumber(entry->d_name, ".pbf"); + if (y >= range.minY && y <= range.maxY) { + job->count++; + } + } + closedir(files); + } + } + job->complete = true; + return true; +} + +} // namespace + +BrowsedCounter::~BrowsedCounter() { + stop(); +} + +void BrowsedCounter::configure(IoTask& ioTask, const char* root) { + io = &ioTask; + snprintf(rootPath, sizeof(rootPath), "%s", root); +} + +bool BrowsedCounter::samePlan(const DownloadPlan& a, const DownloadPlan& b) { + return a.minZoom == b.minZoom && a.maxZoom == b.maxZoom && a.box.west == b.box.west && a.box.east == b.box.east && + a.box.south == b.box.south && a.box.north == b.box.north; +} + +void BrowsedCounter::request(const DownloadPlan& plan, uint32_t nowMs) { + if (!hasWanted || !samePlan(plan, wanted)) { + wanted = plan; + hasWanted = true; + wantedSince = nowMs; + } +} + +bool BrowsedCounter::count(const DownloadPlan& plan, int64_t& out) const { + if (!hasCount || !samePlan(plan, counted)) { + return false; + } + out = countedResult; + return true; +} + +void BrowsedCounter::taskMain(void* context) { + auto* counter = static_cast(context); + CountJob job { counter->rootPath, counter->counting, 0, false }; + // A count that gave way to the map is tried again a little later + for (int attempt = 0; attempt < 40; attempt++) { + if (!counter->io->run(IoPriority::Background, countJob, &job) || job.complete) { + break; + } + vTaskDelay(pdMS_TO_TICKS(100)); + } + counter->countingResult = job.count; + counter->countingComplete = job.complete; + counter->finished = true; + // Deleted by poll() or stop(): a task cannot free its own PSRAM stack + vTaskSuspend(nullptr); +} + +bool BrowsedCounter::poll(uint32_t nowMs) { + bool changed = false; + if (task != nullptr && finished) { + vTaskDeleteWithCaps(task); + task = nullptr; + if (!countingComplete) { + // Gave way to the map too often: try again after a pause + wantedSince = nowMs; + } else { + counted = counting; + countedResult = countingResult; + hasCount = true; + changed = true; + } + } + if (task == nullptr && io != nullptr && hasWanted && nowMs - wantedSince >= SETTLE_MS && !(hasCount && samePlan(wanted, counted)) && + wanted.fits && wanted.tiles > 0) { + counting = wanted; + finished = false; + if (xTaskCreatePinnedToCoreWithCaps(taskMain, "maps-count", TASK_STACK, this, TASK_PRIORITY, &task, tskNO_AFFINITY, + MALLOC_CAP_SPIRAM) != pdPASS) { + task = nullptr; + // Not counted: the estimate simply shows no tiles on the card + hasWanted = false; + } + } + return changed; +} + +void BrowsedCounter::stop() { + if (task == nullptr) { + return; + } + while (!finished) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + vTaskDeleteWithCaps(task); + task = nullptr; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/BrowsedCounter.h b/Apps/Maps/main/Source/app/BrowsedCounter.h new file mode 100644 index 0000000..8f4b57a --- /dev/null +++ b/Apps/Maps/main/Source/app/BrowsedCounter.h @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IoTask.h" + +#include "../tiles/DownloadPlan.h" + +#include +#include + +#include +#include + +namespace maps { + +/** + * Counts the tiles of a download plan that are already in the browsed region, for the download screen's estimate + * ("1240 tiles, 310 already on the card"): those move into the region instead of being downloaded. + * + * The count lists only the browsed column folders inside the plan (one folder read per tile column, no file is + * opened), on the I/O task at background priority, from a short-lived task of its own so the map loop never waits. + * The map loop calls request() with the plan on screen and poll() every pass. + */ +class BrowsedCounter { +public: + ~BrowsedCounter(); + void configure(IoTask& ioTask, const char* root); + /** The plan on screen now; a count starts once the plan has stayed the same for SETTLE_MS. */ + void request(const DownloadPlan& plan, uint32_t nowMs); + /** Starts and reaps the counting task; true when a new result came in (redraw). */ + bool poll(uint32_t nowMs); + /** The count for this plan, when it is known. */ + bool count(const DownloadPlan& plan, int64_t& out) const; + void stop(); + + static constexpr uint32_t SETTLE_MS = 400; + +private: + static void taskMain(void* context); + static bool samePlan(const DownloadPlan& a, const DownloadPlan& b); + + IoTask* io = nullptr; + char rootPath[160] = ""; + TaskHandle_t task = nullptr; + std::atomic finished { false }; + /** The plan wanted on screen, since when, and the one being counted. */ + DownloadPlan wanted {}; + bool hasWanted = false; + uint32_t wantedSince = 0; + DownloadPlan counting {}; + int64_t countingResult = 0; + bool countingComplete = false; + /** The last finished count. */ + DownloadPlan counted {}; + int64_t countedResult = 0; + bool hasCount = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/Debug.cpp b/Apps/Maps/main/Source/app/Debug.cpp new file mode 100644 index 0000000..b07cf8e --- /dev/null +++ b/Apps/Maps/main/Source/app/Debug.cpp @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Debug.h" + +#include "../platform/Platform.h" + +#include +#include + +namespace maps { + +namespace { + +void writeLittleEndian(uint8_t* out, uint32_t value, int bytes) { + for (int i = 0; i < bytes; i++) { + out[i] = static_cast(value >> (8 * i)); + } +} + +} // namespace + +bool saveBmp(const char* path, const uint16_t* pixels, int width, int height) { + FILE* file = fopen(path, "wb"); + if (file == nullptr) { + return false; + } + const int rowBytes = (width * 3 + 3) & ~3; + uint8_t header[54] = { 'B', 'M' }; + writeLittleEndian(header + 2, 54 + rowBytes * height, 4); + writeLittleEndian(header + 10, 54, 4); + writeLittleEndian(header + 14, 40, 4); + writeLittleEndian(header + 18, width, 4); + writeLittleEndian(header + 22, height, 4); + writeLittleEndian(header + 26, 1, 2); + writeLittleEndian(header + 28, 24, 2); + bool ok = fwrite(header, 1, sizeof(header), file) == sizeof(header); + auto* row = static_cast(largeAlloc(rowBytes)); + if (row == nullptr) { + fclose(file); + return false; + } + memset(row, 0, rowBytes); + for (int y = height - 1; y >= 0 && ok; y--) { + for (int x = 0; x < width; x++) { + const uint16_t c = pixels[y * width + x]; + row[x * 3 + 0] = static_cast((c & 0x1F) * 255 / 31); + row[x * 3 + 1] = static_cast(((c >> 5) & 0x3F) * 255 / 63); + row[x * 3 + 2] = static_cast(((c >> 11) & 0x1F) * 255 / 31); + } + ok = fwrite(row, 1, rowBytes, file) == static_cast(rowBytes); + } + largeFree(row); + fclose(file); + return ok; +} + +DebugScript::~DebugScript() { + largeFree(text); +} + +bool DebugScript::open(const char* root) { + char path[160]; + snprintf(path, sizeof(path), "%s/debug.txt", root); + FILE* file = fopen(path, "r"); + if (file == nullptr) { + return false; + } + constexpr size_t MAX_SCRIPT = 128 * 1024; + text = static_cast(largeAlloc(MAX_SCRIPT)); + if (text != nullptr) { + const size_t length = fread(text, 1, MAX_SCRIPT - 1, file); + text[length] = '\0'; + position = 0; + logInfo("Playing %s", path); + } + fclose(file); + return text != nullptr; +} + +DebugScript::Step DebugScript::next(uint32_t now) { + Step step {}; + step.action = Action::None; + if (text == nullptr || static_cast(now - resumeAt) < 0) { + return step; + } + char line[96]; + while (text[position] != '\0') { + size_t length = 0; + while (text[position] != '\0' && text[position] != '\n') { + if (length + 1 < sizeof(line)) { + line[length++] = text[position]; + } + position++; + } + line[length] = '\0'; + if (text[position] == '\n') { + position++; + } + char command[16] = ""; + char argument[48] = ""; + if (sscanf(line, "%15s %47[^\r\n]", command, argument) < 1 || command[0] == '#') { + continue; + } + logInfo("debug: %s %s", command, argument); + if (strcmp(command, "wait") == 0) { + resumeAt = now + static_cast(atoi(argument)); + return step; + } + if (strcmp(command, "key") == 0) { + step.action = Action::Key; + if (strcmp(argument, "esc") == 0) { + step.key = 0x1B; + } else if (strcmp(argument, "enter") == 0) { + step.key = 10; + } else if (strcmp(argument, "bs") == 0) { + step.key = '\b'; + } else { + step.key = static_cast(argument[0]); + } + return step; + } + if (strcmp(command, "shot") == 0) { + step.action = Action::Shot; + snprintf(step.name, sizeof(step.name), "%s", argument); + return step; + } + if (strcmp(command, "goto") == 0 && sscanf(argument, "%lf %lf %d", &step.lat, &step.lon, &step.zoom) == 3) { + step.action = Action::Goto; + return step; + } + if (strcmp(command, "idle") == 0) { + step.action = Action::WaitIdle; + return step; + } + if (strcmp(command, "exit") == 0) { + step.action = Action::Exit; + return step; + } + if (strcmp(command, "touchonly") == 0) { + step.action = Action::TouchOnly; + return step; + } + if (strcmp(command, "squeeze") == 0) { + step.action = Action::Squeeze; + step.zoom = atoi(argument); + return step; + } + // The profile as a number in zoom: 0 walk, 1 bike, 2 car, 3 all + auto profileOf = [](const char* text) { + return strcmp(text, "bike") == 0 ? 1 : (strcmp(text, "car") == 0 ? 2 : (strcmp(text, "all") == 0 ? 3 : 0)); + }; + char word[16] = ""; + if (strcmp(command, "routebench") == 0 && sscanf(argument, "%31s %15s", step.name, word) >= 1) { + step.action = Action::RouteBench; + step.zoom = profileOf(word); + return step; + } + if (strcmp(command, "navigate") == 0 && sscanf(argument, "%lf %lf %15s", &step.lat, &step.lon, word) >= 2) { + step.action = Action::Navigate; + step.zoom = profileOf(word); + return step; + } + if (strcmp(command, "waitroute") == 0) { + step.action = Action::WaitRoute; + return step; + } + if (strcmp(command, "replay") == 0 && sscanf(argument, "%31s", step.name) == 1) { + step.action = Action::Replay; + return step; + } + float v[6] = {}; + const int found = sscanf(argument, "%f %f %f %f %f %f", &v[0], &v[1], &v[2], &v[3], &v[4], &v[5]); + struct TouchCommand { + const char* name; + TouchKind kind; + int values; + bool hasDuration; + }; + static constexpr TouchCommand TOUCH_COMMANDS[] = { + { "tap", TouchKind::Tap, 2, false }, { "hold", TouchKind::Hold, 2, true }, { "drag", TouchKind::Drag, 4, true }, + { "pinch", TouchKind::Pinch, 4, true }, { "twist", TouchKind::Twist, 5, true }, + }; + for (const TouchCommand& touch : TOUCH_COMMANDS) { + if (strcmp(command, touch.name) == 0 && found >= touch.values + (touch.hasDuration ? 1 : 0)) { + step.action = Action::Touch; + step.touch = touch.kind; + for (int i = 0; i < touch.values; i++) { + step.values[i] = v[i]; + } + step.durationMs = touch.hasDuration ? static_cast(v[touch.values]) : 60; + // The next line waits until the fingers lifted and the gesture code had a moment + resumeAt = now + step.durationMs + 120; + return step; + } + } + } + largeFree(text); + text = nullptr; + return step; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/Debug.h b/Apps/Maps/main/Source/app/Debug.h new file mode 100644 index 0000000..fde62d7 --- /dev/null +++ b/Apps/Maps/main/Source/app/Debug.h @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace maps { + +/** Writes an RGB565 frame as a 24-bit BMP file, for screenshots while LVGL is stopped. */ +bool saveBmp(const char* path, const uint16_t* pixels, int width, int height); + +/** + * Plays /debug.txt when it exists, so the app can be tested without touching the device. + * Lines: "key ", "wait ", "shot ", "goto ", "idle" (waits until the view is complete), "exit". + * Touch, fed to the same gesture code as the touch screen (the script waits until a touch has ended): + * "tap ", "hold ", "drag ", "pinch " (finger distance + * from d1 to d2), "twist " (two fingers at distance 2r turning from a1 to a2 degrees). + * "touchonly": from then on the app acts as a board with only a touch screen (keyboards and pointers ignored). + * "squeeze ": PSRAM is held in 64 KB pieces until no free block is that large, to test drawing with little memory. + * Navigation (M5c): "routebench " runs the route search over the pairs of a file in the app's + * folder (host/routing/export_pairs.py) and logs each; "navigate " asks for a route from the + * position (or the screen centre) as "Navigate here" does; "waitroute" waits until no search runs; "replay " + * plays a GPX track from the app's folder as positions, one point per second ("replay stop" ends it). + */ +class DebugScript { +public: + enum class Action { + None, + Key, + Shot, + Goto, + WaitIdle, + Exit, + Touch, + TouchOnly, + /** "squeeze ": PSRAM is held in pieces until no free block is that large, as after a long session. */ + Squeeze, + RouteBench, + Navigate, + WaitRoute, + Replay + }; + + enum class TouchKind : uint8_t { Tap, Hold, Drag, Pinch, Twist }; + + struct Step { + Action action; + uint32_t key; + char name[32]; + double lat; + double lon; + int zoom; + TouchKind touch; + /** Tap, hold: x, y. Drag: x1, y1, x2, y2. Pinch: cx, cy, d1, d2. Twist: cx, cy, r, a1, a2. */ + float values[5]; + uint32_t durationMs; + }; + + ~DebugScript(); + /** Reads the whole script into memory. Call it on the I/O task. */ + bool open(const char* root); + bool isActive() const { return text != nullptr; } + /** Returns the next step once its wait is over, or Action::None. */ + Step next(uint32_t now); + +private: + char* text = nullptr; + size_t position = 0; + uint32_t resumeAt = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/DisplayOutput.cpp b/Apps/Maps/main/Source/app/DisplayOutput.cpp new file mode 100644 index 0000000..6cc16b2 --- /dev/null +++ b/Apps/Maps/main/Source/app/DisplayOutput.cpp @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "DisplayOutput.h" + +#include "../platform/Platform.h" + +#include +#include + +#include + +namespace maps { + +namespace { + +/** + * Strips are in internal DMA memory. In PSRAM the SPI driver copied every strip into a new internal DMA buffer of the + * same size, which failed while a download used most of the internal RAM, and the map flickered. + */ +constexpr int STRIP_ROWS = 12; + +} // namespace + +DisplayOutput::~DisplayOutput() { + end(); +} + +bool DisplayOutput::begin(Device* displayDevice) { + display = displayDevice; + panelWidth = display_get_resolution_x(display); + panelHeight = display_get_resolution_y(display); + format = display_get_color_format(display); + monochrome = format == DISPLAY_COLOR_FORMAT_MONOCHROME || format == DISPLAY_COLOR_FORMAT_GRAYSCALE8; + fullFrame = format == DISPLAY_COLOR_FORMAT_MONOCHROME || display_has_capability(display, DISPLAY_CAPABILITY_REQUIRES_FULL_FRAME); + + if (display_get_frame_buffer_count(display) == 2) { + void* first = nullptr; + void* second = nullptr; + display_get_frame_buffer(display, 0, &first); + display_get_frame_buffer(display, 1, &second); + if (first != nullptr && second != nullptr) { + hwBuffers[0] = pixel_buffer_wrap(first, format, panelWidth, panelHeight, 0); + hwBuffers[1] = pixel_buffer_wrap(second, format, panelWidth, panelHeight, 0); + } + if (hwBuffers[0] != nullptr && hwBuffers[1] != nullptr) { + logInfo("Display %dx%d format %d, hardware double buffer", panelWidth, panelHeight, format); + return true; + } + pixel_buffer_free(hwBuffers[0]); + pixel_buffer_free(hwBuffers[1]); + hwBuffers[0] = nullptr; + hwBuffers[1] = nullptr; + } + + if (fullFrame) { + frameBuffer = pixel_buffer_create(format, panelWidth, panelHeight); + logInfo("Display %dx%d format %d, full frames", panelWidth, panelHeight, format); + return frameBuffer != nullptr; + } + + const MemoryPolicy stripPolicy = { .required = MEMORY_CAPABILITY_INTERNAL | MEMORY_CAPABILITY_DMA, .desired = 0, .alignment = 64 }; + strips[0] = pixel_buffer_create_with_policy(format, panelWidth, STRIP_ROWS, &stripPolicy); + strips[1] = pixel_buffer_create_with_policy(format, panelWidth, STRIP_ROWS, &stripPolicy); + logInfo("Display %dx%d format %d, strips of %d rows", panelWidth, panelHeight, format, STRIP_ROWS); + return strips[0] != nullptr && strips[1] != nullptr; +} + +void DisplayOutput::end() { + for (auto*& buffer : hwBuffers) { + pixel_buffer_free(buffer); + buffer = nullptr; + } + for (auto*& buffer : strips) { + pixel_buffer_free(buffer); + buffer = nullptr; + } + pixel_buffer_free(frameBuffer); + frameBuffer = nullptr; + display = nullptr; +} + +void DisplayOutput::present(uint16_t* frame, int y, int height) { + if (display == nullptr) { + return; + } + PixelBuffer* source = pixel_buffer_wrap(frame, DISPLAY_COLOR_FORMAT_RGB565, panelWidth, panelHeight, 0); + if (source == nullptr) { + return; + } + const auto conversion = monochrome ? PIXEL_BUFFER_CONVERSION_LUMA_THRESHOLD : PIXEL_BUFFER_CONVERSION_AVERAGE; + + if (hwBuffers[0] != nullptr) { + // The whole frame goes to the back buffer, so it never shows an older frame in the other region + PixelBuffer* back = hwBuffers[backBuffer]; + pixel_buffer_blit(back, 0, 0, source, 0, 0, panelWidth, panelHeight, conversion); + pixel_buffer_msync(back, 0, 0, panelWidth, panelHeight); + display_draw_bitmap(display, 0, 0, panelWidth, panelHeight, pixel_buffer_get_data(back)); + backBuffer = 1 - backBuffer; + } else if (frameBuffer != nullptr) { + pixel_buffer_blit(frameBuffer, 0, y, source, 0, y, panelWidth, height, conversion); + pixel_buffer_msync(frameBuffer, 0, 0, panelWidth, panelHeight); + display_draw_bitmap(display, 0, 0, panelWidth, panelHeight, pixel_buffer_get_data(frameBuffer)); + } else { + for (int row = y; row < y + height; row += STRIP_ROWS) { + const int rows = std::min(STRIP_ROWS, y + height - row); + PixelBuffer* strip = strips[nextStrip]; + nextStrip = 1 - nextStrip; + pixel_buffer_blit(strip, 0, 0, source, 0, row, panelWidth, rows, conversion); + pixel_buffer_msync(strip, 0, 0, panelWidth, rows); + display_draw_bitmap(display, 0, row, panelWidth, row + rows, pixel_buffer_get_data(strip)); + } + } + pixel_buffer_free(source); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/DisplayOutput.h b/Apps/Maps/main/Source/app/DisplayOutput.h new file mode 100644 index 0000000..eddfdef --- /dev/null +++ b/Apps/Maps/main/Source/app/DisplayOutput.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +#include + +struct PixelBuffer; + +namespace maps { + +/** + * Sends an RGB565 frame to the panel in its own colour format. Uses the hardware double buffer when the driver + * has one, a full frame buffer when the panel needs full frames, and otherwise small strips. + */ +class DisplayOutput { +public: + ~DisplayOutput(); + + bool begin(Device* displayDevice); + void end(); + + /** Sends rows y to y + height of a frame of width() x height() RGB565 pixels. */ + void present(uint16_t* frame, int y, int height); + + int width() const { return panelWidth; } + int height() const { return panelHeight; } + bool isMonochrome() const { return monochrome; } + +private: + Device* display = nullptr; + int panelWidth = 0; + int panelHeight = 0; + DisplayColorFormat format = DISPLAY_COLOR_FORMAT_RGB565; + bool monochrome = false; + bool fullFrame = false; + PixelBuffer* hwBuffers[2] = { nullptr, nullptr }; + int backBuffer = 1; + PixelBuffer* frameBuffer = nullptr; + PixelBuffer* strips[2] = { nullptr, nullptr }; + int nextStrip = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/Downloader.cpp b/Apps/Maps/main/Source/app/Downloader.cpp new file mode 100644 index 0000000..15fb0a1 --- /dev/null +++ b/Apps/Maps/main/Source/app/Downloader.cpp @@ -0,0 +1,1833 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Downloader.h" +#include "RootCertificates.h" + +#include "../platform/Platform.h" +#include "../mvt/PlaceFinder.h" +#include "../tiles/BrowsedMove.h" +#include "../tiles/RegionCatalog.h" +#include "../tiles/TileJson.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace maps { + +namespace { + +constexpr uint32_t CONTROL_STACK = 8192; +constexpr uint32_t CONNECTION_STACK = 6144; +constexpr UBaseType_t TASK_PRIORITY = 3; +/** Tiles at this zoom level and below are downloaded one at a time. */ +constexpr int ALONE_MAX_ZOOM = 7; +/** Idle keep-alive connections are closed after this long, a new one costs a full TLS handshake. */ +constexpr uint32_t IDLE_CLOSE_MS = 60000; +/** How often the network tasks look for idle connections to close. */ +constexpr TickType_t SWEEP_TICKS = pdMS_TO_TICKS(5000); + +/** + * Starts a network task with its stack in PSRAM, which keeps about 28 KB of internal RAM free for Wi-Fi, TLS and + * the display. These tasks never touch flash or the SD card themselves. The task ends by suspending itself after + * signalling its owner, who then deletes it with endNetworkTask(). + */ +bool startNetworkTask(TaskFunction_t function, const char* name, uint32_t stack, void* context, TaskHandle_t* task) { + return xTaskCreatePinnedToCoreWithCaps(function, name, stack, context, TASK_PRIORITY, task, Downloader::NETWORK_CORE, MALLOC_CAP_SPIRAM) == pdPASS; +} + +void endNetworkTask(TaskHandle_t task) { + // It signalled before suspending itself, wait until it got there + TaskStatus_t status {}; + for (int i = 0; i < 100; i++) { + vTaskGetInfo(task, &status, pdFALSE, eInvalid); + if (status.eCurrentState == eSuspended) { + break; + } + vTaskDelay(pdMS_TO_TICKS(5)); + } + vTaskDeleteWithCaps(task); +} +constexpr int HTTP_TIMEOUT_MS = 15000; +constexpr int SAVE_EVERY_TILES = 20; +/** How often idle time looks for regions that need an update. */ +constexpr uint32_t UPDATE_CHECK_MS = 15000; +constexpr int MAX_ATTEMPTS = 3; + +bool wifiConnected() { + Device* wifi = nullptr; + if (device_get_first_active_by_type(&WIFI_TYPE, &wifi) != ERROR_NONE) { + return false; + } + WifiStationState state = WIFI_STATION_STATE_DISCONNECTED; + const bool connected = wifi_get_station_state(wifi, &state) == ERROR_NONE && state == WIFI_STATION_STATE_CONNECTED; + device_put(wifi); + return connected; +} + +/** Larger responses are refused, no real tile comes close. */ +constexpr size_t MAX_TILE_BYTES = 4 * 1024 * 1024; + +/** + * When the last connection was established (TCP and TLS done), in ms since boot, for the timing log. 32 bits: + * 64-bit atomics are not exported to apps on the ESP32-S3. + */ +std::atomic connectedAtMs = 0; + +uint32_t msSince(int64_t startUs, int64_t endUs) { + return static_cast((endUs - startUs) / 1000); +} + +/** TLS connections opened in this app session and the time they took, for the log. */ +std::atomic handshakeCount = 0; +std::atomic handshakeTotalMs = 0; + +} // namespace + +std::atomic Downloader::handshakes = 0; + +esp_err_t Downloader::onHttpEvent(esp_http_client_event_t* event) { + auto* connection = static_cast(event->user_data); + if (event->event_id == HTTP_EVENT_ON_CONNECTED) { + connectedAtMs = static_cast(esp_timer_get_time() / 1000); + if (connection != nullptr) { + connection->connectMs = connectedAtMs - connection->requestStartMs; + connection->connected = true; + if (connection->owner != nullptr) { + connection->owner->endHandshake(*connection); + } + } + } + if (event->event_id == HTTP_EVENT_DISCONNECTED && connection != nullptr) { + connection->connected = false; + } + ChunkedBody* body = connection != nullptr ? connection->body : nullptr; + // Low-zoom tiles are up to about 1.2 MB, kept in 64 KB pieces: late in a session PSRAM has the room, but not in + // one block + if (event->event_id == HTTP_EVENT_ON_DATA && body != nullptr) { + const size_t total = body->size() + static_cast(event->data_len); + if (total > MAX_TILE_BYTES) { + body->fail(); + } else { + Downloader* owner = connection->owner; + if (!body->failed() && !body->ensure(total) && owner != nullptr && owner->memoryRelief != nullptr) { + // No room even for a piece: the map gives some of its tile cache for it + owner->memoryRelief(owner->memoryReliefContext, ChunkedBody::CHUNK_BYTES); + } + body->putBytes(event->data, static_cast(event->data_len)); + } + } + return ESP_OK; +} + +void* Downloader::createClient(Connection* connection) { + esp_http_client_config_t config = {}; + config.url = TILEJSON_URL; + config.user_agent = USER_AGENT; + config.timeout_ms = HTTP_TIMEOUT_MS; + config.keep_alive_enable = true; + config.cert_pem = ROOT_CERTIFICATES; + config.cert_len = sizeof(ROOT_CERTIFICATES); + config.event_handler = onHttpEvent; + config.user_data = connection; + config.buffer_size = 4096; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (client != nullptr) { + // Tiles are stored as the server compressed them + esp_http_client_set_header(client, "Accept-Encoding", "gzip"); + } + if (connection != nullptr) { + connection->connected = false; + } + return client; +} + +void Downloader::beginHandshake(Connection& connection) { + // One handshake at a time: each is 2-3.6 s of CPU on the network core, two at once kept its idle task from the + // task watchdog + xSemaphoreTake(handshakeLock, pdMS_TO_TICKS(HTTP_TIMEOUT_MS)); + connection.handshaking = true; + handshakes++; +} + +void Downloader::endHandshake(Connection& connection) { + if (connection.handshaking) { + connection.handshaking = false; + handshakes--; + xSemaphoreGive(handshakeLock); + } +} + +esp_err_t Downloader::perform(Connection& connection) { + auto* client = static_cast(connection.client); + const bool wasConnected = connection.connected; + if (!wasConnected) { + beginHandshake(connection); + } + connection.requestStartMs = static_cast(esp_timer_get_time() / 1000); + connection.connectMs = 0; + const esp_err_t result = esp_http_client_perform(client); + endHandshake(connection); + if (connection.connectMs > 0) { + const uint32_t count = ++handshakeCount; + const uint32_t total = handshakeTotalMs += connection.connectMs; + logInfo("TLS: [%d] new connection in %u ms (session: %u connections, %u ms)", connection.index, + static_cast(connection.connectMs), static_cast(count), static_cast(total)); + } + return result; +} + +void Downloader::takeClient(Connection& connection) { + if (connection.client != nullptr) { + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + if (idleCount > 0) { + // The most recently used one, the least likely to have been closed by the server + idleCount--; + connection.client = idleClients[idleCount].client; + connection.connected = true; + } + xSemaphoreGive(mutex); + if (connection.client != nullptr) { + esp_http_client_set_user_data(static_cast(connection.client), &connection); + } else { + connection.client = createClient(&connection); + } +} + +void Downloader::dropClient(Connection& connection) { + if (connection.client != nullptr) { + esp_http_client_cleanup(static_cast(connection.client)); + connection.client = nullptr; + } + connection.connected = false; +} + +void Downloader::giveClient(Connection& connection) { + if (connection.client == nullptr) { + return; + } + if (!connection.connected) { + dropClient(connection); + return; + } + esp_http_client_set_user_data(static_cast(connection.client), nullptr); + xSemaphoreTake(mutex, portMAX_DELAY); + void* surplus = nullptr; + if (idleCount < IDLE_CLIENTS) { + idleClients[idleCount++] = { connection.client, nowMs() }; + } else { + surplus = connection.client; + } + xSemaphoreGive(mutex); + if (surplus != nullptr) { + esp_http_client_cleanup(static_cast(surplus)); + } + connection.client = nullptr; + connection.connected = false; +} + +void Downloader::closeIdleClients(uint32_t maxIdleMs) { + void* closing[IDLE_CLIENTS] {}; + int closingCount = 0; + xSemaphoreTake(mutex, portMAX_DELAY); + const uint32_t now = nowMs(); + int kept = 0; + for (int i = 0; i < idleCount; i++) { + if (now - idleClients[i].sinceMs >= maxIdleMs) { + closing[closingCount++] = idleClients[i].client; + } else { + idleClients[kept++] = idleClients[i]; + } + } + idleCount = kept; + xSemaphoreGive(mutex); + for (int i = 0; i < closingCount; i++) { + esp_http_client_cleanup(static_cast(closing[i])); + } + if (closingCount > 0) { + logInfo("TLS: closed %d idle connection(s)", closingCount); + } +} + +namespace { + +void makeFolders(const char* root, const char* region, const TileKey& tile) { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/%d", root, region, tile.z); + mkdir(path, 0777); + if (tileFolderPath(path, sizeof(path), root, region, tile)) { + mkdir(path, 0777); + } +} + +struct MetaJob { + char path[PATH_MAX_LENGTH]; + RegionMeta* meta; +}; + +bool readMetaJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + return readRegionMeta(job->path, *job->meta); +} + +bool writeMetaJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + return writeRegionMeta(job->path, *job->meta); +} + +struct OnCardJob { + const char* root; + const TileStore* store; + const char* region; + TileKey tile; + bool inThisRegion; + bool inBrowsed; +}; + +bool onCardJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + char path[PATH_MAX_LENGTH]; + job->inThisRegion = tilePath(path, sizeof(path), job->root, job->region, job->tile) && fileExists(path); + if (job->inThisRegion) { + return true; + } + // A tile of an overlapping region is shared instead of downloaded again. Browsed tiles are not shared with a + // download: they move into the region (Downloader::moveBrowsedTile), so the browsed region stays a cache + const bool forBrowsing = strcmp(job->region, BROWSE_REGION) == 0; + for (int i = 0; i < job->store->regionCount(); i++) { + const char* other = job->store->regionName(i); + if (strcmp(other, job->region) == 0 || (!forBrowsing && strcmp(other, BROWSE_REGION) == 0)) { + continue; + } + if (tilePath(path, sizeof(path), job->root, other, job->tile) && fileExists(path)) { + return true; + } + } + job->inBrowsed = !forBrowsing && tilePath(path, sizeof(path), job->root, BROWSE_REGION, job->tile) && fileExists(path); + return false; +} + +bool makeFolder(const char* path) { + return mkdir(path, 0777) == 0; +} + +/** Moves a browsed tile into a region (tiles/BrowsedMove.h), on the I/O task. */ +struct MoveJob { + const char* root; + const TileStore* store; + const char* region; + TileKey tile; + /** The source version the region downloads from. */ + const char* version; + BrowsedMove result; + uint64_t size; +}; + +bool moveJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + const char* browsedVersion = ""; + for (int i = 0; i < job->store->regionCount(); i++) { + if (strcmp(job->store->regionName(i), BROWSE_REGION) == 0 && job->store->regionVersion(i) != nullptr) { + browsedVersion = job->store->regionVersion(i); + } + } + job->result = moveBrowsedFiles(job->root, job->region, job->tile, job->version, browsedVersion, job->size, makeFolder); + return true; +} + +struct RemoveBrowsedJob { + const char* root; + TileKey tile; + uint64_t size; +}; + +bool removeBrowsedJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + job->size = removeBrowsedFiles(job->root, job->tile); + return true; +} + +struct StoreTileJob { + const char* root; + const char* region; + TileKey tile; + const ChunkedBody* body; + /** The tile column folder this connection made last, so it is not made again for every tile. */ + char* lastFolder; + bool yielded; +}; + +bool storeTileJob(IoTask& io, void* context) { + auto* job = static_cast(context); + char path[PATH_MAX_LENGTH]; + char temporary[PATH_MAX_LENGTH + 8]; + char prepared[PATH_MAX_LENGTH]; + if (!tilePath(path, sizeof(path), job->root, job->region, job->tile)) { + return false; + } + snprintf(temporary, sizeof(temporary), "%s.tmp", path); + char folder[PATH_MAX_LENGTH]; + if (tileFolderPath(folder, sizeof(folder), job->root, job->region, job->tile) && strcmp(folder, job->lastFolder) != 0) { + makeFolders(job->root, job->region, job->tile); + snprintf(job->lastFolder, PATH_MAX_LENGTH, "%s", folder); + } + // Only tiles that are not on the card are fetched, a prepared copy can only be left from a deleted original + for (int file = 0; file < PREPARED_FILE_COUNT; file++) { + if (preparedFilePath(prepared, sizeof(prepared), job->root, job->region, job->tile, static_cast(file)) && + fileExists(prepared)) { + remove(prepared); + } + } + // A write of a few hundred KB gives way to tiles on screen, the tile stays in memory and is written again + const bool ok = io.writeFile(path, temporary, *job->body, true); + job->yielded = io.yielded(); + return ok; +} + +constexpr int MAX_DELETE_COLUMNS = 4096; + +struct ColumnsJob { + char folder[PATH_MAX_LENGTH]; + int* columns; + int count; +}; + +bool listColumnsJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + job->count = 0; + return listDirectory(job->folder, [](const char* name, bool isDirectory, void* data) { + auto* columnsJob = static_cast(data); + if (isDirectory && name[0] >= '0' && name[0] <= '9' && columnsJob->count < MAX_DELETE_COLUMNS) { + columnsJob->columns[columnsJob->count++] = atoi(name); + } + return true; + }, job); +} + +struct RemoveJob { + char path[PATH_MAX_LENGTH]; + bool folder; +}; + +bool removeJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + return (job->folder ? rmdir(job->path) : remove(job->path)) == 0; +} + +} // namespace + +bool Downloader::start(const char* root, TileStore* tileStore, IoTask* ioTask) { + snprintf(rootPath, sizeof(rootPath), "%s", root); + store = tileStore; + io = ioTask; + mutex = xSemaphoreCreateMutex(); + wake = xSemaphoreCreateBinary(); + stopped = xSemaphoreCreateBinary(); + connectionsDone = xSemaphoreCreateCounting(CONNECTIONS, 0); + tileJsonLock = xSemaphoreCreateMutex(); + browseWake = xSemaphoreCreateBinary(); + browseStopped = xSemaphoreCreateBinary(); + handshakeLock = xSemaphoreCreateBinary(); + if (handshakeLock != nullptr) { + xSemaphoreGive(handshakeLock); + } + if (handshakeLock == nullptr || mutex == nullptr || wake == nullptr || stopped == nullptr || connectionsDone == nullptr || tileJsonLock == nullptr || + browseWake == nullptr || browseStopped == nullptr) { + return false; + } + current = {}; + current.state = State::Idle; + stopRequested = false; + if (!startNetworkTask(controlMain, "maps-download", CONTROL_STACK, this, &controlTask)) { + controlTask = nullptr; + return false; + } + if (!startNetworkTask(browseMain, "maps-browse", CONTROL_STACK, this, &browseTask)) { + browseTask = nullptr; + logError("Browse task not started"); + } + return true; +} + +void Downloader::stop() { + if (controlTask != nullptr) { + stopRequested = true; + cancelRequested = true; + xSemaphoreGive(wake); + xSemaphoreTake(stopped, portMAX_DELAY); + endNetworkTask(controlTask); + controlTask = nullptr; + } + if (browseTask != nullptr) { + stopRequested = true; + xSemaphoreGive(browseWake); + xSemaphoreTake(browseStopped, portMAX_DELAY); + endNetworkTask(browseTask); + browseTask = nullptr; + } + if (mutex != nullptr) { + closeIdleClients(0); + } + for (SemaphoreHandle_t* handle : { &mutex, &wake, &stopped, &connectionsDone, &tileJsonLock, &browseWake, &browseStopped, &handshakeLock }) { + if (*handle != nullptr) { + vSemaphoreDelete(*handle); + *handle = nullptr; + } + } +} + +bool Downloader::queue(Job next, const char* region) { + xSemaphoreTake(mutex, portMAX_DELAY); + job = next; + snprintf(jobRegion, sizeof(jobRegion), "%s", region); + xSemaphoreGive(mutex); + // A running download is paused, it continues from its meta.json later + if (busy) { + cancelRequested = true; + } + busy = true; + xSemaphoreGive(wake); + return true; +} + +bool Downloader::download(const char* region) { + return queue(Job::Download, region); +} + +bool Downloader::remove(const char* region) { + return queue(Job::Delete, region); +} + +bool Downloader::update(const char* region) { + return queue(Job::Update, region); +} + +void Downloader::setCatalogPath(const char* path) { + snprintf(catalogPath, sizeof(catalogPath), "%s", path); +} + +namespace { + +struct CatalogJob { + const char* path; + FileBuffer* buffer; +}; + +bool readCatalogJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + // In one go: IoTask::readFile gives way to the tiles on screen and then fails, which a background job would + // do all the time while the map loads + return job->buffer->load(job->path); +} + +struct OutlineList { + TileKey* tiles; + int64_t count; + int64_t capacity; +}; + +} // namespace + +bool Downloader::loadOutlineTiles(const RegionMeta& region) { + freeOutlineTiles(); + if (region.outline[0] == '\0') { + return true; + } + FileBuffer file; + CatalogJob job { catalogPath, &file }; + RegionCatalog catalog; + if (catalogPath[0] == '\0' || !io->run(IoPriority::Background, readCatalogJob, &job) || !catalog.open(file.bytes(), file.size())) { + logError("Region catalog %s not readable", catalogPath); + return false; + } + int index = -1; + char name[sizeof(region.outline)]; + for (int i = 0; i < catalog.count() && index < 0; i++) { + catalog.copyName(i, name, sizeof(name)); + if (strcmp(name, region.outline) == 0) { + index = i; + } + } + if (index < 0 || region.total <= 0) { + logError("Region %s is not in the catalog", region.outline); + return false; + } + OutlineList list { static_cast(largeAlloc(sizeof(TileKey) * static_cast(region.total))), 0, region.total }; + if (list.tiles == nullptr) { + return false; + } + for (int z = region.minZoom; z <= region.maxZoom; z++) { + catalog.forEachTile(index, z, [](const TileKey& tile, void* context) { + auto* tiles = static_cast(context); + if (tiles->count >= tiles->capacity) { + return false; + } + tiles->tiles[tiles->count++] = tile; + return true; + }, &list); + } + outlineTiles = list.tiles; + outlineCount = list.count; + if (outlineCount != region.total) { + logError("Region %s: %d tiles in the outline, %d expected", region.name, static_cast(outlineCount), static_cast(region.total)); + } + return true; +} + +namespace { + +struct PlaceJob { + char path[PATH_MAX_LENGTH]; + double fx; + double fy; + PlaceName place; + bool found; +}; + +bool placeJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + FileInput input; + PlaceFinder finder; + job->found = input.open(job->path) && finder.init() && finder.nearest(&input, job->fx, job->fy, job->place); + return true; +} + +} // namespace + +bool Downloader::findPlaceName(const RegionMeta& region, char* out, size_t size) { + const double lat = (region.box.south + region.box.north) / 2.0; + const double lon = (region.box.west + region.box.east) / 2.0; + // z12 shows villages, z10 only towns and cities; a village right at the centre wins over a town far away + for (const int z : { 12, 10 }) { + if (z < region.minZoom || z > region.maxZoom) { + continue; + } + const TileKey tile = tileAt(lat, lon, z); + PlaceJob job {}; + if (!tilePath(job.path, sizeof(job.path), rootPath, region.name, tile)) { + continue; + } + job.fx = lonToWorldX(lon, z) / TILE_SIZE - tile.x; + job.fy = latToWorldY(lat, z) / TILE_SIZE - tile.y; + io->run(IoPriority::Background, placeJob, &job); + if (job.found && job.place.name[0] != '\0') { + snprintf(out, size, "%s", job.place.name); + return true; + } + } + return false; +} + +void Downloader::freeOutlineTiles() { + largeFree(outlineTiles); + outlineTiles = nullptr; + outlineCount = 0; +} + +bool Downloader::tileForIndex(const RegionMeta& region, int64_t index, TileKey& tile) const { + if (outlineTiles != nullptr) { + if (index < 0 || index >= outlineCount) { + return false; + } + tile = outlineTiles[index]; + return true; + } + return tileAtIndex(region.plan(), index, tile); +} + +void Downloader::setView(const BoundingBox& box) { + xSemaphoreTake(mutex, portMAX_DELAY); + viewBox = box; + viewKnown = true; + xSemaphoreGive(mutex); +} + +Downloader::Status Downloader::status() { + xSemaphoreTake(mutex, portMAX_DELAY); + Status copy = current; + xSemaphoreGive(mutex); + return copy; +} + +void Downloader::setStatus(State state, const char* message) { + xSemaphoreTake(mutex, portMAX_DELAY); + current.state = state; + snprintf(current.message, sizeof(current.message), "%s", message); + xSemaphoreGive(mutex); + if (state == State::Failed) { + logError("Download: %s", message); + } else { + logInfo("Download: %s", message); + } +} + +void Downloader::controlMain(void* context) { + auto* downloader = static_cast(context); + downloader->controlLoop(); + xSemaphoreGive(downloader->stopped); + vTaskSuspend(nullptr); +} + +void Downloader::controlLoop() { + while (!stopRequested) { + if (xSemaphoreTake(wake, SWEEP_TICKS) != pdTRUE) { + closeIdleClients(IDLE_CLOSE_MS); + // Outdated regions are looked for at most once a minute while the map is left alone + if (updatesAllowed && !stopRequested && nowMs() - lastUpdateCheckMs >= UPDATE_CHECK_MS && wifiConnected()) { + lastUpdateCheckMs = nowMs(); + char region[REGION_NAME_MAX + 1]; + if (urlTemplate[0] != '\0' && findOutdated(region, sizeof(region))) { + xSemaphoreTake(mutex, portMAX_DELAY); + const bool jobless = job == Job::None; + if (jobless) { + snprintf(current.region, sizeof(current.region), "%s", region); + busy = true; + } + xSemaphoreGive(mutex); + if (jobless) { + cancelRequested = false; + runUpdate(false); + xSemaphoreTake(mutex, portMAX_DELAY); + busy = job != Job::None; + xSemaphoreGive(mutex); + if (busy) { + xSemaphoreGive(wake); + } + } + } + } + continue; + } + if (stopRequested) { + break; + } + cancelRequested = false; + xSemaphoreTake(mutex, portMAX_DELAY); + const Job requested = job; + job = Job::None; + snprintf(current.region, sizeof(current.region), "%s", jobRegion); + xSemaphoreGive(mutex); + if (requested == Job::Download) { + runDownload(); + } else if (requested == Job::Delete) { + runDelete(); + } else if (requested == Job::Update) { + runUpdate(true); + } + xSemaphoreTake(mutex, portMAX_DELAY); + const bool more = job != Job::None; + busy = more; + xSemaphoreGive(mutex); + } +} + +bool Downloader::fetchTileJson(Connection& connection) { + if (timing) { + // Plain HTTP first: name lookup and TCP without TLS. The HTTPS request after it then finds the name cached. + esp_http_client_config_t config = {}; + config.url = "http://tiles.openfreemap.org/planet"; + config.user_agent = USER_AGENT; + config.timeout_ms = HTTP_TIMEOUT_MS; + config.event_handler = onHttpEvent; + esp_http_client_handle_t plain = esp_http_client_init(&config); + if (plain != nullptr) { + const int64_t startUs = esp_timer_get_time(); + connectedAtMs = 0; + const esp_err_t opened = esp_http_client_open(plain, 0); + const int64_t openedUs = esp_timer_get_time(); + const int64_t headers = opened == ESP_OK ? esp_http_client_fetch_headers(plain) : -1; + const int64_t headersUs = esp_timer_get_time(); + logInfo("Timing http: start t=%u ms, connected +%u ms (DNS and TCP), open +%u, headers +%u (%d, HTTP %d)", + static_cast(startUs / 1000), connectedAtMs > 0 ? connectedAtMs - static_cast(startUs / 1000) : 0, msSince(startUs, openedUs), + msSince(startUs, headersUs), static_cast(headers), esp_http_client_get_status_code(plain)); + esp_http_client_close(plain); + esp_http_client_cleanup(plain); + } + } + // Through a pooled keep-alive connection, which the tile requests after it then reuse + takeClient(connection); + auto* client = static_cast(connection.client); + if (client == nullptr || connection.body == nullptr) { + return false; + } + // The TileJSON is read as plain text + esp_http_client_delete_header(client, "Accept-Encoding"); + esp_http_client_set_url(client, TILEJSON_URL); + connection.body->clear(); + const int64_t startUs = esp_timer_get_time(); + const esp_err_t result = perform(connection); + const int64_t doneUs = esp_timer_get_time(); + esp_http_client_set_header(client, "Accept-Encoding", "gzip"); + const int status = esp_http_client_get_status_code(client); + ChunkedBody& text = *connection.body; + bool ok = false; + if (result != ESP_OK || status != 200 || text.failed()) { + logError("TileJSON request failed: %s, HTTP %d", esp_err_to_name(result), status); + dropClient(connection); + } else { + if (timing) { + logInfo("Timing https: start t=%u ms, connect %u ms (DNS, TCP, TLS), whole request %u ms (%u bytes)", + static_cast(startUs / 1000), static_cast(connection.connectMs), msSince(startUs, doneUs), + static_cast(text.size())); + } + text.put('\0'); + ok = !text.failed() && text.contiguous() != nullptr && + parseTileJson(reinterpret_cast(text.contiguous()), urlTemplate, sizeof(urlTemplate), version, sizeof(version)); + if (!ok) { + logError("TileJSON has no usable tile URL (%u bytes read)", static_cast(text.size())); + } + } + text.clear(); + if (ok) { + logInfo("Tile source %s (version %s)", urlTemplate, version); + } + return ok; +} + +bool Downloader::readMeta(const char* region, RegionMeta& out) { + MetaJob job {}; + snprintf(job.path, sizeof(job.path), "%s/maps/%s/meta.json", rootPath, region); + job.meta = &out; + return io->run(IoPriority::Background, readMetaJob, &job); +} + +bool Downloader::writeMeta(const RegionMeta& out) { + RegionMeta copy = out; + MetaJob job {}; + snprintf(job.path, sizeof(job.path), "%s/maps/%s/meta.json", rootPath, copy.name); + job.meta = © + return io->run(IoPriority::Background, writeMetaJob, &job); +} + +bool Downloader::tileOnCard(const char* region, const TileKey& tile, bool& inThisRegion) { + OnCardJob job { rootPath, store, region, tile, false, false }; + const bool found = io->run(IoPriority::Background, onCardJob, &job); + inThisRegion = job.inThisRegion; + return found; +} + +Downloader::OnCard Downloader::findTile(const char* region, const TileKey& tile) { + OnCardJob job { rootPath, store, region, tile, false, false }; + if (io->run(IoPriority::Background, onCardJob, &job)) { + return job.inThisRegion ? OnCard::InRegion : OnCard::Shared; + } + return job.inBrowsed ? OnCard::InBrowsed : OnCard::Missing; +} + +BrowsedMove Downloader::moveBrowsedTile(Connection& connection, const TileKey& tile, uint64_t& size) { + // Renames write the folder entries on the card, which shares its bus with the display: only while the map is idle + while (!idle && !stopRequested && !cancelRequested) { + vTaskDelay(pdMS_TO_TICKS(50)); + } + MoveJob job { rootPath, store, meta.name, tile, meta.version, BrowsedMove::Failed, 0 }; + if (!io->run(IoPriority::Background, moveJob, &job)) { + return BrowsedMove::Failed; + } + size = job.size; + return job.result; +} + +void Downloader::removeBrowsedCopy(Connection& connection, const TileKey& tile) { + RemoveBrowsedJob job { rootPath, tile, 0 }; + io->run(IoPriority::Background, removeBrowsedJob, &job); + connection.browsedRemoved += job.size; +} + +void Downloader::settleBrowsed(uint64_t removedBytes) { + if (removedBytes == 0) { + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + const bool inMemory = browseMetaReady; + if (inMemory) { + browseMeta.bytes -= std::min(browseMeta.bytes, removedBytes); + } + RegionMeta copy = browseMeta; + xSemaphoreGive(mutex); + if (!inMemory && !readMeta(BROWSE_REGION, copy)) { + return; + } + if (!inMemory) { + copy.bytes -= std::min(copy.bytes, removedBytes); + } + writeMeta(copy); +} + +int Downloader::movedTiles() const { + int total = 0; + for (int i = 0; i < CONNECTIONS; i++) { + total += connections[i].movedTiles; + } + return total; +} + +bool Downloader::downloadTile(Connection& connection, const char* region, const TileKey& tile, int& status, bool waitForIdle) { + char url[200]; + if (!formatTileUrl(urlTemplate, tile, url, sizeof(url))) { + return false; + } + connection.body->clear(); + auto* client = static_cast(connection.client); + esp_http_client_set_url(client, url); + const esp_err_t result = perform(connection); + status = esp_http_client_get_status_code(client); + const size_t size = connection.body->size(); + if (result != ESP_OK || connection.body->failed() || size > MAX_TILE_BYTES || (status != 200 && status != 204 && status != 404)) { + if (connection.body->failed()) { + logError("Tile %d/%d/%d: no memory for %u bytes", tile.z, tile.x, tile.y, static_cast(size)); + } + connection.body->trim(); + return false; + } + if (status != 200 || size == 0) { + // An empty tile, the sea or empty land at this zoom level + return true; + } + // Card work for downloads waits until the map has been left alone for a moment, writes hold the display's shared + // bus. A tile the screen needs now is written at once + while (waitForIdle && !idle && !stopRequested && !cancelRequested) { + vTaskDelay(pdMS_TO_TICKS(50)); + } + StoreTileJob job { rootPath, region, tile, connection.body, connection.lastFolder, false }; + bool stored = io->run(IoPriority::Background, storeTileJob, &job); + for (int attempt = 0; !stored && job.yielded && attempt < 20 && !stopRequested; attempt++) { + vTaskDelay(pdMS_TO_TICKS(50)); + stored = io->run(IoPriority::Background, storeTileJob, &job); + } + connection.body->trim(); + if (!stored) { + return false; + } + connection.received += static_cast(size); + return true; +} + +bool Downloader::claimIndex(int64_t& index, int connectionIndex) { + while (true) { + xSemaphoreTake(mutex, portMAX_DELAY); + const bool available = nextIndex < meta.total && !failed && !cancelRequested && !stopRequested; + if (!available) { + xSemaphoreGive(mutex); + return false; + } + // Tiles up to ALONE_MAX_ZOOM can be over 1 MB each: one is downloaded at a time, with no other tile next to it + TileKey tile {}; + bool large = tileAtIndex(meta.plan(), nextIndex, tile) && tile.z <= ALONE_MAX_ZOOM; + bool othersActive = false; + for (int i = 0; i < CONNECTIONS; i++) { + if (i == connectionIndex || activeIndex[i] < 0) { + continue; + } + othersActive = true; + large = large || (tileAtIndex(meta.plan(), activeIndex[i], tile) && tile.z <= ALONE_MAX_ZOOM); + } + if (!large || !othersActive) { + index = nextIndex++; + activeIndex[connectionIndex] = index; + xSemaphoreGive(mutex); + return true; + } + xSemaphoreGive(mutex); + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +void Downloader::finishIndex(int connectionIndex) { + xSemaphoreTake(mutex, portMAX_DELAY); + activeIndex[connectionIndex] = -1; + // Tiles finish out of order, the saved count is the first tile that is not finished yet + int64_t done = nextIndex; + for (int i = 0; i < CONNECTIONS; i++) { + if (activeIndex[i] >= 0 && activeIndex[i] < done) { + done = activeIndex[i]; + } + } + meta.done = done; + uint64_t bytes = 0; + for (int i = 0; i < CONNECTIONS; i++) { + bytes += connections[i].received + connections[i].moved; + } + current.done = done; + current.bytes = meta.bytes + bytes; + sinceSave++; + const bool save = sinceSave >= SAVE_EVERY_TILES; + if (save) { + sinceSave = 0; + } + xSemaphoreGive(mutex); + if (save) { + saveProgress(); + allTilesChanged = true; + tilesChanged = true; + } +} + +void Downloader::saveProgress() { + xSemaphoreTake(mutex, portMAX_DELAY); + RegionMeta copy = meta; + uint64_t bytes = 0; + uint64_t moved = 0; + for (int i = 0; i < CONNECTIONS; i++) { + bytes += connections[i].received + connections[i].moved; + moved += connections[i].moved; + } + copy.bytes = meta.bytes + bytes; + copy.moved = meta.moved + moved; + xSemaphoreGive(mutex); + writeMeta(copy); +} + +void Downloader::connectionMain(void* context) { + auto* connection = static_cast(context); + connection->owner->connectionLoop(*connection); + xSemaphoreGive(connection->owner->connectionsDone); + vTaskSuspend(nullptr); +} + +void Downloader::connectionLoop(Connection& connection) { + int64_t index; + while (claimIndex(index, connection.index)) { + TileKey tile {}; + if (!tileForIndex(meta, index, tile)) { + finishIndex(connection.index); + continue; + } + const OnCard onCard = findTile(meta.name, tile); + if (onCard == OnCard::InRegion || onCard == OnCard::Shared) { + finishIndex(connection.index); + continue; + } + // A tile browsed earlier moves into the region, unless it came from another version of the tiles + bool staleBrowsed = false; + if (onCard == OnCard::InBrowsed) { + uint64_t size = 0; + const BrowsedMove move = moveBrowsedTile(connection, tile, size); + if (move == BrowsedMove::Moved) { + xSemaphoreTake(mutex, portMAX_DELAY); + connection.moved += size; + connection.movedTiles++; + xSemaphoreGive(mutex); + finishIndex(connection.index); + continue; + } + staleBrowsed = move == BrowsedMove::Stale; + } + bool ok = false; + int status = 0; + const uint32_t tileStart = nowMs(); + const uint64_t receivedBefore = connection.received; + for (int attempt = 1; attempt <= MAX_ATTEMPTS && !ok && !cancelRequested; attempt++) { + takeClient(connection); + ok = connection.client != nullptr && downloadTile(connection, meta.name, tile, status); + if (!ok) { + logError("[%d] Tile %d/%d/%d failed (HTTP %d), attempt %d", connection.index, tile.z, tile.x, tile.y, status, attempt); + dropClient(connection); + vTaskDelay(pdMS_TO_TICKS(1000 * attempt)); + } + } + if (!ok) { + failed = true; + } + if (ok && staleBrowsed) { + // Never two versions of a tile: the old browsed copy goes once the new one is stored + removeBrowsedCopy(connection, tile); + } + // The limit holds for the real size too, the estimate can be lower for dense city centres. Moved tiles were + // not downloaded and do not count + xSemaphoreTake(mutex, portMAX_DELAY); + uint64_t bytes = meta.bytes - std::min(meta.bytes, meta.moved); + for (int i = 0; i < CONNECTIONS; i++) { + bytes += connections[i].received; + } + xSemaphoreGive(mutex); + if (bytes > MAX_DOWNLOAD_BYTES) { + failed = true; + } + if (ok) { + logInfo("[%d] Tile %d/%d/%d: %u bytes in %u ms (HTTP %d)", connection.index, tile.z, tile.x, tile.y, + static_cast(connection.received - receivedBefore), nowMs() - tileStart, status); + finishIndex(connection.index); + } + } + // Kept open for the next download, browsing or an update + giveClient(connection); +} + +void Downloader::runDownload() { + if (!readMeta(current.region, meta)) { + setStatus(State::Failed, "Region information is missing"); + return; + } + snprintf(meta.name, sizeof(meta.name), "%s", current.region); + if (meta.total > MAX_DOWNLOAD_TILES) { + setStatus(State::Failed, "Area is larger than 3000 tiles"); + return; + } + if (!loadOutlineTiles(meta)) { + setStatus(State::Failed, "Region outline is missing"); + return; + } + if (!wifiConnected()) { + setStatus(State::Failed, "No Wi-Fi connection"); + return; + } + setStatus(State::Connecting, "Connecting to OpenFreeMap"); + logMemory("download start"); + connections[0].owner = this; + connections[0].index = 0; + connections[0].body = &bodies[0]; + const bool tileJson = ensureTileJson(connections[0]); + giveClient(connections[0]); + bodies[0].release(); + if (!tileJson) { + setStatus(State::Failed, "OpenFreeMap is not reachable"); + return; + } + if (meta.source[0] == '\0') { + snprintf(meta.source, sizeof(meta.source), "%s", urlTemplate); + } + snprintf(meta.version, sizeof(meta.version), "%s", version); + xSemaphoreTake(mutex, portMAX_DELAY); + current.total = meta.total; + current.done = meta.done; + current.bytes = meta.bytes; + nextIndex = meta.done; + sinceSave = 0; + failed = false; + xSemaphoreGive(mutex); + setStatus(State::Downloading, "Downloading"); + + const uint32_t start = nowMs(); + int started = 0; + for (int i = 0; i < CONNECTIONS; i++) { + Connection& connection = connections[i]; + connection = { this, i, nullptr, &bodies[i], 0, nullptr, "" }; + activeIndex[i] = -1; + char name[16]; + snprintf(name, sizeof(name), "maps-http%d", i); + if (startNetworkTask(connectionMain, name, CONNECTION_STACK, &connection, &connection.task)) { + started++; + } else { + connection.task = nullptr; + } + } + for (int i = 0; i < started; i++) { + xSemaphoreTake(connectionsDone, portMAX_DELAY); + } + for (int i = 0; i < CONNECTIONS; i++) { + if (connections[i].task != nullptr) { + endNetworkTask(connections[i].task); + connections[i].task = nullptr; + } + bodies[i].release(); + } + freeOutlineTiles(); + logMemory("download end"); + + xSemaphoreTake(mutex, portMAX_DELAY); + uint64_t leftBrowsed = 0; + int moveCount = 0; + for (int i = 0; i < CONNECTIONS; i++) { + meta.bytes += connections[i].received + connections[i].moved; + meta.moved += connections[i].moved; + leftBrowsed += connections[i].moved + connections[i].browsedRemoved; + moveCount += connections[i].movedTiles; + connections[i].received = 0; + connections[i].moved = 0; + connections[i].browsedRemoved = 0; + } + if (!failed && !cancelRequested) { + meta.done = meta.total; + } + time_t now = time(nullptr); + struct tm local {}; + localtime_r(&now, &local); + strftime(meta.date, sizeof(meta.date), "%Y-%m-%d", &local); + current.done = meta.done; + current.bytes = meta.bytes; + xSemaphoreGive(mutex); + // An area framed on the map is named after the nearest town in its own tiles, now that they are on the card + char place[sizeof(meta.title)]; + if (meta.isComplete() && meta.outline[0] == '\0' && findPlaceName(meta, place, sizeof(place))) { + logInfo("Region %s is named %s", meta.name, place); + setRegionTitle(meta, place); + } + writeMeta(meta); + store->setRegionVersion(meta.name, meta.version); + // The browsed region is smaller by the tiles that moved out of it and the stale copies replaced + settleBrowsed(leftBrowsed); + if (moveCount > 0) { + logInfo("%d browsed tiles moved into %s", moveCount, meta.name); + } + allTilesChanged = true; + tilesChanged = true; + + const uint32_t seconds = (nowMs() - start) / 1000; + char message[64]; + if (meta.bytes > MAX_DOWNLOAD_BYTES) { + setStatus(State::Failed, "Stopped at the 50 MB limit"); + } else if (failed) { + setStatus(State::Failed, "Download failed, it continues later"); + } else if (cancelRequested) { + setStatus(State::Done, "Download paused"); + } else { + snprintf(message, sizeof(message), "Downloaded %d KB in %u s", static_cast(meta.bytes / 1024), static_cast(seconds)); + setStatus(State::Done, message); + } +} + +struct DeleteColumnJob { + const char* root; + const char* region; + int z; + int x; + const RegionMeta* others; + int otherCount; +}; + +namespace { + +bool deleteColumnJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + char column[PATH_MAX_LENGTH]; + snprintf(column, sizeof(column), "%s/maps/%s/%d/%d", job->root, job->region, job->z, job->x); + struct Files { + DeleteColumnJob* job; + const char* column; + } files { job, column }; + listDirectory(column, [](const char* fileName, bool fileIsDirectory, void* fileData) { + auto* f = static_cast(fileData); + if (fileIsDirectory) { + return true; + } + char file[PATH_MAX_LENGTH]; + snprintf(file, sizeof(file), "%s/%s", f->column, fileName); + // Only originals move to a region that still needs them, prepared copies are made again there + const size_t nameLength = strlen(fileName); + if (nameLength < 5 || (strcmp(fileName + nameLength - 4, ".pbf") != 0 && strcmp(fileName + nameLength - 4, ".PBF") != 0)) { + remove(file); + return true; + } + const TileKey tile { f->job->z, f->job->x, atoi(fileName) }; + for (int i = 0; i < f->job->otherCount; i++) { + const RegionMeta& other = f->job->others[i]; + const TileRange range = tileRange(other.box, tile.z); + char otherPath[PATH_MAX_LENGTH]; + if (tile.z >= other.minZoom && tile.z <= other.maxZoom && tile.x >= range.minX && tile.x <= range.maxX && + tile.y >= range.minY && tile.y <= range.maxY && tilePath(otherPath, sizeof(otherPath), f->job->root, other.name, tile) && + !fileExists(otherPath)) { + makeFolders(f->job->root, other.name, tile); + if (rename(file, otherPath) == 0) { + return true; + } + } + } + remove(file); + return true; + }, &files); + rmdir(column); + return true; +} + +} // namespace + +bool Downloader::ensureTileJson(Connection& connection) { + // The region download and the browse task may both need it first + xSemaphoreTake(tileJsonLock, portMAX_DELAY); + const bool ok = urlTemplate[0] != '\0' || fetchTileJson(connection); + xSemaphoreGive(tileJsonLock); + return ok; +} + +bool Downloader::isOnline() { + return wifiConnected(); +} + +void Downloader::keepBrowse(int sourceZoom, int minX, int minY, int maxX, int maxY) { + xSemaphoreTake(mutex, portMAX_DELAY); + int kept = 0; + for (int i = 0; i < browseCount; i++) { + const TileKey& tile = browseQueue[i]; + const bool column = minX <= maxX ? tile.x >= minX && tile.x <= maxX : tile.x >= minX || tile.x <= maxX; + if (tile.z == sourceZoom && column && tile.y >= minY && tile.y <= maxY) { + browseQueue[kept++] = tile; + } + } + // Dropped tiles no longer count towards "Fetching detail done/total" + browseTotal -= browseCount - kept; + browseCount = kept; + xSemaphoreGive(mutex); +} + +void Downloader::browse(const TileKey* tiles, int count) { + if (browseTask == nullptr) { + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + bool added = false; + if (browseCount == 0 && !browsing) { + // A new batch: its progress counts from zero + browseDone = 0; + browseTotal = 0; + } + for (int i = 0; i < count; i++) { + bool known = false; + for (int j = 0; j < browseCount && !known; j++) { + known = browseQueue[j] == tiles[i]; + } + for (int j = 0; j < browseEmptyCount && !known; j++) { + known = browseEmpty[j] == tiles[i]; + } + if (known || !isValidTile(tiles[i])) { + continue; + } + if (browseCount == BROWSE_QUEUE) { + // The oldest request goes, the newest view matters most + for (int j = 1; j < BROWSE_QUEUE; j++) { + browseQueue[j - 1] = browseQueue[j]; + } + browseCount--; + browseTotal--; + } + browseQueue[browseCount++] = tiles[i]; + browseTotal++; + added = true; + } + xSemaphoreGive(mutex); + if (added) { + xSemaphoreGive(browseWake); + } +} + +void Downloader::prepareBrowsing() { + if (browseTask != nullptr) { + warmUp = true; + xSemaphoreGive(browseWake); + } +} + +void Downloader::browseMain(void* context) { + auto* downloader = static_cast(context); + downloader->browseLoop(); + xSemaphoreGive(downloader->browseStopped); + vTaskSuspend(nullptr); +} + +bool Downloader::ensureBrowseRegion(const TileKey& tile) { + // The region's box grows with the tiles + const double west = worldXToLon(static_cast(tile.x) * TILE_SIZE, tile.z); + const double east = worldXToLon(static_cast(tile.x + 1) * TILE_SIZE, tile.z); + const double north = worldYToLat(static_cast(tile.y) * TILE_SIZE, tile.z); + const double south = worldYToLat(static_cast(tile.y + 1) * TILE_SIZE, tile.z); + if (!browseMetaReady) { + if (!readMeta(BROWSE_REGION, browseMeta)) { + browseMeta = {}; + browseMeta.box = { west, south, east, north }; + auto makeFolder = [&] { + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/maps/%s", rootPath, BROWSE_REGION); + mkdir(folder, 0777); + return true; + }; + runOnCard(*io, makeFolder, IoPriority::Background); + } + snprintf(browseMeta.name, sizeof(browseMeta.name), "%s", BROWSE_REGION); + browseMeta.minZoom = 0; + browseMeta.maxZoom = MAX_SOURCE_ZOOM; + browseMeta.total = 1; + browseMeta.done = 1; + snprintf(browseMeta.version, sizeof(browseMeta.version), "%s", version); + time_t now = time(nullptr); + struct tm local {}; + localtime_r(&now, &local); + strftime(browseMeta.date, sizeof(browseMeta.date), "%Y-%m-%d", &local); + if (!writeMeta(browseMeta)) { + return false; + } + store->addRegion(BROWSE_REGION); + store->setRegionVersion(BROWSE_REGION, version); + browseMetaReady = true; + } + browseMeta.box.west = std::min(browseMeta.box.west, west); + browseMeta.box.east = std::max(browseMeta.box.east, east); + browseMeta.box.south = std::min(browseMeta.box.south, south); + browseMeta.box.north = std::max(browseMeta.box.north, north); + store->setRegionBounds(BROWSE_REGION, browseMeta.box, browseMeta.minZoom, browseMeta.maxZoom); + return true; +} + +bool Downloader::takeTilesChanged(TileKey* tiles, int& count) { + if (!tilesChanged.exchange(false)) { + return false; + } + xSemaphoreTake(mutex, portMAX_DELAY); + count = allTilesChanged.exchange(false) ? -1 : arrivedCount; + for (int i = 0; i < count; i++) { + tiles[i] = arrived[i]; + } + arrivedCount = 0; + xSemaphoreGive(mutex); + return true; +} + +bool Downloader::fetchBrowseTile(const TileKey& tile) { + bool inThisRegion = false; + if (tileOnCard(BROWSE_REGION, tile, inThisRegion)) { + return true; + } + if (!ensureBrowseRegion(tile)) { + return false; + } + Connection& connection = browseConnection; + const uint64_t before = connection.received; + const uint32_t startMs = nowMs(); + int status = 0; + bool ok = false; + for (int attempt = 1; attempt <= 2 && !ok && !stopRequested; attempt++) { + takeClient(connection); + ok = connection.client != nullptr && downloadTile(connection, BROWSE_REGION, tile, status, false); + if (!ok) { + dropClient(connection); + } + } + if (!ok) { + logError("Browse: tile %d/%d/%d failed (HTTP %d)", tile.z, tile.x, tile.y, status); + return false; + } + if (connection.received == before) { + // No data there: remembered, so it is not asked for again + xSemaphoreTake(mutex, portMAX_DELAY); + browseEmpty[browseEmptyNext] = tile; + browseEmptyNext = (browseEmptyNext + 1) % BROWSE_EMPTY; + if (browseEmptyCount < BROWSE_EMPTY) { + browseEmptyCount++; + } + xSemaphoreGive(mutex); + return true; + } + browseMeta.bytes += connection.received - before; + logInfo("Browse: tile %d/%d/%d, %u bytes in %u ms", tile.z, tile.x, tile.y, static_cast(connection.received - before), nowMs() - startMs); + if (++browseUnsaved >= 5) { + browseUnsaved = 0; + writeMeta(browseMeta); + } + xSemaphoreTake(mutex, portMAX_DELAY); + if (arrivedCount < MAX_ARRIVED) { + arrived[arrivedCount++] = tile; + } else { + allTilesChanged = true; + } + xSemaphoreGive(mutex); + tilesChanged = true; + return true; +} + +void Downloader::browseLoop() { + browseConnection = { this, CONNECTIONS, nullptr, &browseBody, 0, nullptr, "" }; + while (!stopRequested) { + if (xSemaphoreTake(browseWake, SWEEP_TICKS) != pdTRUE) { + closeIdleClients(IDLE_CLOSE_MS); + continue; + } + if (warmUp.exchange(false) && wifiConnected()) { + ensureTileJson(browseConnection); + } + while (!stopRequested) { + TileKey tile {}; + xSemaphoreTake(mutex, portMAX_DELAY); + const bool any = browseCount > 0; + if (any) { + tile = browseQueue[0]; + for (int i = 1; i < browseCount; i++) { + browseQueue[i - 1] = browseQueue[i]; + } + browseCount--; + } + xSemaphoreGive(mutex); + if (!any) { + break; + } + if (!wifiConnected() || !ensureTileJson(browseConnection)) { + // Offline: the map says so, the requests are dropped and come again with the next view + xSemaphoreTake(mutex, portMAX_DELAY); + browseCount = 0; + browseDone = 0; + browseTotal = 0; + xSemaphoreGive(mutex); + break; + } + browsing = true; + const bool fetched = fetchBrowseTile(tile); + xSemaphoreTake(mutex, portMAX_DELAY); + browseDone++; + xSemaphoreGive(mutex); + if (!fetched) { + // A failing server is not asked again at once + vTaskDelay(pdMS_TO_TICKS(2000)); + } + } + browsing = false; + giveClient(browseConnection); + browseBody.release(); + if (browseUnsaved > 0) { + browseUnsaved = 0; + writeMeta(browseMeta); + } + } + dropClient(browseConnection); +} + +void Downloader::browseProgress(int& done, int& total) { + xSemaphoreTake(mutex, portMAX_DELAY); + done = browseDone; + total = std::max(browseTotal, browseDone); + xSemaphoreGive(mutex); +} + +void Downloader::noteArrived(const TileKey& tile) { + xSemaphoreTake(mutex, portMAX_DELAY); + if (arrivedCount < MAX_ARRIVED) { + arrived[arrivedCount++] = tile; + } else { + allTilesChanged = true; + } + xSemaphoreGive(mutex); + tilesChanged = true; +} + +bool Downloader::findOutdated(char* region, size_t size) { + for (int i = 0; i < store->regionCount(); i++) { + const char* name = store->regionName(i); + // Browsed tiles have no plan to walk through, they stay as they were fetched + if (strcmp(name, BROWSE_REGION) == 0) { + continue; + } + RegionMeta candidate {}; + if (readMeta(name, candidate) && candidate.isComplete() && candidate.version[0] != '\0' && strcmp(candidate.version, version) != 0) { + snprintf(region, size, "%s", name); + return true; + } + } + return false; +} + +namespace { + +/** Which tiles of a region are already at the new version: maps//update.bin, so an update resumes. */ +struct UpdateProgress { + static constexpr uint32_t MAGIC = 0x5055'4D54; // "TMUP" + static constexpr size_t HEADER = 4 + 4 + 48; + char version[48]; + int64_t total; + int64_t done; + uint8_t* bits; + + bool isSet(int64_t index) const { return (bits[index >> 3] & (1u << (index & 7))) != 0; } + void set(int64_t index) { + if (!isSet(index)) { + bits[index >> 3] = static_cast(bits[index >> 3] | (1u << (index & 7))); + done++; + } + } + size_t bitBytes() const { return static_cast((total + 7) / 8); } +}; + +struct ProgressJob { + char path[PATH_MAX_LENGTH]; + UpdateProgress* progress; + bool write; +}; + +bool progressJob(IoTask& io, void* context) { + auto* job = static_cast(context); + UpdateProgress& progress = *job->progress; + if (job->write) { + const size_t size = UpdateProgress::HEADER + progress.bitBytes(); + auto* bytes = static_cast(largeAlloc(size)); + if (bytes == nullptr) { + return false; + } + const uint32_t magic = UpdateProgress::MAGIC; + const auto total = static_cast(progress.total); + memcpy(bytes, &magic, 4); + memcpy(bytes + 4, &total, 4); + memcpy(bytes + 8, progress.version, 48); + memcpy(bytes + UpdateProgress::HEADER, progress.bits, progress.bitBytes()); + char temporary[PATH_MAX_LENGTH + 8]; + snprintf(temporary, sizeof(temporary), "%s.tmp", job->path); + const bool ok = io.writeFile(job->path, temporary, bytes, size, false); + largeFree(bytes); + return ok; + } + FILE* file = fopen(job->path, "rb"); + if (file == nullptr) { + return false; + } + uint8_t header[UpdateProgress::HEADER]; + bool ok = fread(header, 1, sizeof(header), file) == sizeof(header); + uint32_t magic = 0; + uint32_t total = 0; + if (ok) { + memcpy(&magic, header, 4); + memcpy(&total, header + 4, 4); + ok = magic == UpdateProgress::MAGIC && total == progress.total && memcmp(header + 8, progress.version, 48) == 0 && + fread(progress.bits, 1, progress.bitBytes(), file) == progress.bitBytes(); + } + fclose(file); + if (!ok) { + memset(progress.bits, 0, progress.bitBytes()); + } + return ok; +} + +} // namespace + +void Downloader::runUpdate(bool manual) { + RegionMeta target {}; + if (!readMeta(current.region, target)) { + setStatus(State::Failed, "Region information is missing"); + return; + } + snprintf(target.name, sizeof(target.name), "%s", current.region); + if (!target.isComplete()) { + // An unfinished download continues as a download, which fetches from the newest version anyway + setStatus(State::Failed, "Finish the download first"); + return; + } + if (!wifiConnected()) { + setStatus(State::Failed, "No Wi-Fi connection"); + return; + } + Connection& connection = connections[0]; + connection.owner = this; + connection.index = 0; + connection.body = &bodies[0]; + connection.received = 0; + connection.lastFolder[0] = '\0'; + if (!ensureTileJson(connection)) { + giveClient(connection); + setStatus(State::Failed, "OpenFreeMap is not reachable"); + return; + } + if (strcmp(target.version, version) == 0) { + giveClient(connection); + setStatus(State::Done, "Region is up to date"); + return; + } + + UpdateProgress progress {}; + snprintf(progress.version, sizeof(progress.version), "%s", version); + progress.total = target.total; + progress.bits = static_cast(largeAlloc(progress.bitBytes())); + if (progress.bits == nullptr) { + giveClient(connection); + setStatus(State::Failed, "Out of memory"); + return; + } + memset(progress.bits, 0, progress.bitBytes()); + ProgressJob progressFile {}; + snprintf(progressFile.path, sizeof(progressFile.path), "%s/maps/%s/update.bin", rootPath, target.name); + progressFile.progress = &progress; + io->run(IoPriority::Background, progressJob, &progressFile); + for (int64_t i = 0; i < progress.total; i++) { + progress.done += progress.isSet(i) ? 1 : 0; + } + logInfo("Update %s from %s to %s: %d of %d tiles done", target.name, target.version, version, static_cast(progress.done), + static_cast(progress.total)); + xSemaphoreTake(mutex, portMAX_DELAY); + current.total = progress.total; + current.done = progress.done; + current.bytes = 0; + const BoundingBox view = viewBox; + const bool hasView = viewKnown; + xSemaphoreGive(mutex); + setStatus(State::Updating, manual ? "Updating" : "Updating in idle time"); + + const DownloadPlan plan = target.plan(); + if (!loadOutlineTiles(target)) { + largeFree(progress.bits); + giveClient(connection); + setStatus(State::Failed, "Region outline is missing"); + return; + } + bool failedTile = false; + int unsaved = 0; + auto stopNow = [&] { return stopRequested || cancelRequested || (!manual && !updatesAllowed); }; + auto updateOne = [&](int64_t index) { + TileKey tile {}; + if (index < 0 || index >= progress.total || progress.isSet(index) || !tileForIndex(target, index, tile)) { + return true; + } + bool ok = false; + int status = 0; + for (int attempt = 1; attempt <= 2 && !ok && !stopRequested; attempt++) { + takeClient(connection); + ok = connection.client != nullptr && downloadTile(connection, target.name, tile, status); + if (!ok) { + dropClient(connection); + } + } + if (!ok) { + logError("Update: tile %d/%d/%d failed (HTTP %d)", tile.z, tile.x, tile.y, status); + return false; + } + if (status != 200) { + // The new version has nothing here any more: the old tile goes + RemoveJob old {}; + if (tilePath(old.path, sizeof(old.path), rootPath, target.name, tile)) { + io->run(IoPriority::Background, removeJob, &old); + } + if (preparedPath(old.path, sizeof(old.path), rootPath, target.name, tile)) { + io->run(IoPriority::Background, removeJob, &old); + } + } + progress.set(index); + noteArrived(tile); + xSemaphoreTake(mutex, portMAX_DELAY); + current.done = progress.done; + xSemaphoreGive(mutex); + if (++unsaved >= SAVE_EVERY_TILES) { + unsaved = 0; + progressFile.write = true; + io->run(IoPriority::Background, progressJob, &progressFile); + } + return true; + }; + // The tiles in view first, from the lowest zoom level, then all the others in download order + if (hasView && outlineTiles != nullptr) { + for (int64_t index = 0; index < outlineCount && !stopNow() && !failedTile; index++) { + const TileRange seen = tileRange(view, outlineTiles[index].z); + const TileKey& tile = outlineTiles[index]; + if (tile.x >= seen.minX && tile.x <= seen.maxX && tile.y >= seen.minY && tile.y <= seen.maxY) { + failedTile = !updateOne(index); + } + } + } else if (hasView) { + for (int z = plan.minZoom; z <= plan.maxZoom && !stopNow() && !failedTile; z++) { + const TileRange area = tileRange(plan.box, z); + const TileRange seen = tileRange(view, z); + for (int y = std::max(area.minY, seen.minY); y <= std::min(area.maxY, seen.maxY) && !stopNow() && !failedTile; y++) { + for (int x = std::max(area.minX, seen.minX); x <= std::min(area.maxX, seen.maxX) && !stopNow() && !failedTile; x++) { + failedTile = !updateOne(indexOfTile(plan, { z, x, y })); + } + } + } + } + for (int64_t index = 0; index < progress.total && !stopNow() && !failedTile; index++) { + failedTile = !updateOne(index); + } + giveClient(connection); + bodies[0].release(); + freeOutlineTiles(); + + if (progress.done >= progress.total) { + snprintf(target.version, sizeof(target.version), "%s", version); + time_t now = time(nullptr); + struct tm local {}; + localtime_r(&now, &local); + strftime(target.date, sizeof(target.date), "%Y-%m-%d", &local); + writeMeta(target); + store->setRegionVersion(target.name, target.version); + RemoveJob done {}; + snprintf(done.path, sizeof(done.path), "%s", progressFile.path); + io->run(IoPriority::Background, removeJob, &done); + char message[64]; + snprintf(message, sizeof(message), "%s updated", target.name); + setStatus(State::Done, message); + } else { + progressFile.write = true; + io->run(IoPriority::Background, progressJob, &progressFile); + if (failedTile) { + setStatus(State::Failed, "Update failed, it continues later"); + } else { + // Paused by input: no message, the pill just goes + setStatus(State::Idle, ""); + } + } + largeFree(progress.bits); +} + +void Downloader::runDelete() { + setStatus(State::Deleting, "Deleting"); + if (strcmp(current.region, BROWSE_REGION) == 0) { + // Browsing starts the region again with its next tile + browseMetaReady = false; + } + RegionMeta target {}; + readMeta(current.region, target); + snprintf(target.name, sizeof(target.name), "%s", current.region); + + // Other regions keep the tiles they skipped because this region had them + auto* others = static_cast(largeAlloc(sizeof(RegionMeta) * TileStore::MAX_REGIONS)); + auto* columns = static_cast(largeAlloc(sizeof(int) * MAX_DELETE_COLUMNS)); + if (others == nullptr || columns == nullptr) { + largeFree(others); + largeFree(columns); + setStatus(State::Failed, "Out of memory"); + return; + } + int otherCount = 0; + for (int i = 0; i < store->regionCount() && otherCount < TileStore::MAX_REGIONS; i++) { + const char* name = store->regionName(i); + if (strcmp(name, target.name) != 0 && readMeta(name, others[otherCount])) { + snprintf(others[otherCount].name, sizeof(others[otherCount].name), "%s", name); + otherCount++; + } + } + + // One column folder per job, so the tiles on screen are read in between + for (int z = 0; z <= MAX_SOURCE_ZOOM && !cancelRequested; z++) { + ColumnsJob list {}; + snprintf(list.folder, sizeof(list.folder), "%s/maps/%s/%d", rootPath, target.name, z); + list.columns = columns; + if (!io->run(IoPriority::Background, listColumnsJob, &list)) { + continue; + } + for (int i = 0; i < list.count && !cancelRequested; i++) { + DeleteColumnJob job { rootPath, target.name, z, columns[i], others, otherCount }; + io->run(IoPriority::Background, deleteColumnJob, &job); + } + RemoveJob folder {}; + snprintf(folder.path, sizeof(folder.path), "%s", list.folder); + folder.folder = true; + io->run(IoPriority::Background, removeJob, &folder); + } + largeFree(others); + largeFree(columns); + RemoveJob metaFile {}; + snprintf(metaFile.path, sizeof(metaFile.path), "%s/maps/%s/meta.json", rootPath, target.name); + io->run(IoPriority::Background, removeJob, &metaFile); + RemoveJob folder {}; + snprintf(folder.path, sizeof(folder.path), "%s/maps/%s", rootPath, target.name); + folder.folder = true; + io->run(IoPriority::Background, removeJob, &folder); + allTilesChanged = true; + tilesChanged = true; + setStatus(State::Done, cancelRequested ? "Delete stopped" : "Region deleted"); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/Downloader.h b/Apps/Maps/main/Source/app/Downloader.h new file mode 100644 index 0000000..cb4867e --- /dev/null +++ b/Apps/Maps/main/Source/app/Downloader.h @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IoTask.h" + +#include "../render/PreparedTile.h" +#include "../tiles/BrowsedMove.h" +#include "../tiles/ChunkedBody.h" +#include "../tiles/RegionMeta.h" +#include "../tiles/TileStore.h" + +#include +#include +#include +#include + +#include +#include + +namespace maps { + +/** + * Downloads the tiles of a region from OpenFreeMap in the background, and deletes regions. + * A tile the user already browsed is moved from the region "browsed" into the downloading region instead. + * The tile URL comes from OpenFreeMap's TileJSON. CONNECTIONS connections (one, see there) run at the same time, + * each kept alive across tiles. A tile is received into PSRAM and written by the I/O task, after the tiles on screen. + * Progress is saved in the region's meta.json, so a download continues after a restart. + * + * While browsing, a third connection fetches the tiles on screen that have no detail on the card yet into the + * region "browsed", one tile at a time and only while Wi-Fi is connected. + * + * A TLS handshake costs 2-3.6 s of CPU (the firmware cannot resume sessions), so connections are pooled: a + * finished download or browse run leaves its connection open for the next request of any kind, and one left + * unused for 60 s is closed. New connections are opened one at a time. + * + * Regions whose tiles come from an older OpenFreeMap version are updated while the map is left alone: one tile at a + * time over one connection, the tiles in view first, progress kept in the region's update.bin, any input pauses it. + */ +class Downloader { +public: + /** + * One download connection. Measured on the T-Deck (10 Nov 2026, the same 38 tiles twice each): two connections took + * 31-35 s with 3-4 TLS reads failing for internal RAM (esp-aes) and a 2 s reconnect each, one took 33 s with none, + * small tiles 250 ms each instead of 780 ms, and kept 9 KB more internal RAM free. + */ + static constexpr int CONNECTIONS = 1; + /** + * Network tasks run on core 1. While a TLS handshake (2-3 s of CPU) ran on core 0, the map loop and the render + * lane there made no progress for its whole length (docs/upstream/https-stall.md). + */ + static constexpr BaseType_t NETWORK_CORE = 1; + /** Whether a TLS handshake is running on the network core now; the render lane there waits for it. */ + static bool isHandshaking() { return handshakes.load() > 0; } + + enum class State : uint8_t { + Idle, + Connecting, + Downloading, + Updating, + Deleting, + Done, + Failed + }; + + struct Status { + State state; + char region[REGION_NAME_MAX + 1]; + int64_t done; + int64_t total; + uint64_t bytes; + char message[64]; + }; + + bool start(const char* root, TileStore* store, IoTask* io); + void stop(); + + /** Starts or continues the download of a region whose meta.json exists, pausing a running job. */ + bool download(const char* region); + /** Deletes a region, keeping the tiles another region still needs. */ + bool remove(const char* region); + /** Brings a region to the newest tile version now, without waiting for idle time (Regions, Update now). */ + bool update(const char* region); + /** + * Whether idle-time updates may run: the map was left alone long enough, Wi-Fi and power allow it. Set to false on + * any input, a running idle update stops after the tile it is fetching and continues later. + */ + void setUpdatesAllowed(bool value) { updatesAllowed = value; } + /** The area on screen, so updates fetch the tiles the user sees first. */ + void setView(const BoundingBox& box); + /** The region catalog file (in the app's assets), for regions that follow a catalog outline. */ + void setCatalogPath(const char* path); + /** Asked for a free PSRAM block of bytes when a large tile does not fit; returns whether it made one. */ + using MemoryRelief = bool (*)(void* context, size_t bytes); + void setMemoryRelief(MemoryRelief relief, void* context) { + memoryRelief = relief; + memoryReliefContext = context; + } + void cancel() { cancelRequested = true; } + + Status status(); + bool isBusy() const { return busy.load(); } + /** The tile set version OpenFreeMap serves now, empty until a download asked for it. */ + const char* latestVersion() const { return version; } + /** True once after tiles were added to or removed from the card. */ + /** + * Whether tiles were stored or removed since the last call. count is the number of tiles in tiles (fetched while + * browsing, at most MAX_ARRIVED), or -1 when any tile may have changed. + */ + bool takeTilesChanged(TileKey* tiles, int& count); + static constexpr int MAX_ARRIVED = 8; + + /** Fetches these tiles soon, after the ones already asked for, unless the server had no data for them. */ + void browse(const TileKey* tiles, int count); + /** + * Drops queued browse tiles the view no longer needs: those at another zoom than sourceZoom or outside columns + * minX..maxX (a range that wraps when minX > maxX) and rows minY..maxY. The tile being fetched is left to finish. + */ + void keepBrowse(int sourceZoom, int minX, int minY, int maxX, int maxY); + /** + * Asks OpenFreeMap for its TileJSON now, while Wi-Fi is up. The first connection of a session pauses the + * device for about 1.5 s, better right after the first view than when the user pans into new ground. + */ + void prepareBrowsing(); + /** Whether tiles for the screen are being fetched. */ + bool isBrowsing() const { return browsing.load(); } + /** Tiles fetched and asked for since the browse queue was last empty, for "Fetching detail 2/4". */ + void browseProgress(int& done, int& total); + static bool isOnline(); + /** Browsed tiles moved into the region the current download fills, so far in this run. */ + int movedTiles() const; + /** Idle: no input for a while. Downloaded tiles are only written to the card while idle. */ + void setIdle(bool value) { idle = value; } + TaskHandle_t browseTaskHandle() const { return browseTask; } + TaskHandle_t controlTaskHandle() const { return controlTask; } + /** Logs the phases of the first TileJSON request (developer option). */ + void setTiming(bool value) { timing = value; } + +private: + enum class Job : uint8_t { + None, + Download, + Update, + Delete + }; + + struct Connection { + Downloader* owner; + int index; + void* client; + ChunkedBody* body; + uint64_t received; + TaskHandle_t task; + char lastFolder[PATH_MAX_LENGTH]; + /** When the request now running started, and how long its TCP and TLS connect took (0: reused). */ + uint32_t requestStartMs = 0; + uint32_t connectMs = 0; + /** Whether the client has an open connection the next request can reuse. */ + bool connected = false; + /** Whether this connection holds the handshake lock. */ + bool handshaking = false; + /** Tiles moved from the browsed region instead of downloaded in this run, and their bytes. */ + int movedTiles = 0; + uint64_t moved = 0; + /** Bytes of stale browsed copies removed after a fresh download replaced them. */ + uint64_t browsedRemoved = 0; + }; + + /** What the card has of a tile a download wants. */ + enum class OnCard : uint8_t { Missing, InRegion, Shared, InBrowsed }; + OnCard findTile(const char* region, const TileKey& tile); + /** Moves a browsed tile into the region; Stale when the browsed copy is from another source version. */ + BrowsedMove moveBrowsedTile(Connection& connection, const TileKey& tile, uint64_t& size); + void removeBrowsedCopy(Connection& connection, const TileKey& tile); + /** Takes the bytes of moved and removed tiles off the browsed region's meta.json. */ + void settleBrowsed(uint64_t removedBytes); + + struct IdleClient { + void* client; + uint32_t sinceMs; + }; + + static esp_err_t onHttpEvent(esp_http_client_event_t* event); + /** A keep-alive HTTPS client for OpenFreeMap whose events go to connection. */ + static void* createClient(Connection* connection); + /** Runs the request set up on connection's client, and logs the TLS connection if it had to open one. */ + esp_err_t perform(Connection& connection); + void beginHandshake(Connection& connection); + void endHandshake(Connection& connection); + /** Gives connection an open pooled client, or a new one. */ + void takeClient(Connection& connection); + /** Returns connection's client to the pool when it is still connected, otherwise closes it. */ + void giveClient(Connection& connection); + static void dropClient(Connection& connection); + void closeIdleClients(uint32_t maxIdleMs); + bool queue(Job next, const char* region); + static void controlMain(void* context); + static void connectionMain(void* context); + void controlLoop(); + void runDownload(); + void runDelete(); + /** Updates one region whose tiles are older than the source; manual: not paused by input. */ + void runUpdate(bool manual); + /** The name of a complete region with an older tile version than the source, or false. */ + bool findOutdated(char* region, size_t size); + void noteArrived(const TileKey& tile); + /** + * For a region with a catalog outline: the list of its tiles, zoom by zoom, row by row, made once per job from the + * catalog so the order never changes. Without an outline the tiles come from the plan's rectangle. + */ + bool loadOutlineTiles(const RegionMeta& region); + void freeOutlineTiles(); + bool tileForIndex(const RegionMeta& region, int64_t index, TileKey& tile) const; + /** The nearest city, town or village to the region's centre, from its own z12 or z10 tile. */ + bool findPlaceName(const RegionMeta& region, char* out, size_t size); + void connectionLoop(Connection& connection); + bool fetchTileJson(Connection& connection); + /** waitForIdle: the tile is written only once the map is idle (downloads); tiles for the screen are written at once. */ + bool downloadTile(Connection& connection, const char* region, const TileKey& tile, int& status, bool waitForIdle = true); + bool tileOnCard(const char* region, const TileKey& tile, bool& inThisRegion); + bool ensureTileJson(Connection& connection); + static void browseMain(void* context); + void browseLoop(); + bool fetchBrowseTile(const TileKey& tile); + bool ensureBrowseRegion(const TileKey& tile); + void saveProgress(); + bool readMeta(const char* region, RegionMeta& out); + bool writeMeta(const RegionMeta& out); + void setStatus(State state, const char* message); + bool claimIndex(int64_t& index, int connectionIndex); + void finishIndex(int connectionIndex); + + char rootPath[PATH_MAX_LENGTH] = ""; + TileStore* store = nullptr; + IoTask* io = nullptr; + SemaphoreHandle_t mutex = nullptr; + SemaphoreHandle_t wake = nullptr; + SemaphoreHandle_t stopped = nullptr; + TaskHandle_t controlTask = nullptr; + std::atomic stopRequested = false; + std::atomic cancelRequested = false; + std::atomic busy = false; + std::atomic tilesChanged = false; + std::atomic allTilesChanged = false; + TileKey arrived[MAX_ARRIVED] {}; + int arrivedCount = 0; + Job job = Job::None; + char jobRegion[REGION_NAME_MAX + 1] = ""; + Status current {}; + RegionMeta meta {}; + char urlTemplate[160] = ""; + char version[48] = ""; + Connection connections[CONNECTIONS] {}; + ChunkedBody bodies[CONNECTIONS]; + SemaphoreHandle_t tileJsonLock = nullptr; + SemaphoreHandle_t browseWake = nullptr; + SemaphoreHandle_t browseStopped = nullptr; + TaskHandle_t browseTask = nullptr; + static constexpr int BROWSE_QUEUE = 16; + static constexpr int BROWSE_EMPTY = 64; + TileKey browseQueue[BROWSE_QUEUE] {}; + int browseCount = 0; + int browseDone = 0; + int browseTotal = 0; + /** Tiles the server had no data for, the sea or empty land, so they are not asked for again. */ + TileKey browseEmpty[BROWSE_EMPTY] {}; + int browseEmptyCount = 0; + int browseEmptyNext = 0; + std::atomic browsing = false; + std::atomic idle = true; + std::atomic warmUp = false; + bool timing = false; + Connection browseConnection {}; + ChunkedBody browseBody; + MemoryRelief memoryRelief = nullptr; + void* memoryReliefContext = nullptr; + RegionMeta browseMeta {}; + bool browseMetaReady = false; + int browseUnsaved = 0; + SemaphoreHandle_t connectionsDone = nullptr; + int64_t nextIndex = 0; + int64_t activeIndex[CONNECTIONS] {}; + int64_t sinceSave = 0; + std::atomic failed = false; + static constexpr int IDLE_CLIENTS = CONNECTIONS + 1; + IdleClient idleClients[IDLE_CLIENTS] {}; + int idleCount = 0; + SemaphoreHandle_t handshakeLock = nullptr; + static std::atomic handshakes; + std::atomic updatesAllowed = false; + BoundingBox viewBox {}; + bool viewKnown = false; + uint32_t lastUpdateCheckMs = 0; + char catalogPath[PATH_MAX_LENGTH] = ""; + TileKey* outlineTiles = nullptr; + int64_t outlineCount = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/GpxImport.cpp b/Apps/Maps/main/Source/app/GpxImport.cpp new file mode 100644 index 0000000..0ff0b25 --- /dev/null +++ b/Apps/Maps/main/Source/app/GpxImport.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "GpxImport.h" + +#include "../platform/Platform.h" +#include "../render/PreparedTile.h" + +#include +#include + +#include +#include +#include + +namespace maps { + +namespace { + +constexpr uint32_t TASK_STACK = 6144; +constexpr UBaseType_t TASK_PRIORITY = 2; +constexpr size_t CHUNK = 8192; + +struct ChunkJob { + const char* path; + long offset; + char* buffer; + size_t count; + long fileSize; +}; + +bool readChunkJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + FILE* file = fopen(job->path, "rb"); + if (file == nullptr) { + return false; + } + bool ok = true; + if (job->fileSize < 0) { + ok = fseek(file, 0, SEEK_END) == 0; + job->fileSize = ok ? ftell(file) : -1; + } + ok = ok && fseek(file, job->offset, SEEK_SET) == 0; + job->count = ok ? fread(job->buffer, 1, CHUNK, file) : 0; + fclose(file); + return ok; +} + +struct CacheJob { + char path[PATH_MAX_LENGTH + 8]; + FileBuffer* buffer; + const ByteWriter* data; +}; + +bool readCacheJob(IoTask& io, void* context) { + auto* job = static_cast(context); + return io.readFile(job->path, *job->buffer); +} + +bool writeCacheJob(IoTask& io, void* context) { + auto* job = static_cast(context); + char temporary[PATH_MAX_LENGTH + 16]; + snprintf(temporary, sizeof(temporary), "%s.tmp", job->path); + return io.writeFile(job->path, temporary, job->data->bytes(), job->data->size(), false); +} + +} // namespace + +GpxImport::~GpxImport() { + // The map ends the app only when no import runs, see MapView::end() + if (layer != nullptr) { + layer->~GpxLayer(); + largeFree(layer); + } +} + +bool GpxImport::start(IoTask& ioTask, const char* path) { + if (currentState != State::Idle) { + return false; + } + io = &ioTask; + snprintf(filePath, sizeof(filePath), "%s", path); + message[0] = '\0'; + currentState = State::Busy; + if (xTaskCreatePinnedToCoreWithCaps(taskMain, "maps-gpx", TASK_STACK, this, TASK_PRIORITY, &task, 1, MALLOC_CAP_SPIRAM) != pdPASS) { + currentState = State::Idle; + return false; + } + return true; +} + +void GpxImport::taskMain(void* context) { + auto* import = static_cast(context); + import->run(); + vTaskSuspend(nullptr); +} + +void GpxImport::run() { + void* memory = largeAlloc(sizeof(GpxLayer)); + layer = memory != nullptr ? new (memory) GpxLayer() : nullptr; + if (layer == nullptr) { + snprintf(message, sizeof(message), "Not enough memory"); + currentState = State::Failed; + return; + } + // The cache sits next to the file: walk.gpx -> walk.gpc + CacheJob cache {}; + snprintf(cache.path, sizeof(cache.path), "%s", filePath); + const size_t length = strlen(cache.path); + if (length > 4) { + cache.path[length - 1] = 'c'; + } + char* buffer = static_cast(largeAlloc(CHUNK)); + if (buffer == nullptr) { + snprintf(message, sizeof(message), "Not enough memory"); + currentState = State::Failed; + return; + } + ChunkJob chunk { filePath, 0, buffer, 0, -1 }; + if (!io->run(IoPriority::Background, readChunkJob, &chunk) || chunk.fileSize < 0) { + largeFree(buffer); + snprintf(message, sizeof(message), "Cannot read the file"); + currentState = State::Failed; + return; + } + const auto sourceSize = static_cast(chunk.fileSize); + FileBuffer cached; + cache.buffer = &cached; + if (io->run(IoPriority::Background, readCacheJob, &cache) && layer->load(cached.bytes(), cached.size(), sourceSize)) { + largeFree(buffer); + logInfo("GPX %s from its cache: %d lines, %d waypoints", filePath, layer->lineCount(), layer->pinCount()); + currentState = State::Done; + return; + } + // No current cache: the file is read once, in chunks, with the card free for the map in between + void* builderMemory = largeAlloc(sizeof(GpxCacheBuilder)); + auto* builder = builderMemory != nullptr ? new (builderMemory) GpxCacheBuilder() : nullptr; + bool ok = builder != nullptr; + const uint32_t start = nowMs(); + while (ok && chunk.count > 0) { + ok = builder->feed(buffer, chunk.count); + chunk.offset += static_cast(chunk.count); + if (ok && chunk.offset < chunk.fileSize) { + ok = io->run(IoPriority::Background, readChunkJob, &chunk); + } else { + chunk.count = 0; + } + vTaskDelay(1); + } + largeFree(buffer); + ByteWriter data; + const bool built = builder != nullptr && builder->finish(sourceSize, data); + if (!built) { + snprintf(message, sizeof(message), "%s", builder == nullptr ? "Not enough memory" : builder->error()); + } else { + logInfo("GPX %s read in %u ms: %d of %d points kept", filePath, nowMs() - start, builder->pointsKept(), builder->pointsRead()); + } + if (builder != nullptr) { + builder->~GpxCacheBuilder(); + largeFree(builder); + } + if (!built || !layer->load(data.bytes(), data.size(), sourceSize)) { + if (message[0] == '\0') { + snprintf(message, sizeof(message), "Not a usable GPX file"); + } + currentState = State::Failed; + return; + } + cache.data = &data; + if (!io->run(IoPriority::Background, writeCacheJob, &cache)) { + // Only slower next time + logError("Could not write %s", cache.path); + } + currentState = State::Done; +} + +GpxLayer* GpxImport::takeLayer() { + GpxLayer* result = currentState == State::Done ? layer : nullptr; + if (result != nullptr) { + layer = nullptr; + } + acknowledge(); + return result; +} + +void GpxImport::acknowledge() { + if (currentState == State::Done || currentState == State::Failed) { + if (task != nullptr) { + // The state is set just before the task suspends itself + TaskStatus_t status {}; + for (int i = 0; i < 100; i++) { + vTaskGetInfo(task, &status, pdFALSE, eInvalid); + if (status.eCurrentState == eSuspended) { + break; + } + vTaskDelay(pdMS_TO_TICKS(5)); + } + vTaskDeleteWithCaps(task); + task = nullptr; + } + if (layer != nullptr) { + layer->~GpxLayer(); + largeFree(layer); + layer = nullptr; + } + currentState = State::Idle; + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/GpxImport.h b/Apps/Maps/main/Source/app/GpxImport.h new file mode 100644 index 0000000..d519357 --- /dev/null +++ b/Apps/Maps/main/Source/app/GpxImport.h @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IoTask.h" + +#include "../tiles/TilePaths.h" +#include "../ui/GpxLayer.h" + +#include +#include + +#include + +namespace maps { + +/** + * Reads one GPX file at a time in the background into a GpxLayer: from its .gpc cache next to it when that is + * current, otherwise by parsing the GPX in 8 KB chunks through the I/O task and writing a new cache. The map polls + * for the result and takes the layer. + */ +class GpxImport { +public: + enum class State : uint8_t { + Idle, + Busy, + Done, + Failed + }; + + ~GpxImport(); + /** Starts reading path; false while another file is still being read. */ + bool start(IoTask& io, const char* path); + State state() const { return currentState.load(); } + /** After Done: the layer, now owned by the caller; after Failed: why. Both reset the state to Idle. */ + GpxLayer* takeLayer(); + const char* error() const { return message; } + void acknowledge(); + const char* path() const { return filePath; } + +private: + static void taskMain(void* context); + void run(); + void finish(); + + IoTask* io = nullptr; + TaskHandle_t task = nullptr; + std::atomic currentState = State::Idle; + char filePath[PATH_MAX_LENGTH] = ""; + char message[64] = ""; + GpxLayer* layer = nullptr; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/Input.cpp b/Apps/Maps/main/Source/app/Input.cpp new file mode 100644 index 0000000..7bb7c85 --- /dev/null +++ b/Apps/Maps/main/Source/app/Input.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Input.h" + +#include "../platform/Platform.h" + +#include +#include +#include +#include + +namespace maps { + +namespace { + +constexpr uint32_t RESCAN_INTERVAL_MS = 500; + +struct Collect { + Device** devices; + int* count; +}; + +bool collectDevice(Device* device, void* context) { + auto* collect = static_cast(context); + if (*collect->count >= Input::MAX_DEVICES || !device_is_ready(device)) { + return true; + } + for (int i = 0; i < *collect->count; i++) { + if (collect->devices[i] == device) { + return true; + } + } + if (device_get(device) == ERROR_NONE) { + collect->devices[(*collect->count)++] = device; + } + return true; +} + +} // namespace + +Input::~Input() { + end(); +} + +void Input::begin() { + rescan(); + devicesChanged = false; + if (device_get_first_active_by_type(&TOUCH_TYPE, &touch) != ERROR_NONE) { + touch = nullptr; + } + logInfo("Input: %d keyboards, %d mice, %s, %s", keyboardCount, mouseCount, typingKeyboard ? "typing keyboard" : "no typing keyboard", + touch != nullptr ? "touch" : "no touch"); +} + +void Input::end() { + for (int i = 0; i < keyboardCount; i++) { + device_put(keyboards[i]); + } + keyboardCount = 0; + for (int i = 0; i < mouseCount; i++) { + device_put(mice[i]); + } + mouseCount = 0; + if (touch != nullptr) { + device_put(touch); + touch = nullptr; + } +} + +int Input::dropStopped(Device** devices, int& count) { + int dropped = 0; + for (int i = 0; i < count;) { + if (device_is_ready(devices[i])) { + i++; + continue; + } + logInfo("Input: %s stopped", devices[i]->name != nullptr ? devices[i]->name : "a device"); + device_put(devices[i]); + devices[i] = devices[--count]; + dropped++; + } + return dropped; +} + +void Input::rescan() { + lastScanMs = nowMs(); + const int keyboardsBefore = keyboardCount; + const int miceBefore = mouseCount; + const int dropped = dropStopped(keyboards, keyboardCount) + dropStopped(mice, mouseCount); + Collect keyboardCollect { keyboards, &keyboardCount }; + device_for_each_of_type(&KEYBOARD_TYPE, &keyboardCollect, collectDevice); + Collect mouseCollect { mice, &mouseCount }; + device_for_each_of_type(&MOUSE_TYPE, &mouseCollect, collectDevice); + // A started keyboard device (the kernel's hardware discovery), and one of them present: buttons + // (button-control) say they are not since the software keyboard fix, so they show no key hints + const bool typingBefore = typingKeyboard; + typingKeyboard = device_has_active_by_type(&KEYBOARD_TYPE) && lvgl_hardware_keyboard_is_available(); + if (dropped > 0 || keyboardCount != keyboardsBefore || mouseCount != miceBefore || typingKeyboard != typingBefore) { + devicesChanged = true; + logInfo("Input: %d keyboards, %d mice, %s", keyboardCount, mouseCount, typingKeyboard ? "typing keyboard" : "no typing keyboard"); + } +} + +void Input::refreshIfDue() { + if (nowMs() - lastScanMs > RESCAN_INTERVAL_MS) { + rescan(); + } +} + +bool Input::takeDevicesChanged() { + const bool changed = devicesChanged; + devicesChanged = false; + return changed; +} + +int Input::readKeys(KeyEvent* events, int maxEvents) { + if (touchOnly) { + return 0; + } + refreshIfDue(); + int count = 0; + for (int i = 0; i < keyboardCount && count < maxEvents; i++) { + if (!device_is_ready(keyboards[i])) { + continue; + } + KeyboardKeyData data; + while (count < maxEvents && keyboard_read_key(keyboards[i], &data) == ERROR_NONE && data.key != 0) { + events[count++] = { data.key, data.pressed }; + if (!data.continue_reading) { + break; + } + } + } + return count; +} + +bool Input::readMouse(int32_t& dx, int32_t& dy) { + if (touchOnly) { + dx = 0; + dy = 0; + return false; + } + refreshIfDue(); + dx = 0; + dy = 0; + bool pressed = false; + for (int i = 0; i < mouseCount; i++) { + if (!device_is_ready(mice[i])) { + continue; + } + int32_t x = 0; + int32_t y = 0; + if (mouse_read_delta(mice[i], &x, &y) == ERROR_NONE) { + dx += x; + dy += y; + } + bool button = false; + if (mouse_get_button_count(mice[i]) > 0 && mouse_get_button_pressed(mice[i], 0, &button) == ERROR_NONE && button) { + pressed = true; + } + } + return pressed; +} + +bool Input::readTouch(TouchState& state) { + state.count = 0; + if (touch == nullptr || touch_read_data(touch, 0) != ERROR_NONE) { + return false; + } + uint16_t strength[2]; + uint8_t count = 0; + if (!touch_get_touched_points(touch, state.x, state.y, strength, &count, 2)) { + return false; + } + state.count = count > 2 ? 2 : count; + return state.count > 0; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/Input.h b/Apps/Maps/main/Source/app/Input.h new file mode 100644 index 0000000..e2a6c96 --- /dev/null +++ b/Apps/Maps/main/Source/app/Input.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +#include + +namespace maps { + +struct KeyEvent { + uint32_t key; + bool pressed; +}; + +struct TouchState { + uint8_t count; + uint16_t x[2]; + uint16_t y[2]; +}; + +/** + * Reads keyboards (the T-Deck keyboard and a trackball in keyboard mode), mice (a trackball in mouse mode) and + * the touch screen directly from their drivers while LVGL is stopped. + * + * A trackball driver replaces its child device when its mode changes: the keyboard one is stopped and removed, a + * mouse one is added, or the other way round. The device lists are refreshed twice a second, devices that stopped + * are let go at once (a held reference keeps the driver from destroying the old child), and new ones are taken. + */ +class Input { +public: + static constexpr int MAX_DEVICES = 4; + + ~Input(); + + void begin(); + void end(); + + /** Returns the number of key events written to events. */ + int readKeys(KeyEvent* events, int maxEvents); + /** Sums the movement of all mice since the last call. Returns true while a mouse button is held. */ + bool readMouse(int32_t& dx, int32_t& dy); + bool readTouch(TouchState& state); + bool hasTouch() const { return touch != nullptr; } + /** + * A keyboard that types text, so key hints make sense. Keys are read from every keyboard device, but buttons + * (button-control) and a trackball also register as one: only a started keyboard device that reports itself + * present counts, as for Tactility's software keyboard. + */ + bool hasKeyboard() const { return typingKeyboard && !touchOnly; } + bool hasPointer() const { return mouseCount > 0 && !touchOnly; } + /** Debug scripts ("touchonly"): act as a board with only a touch screen; keyboards and pointers are ignored. */ + void setTouchOnly(bool value) { touchOnly = value; } + /** True once after a keyboard or mouse came or went, so the overlay can adapt. */ + bool takeDevicesChanged(); + +private: + void rescan(); + void refreshIfDue(); + /** Lets go of devices that stopped. Returns how many. */ + static int dropStopped(Device** devices, int& count); + + Device* keyboards[MAX_DEVICES] {}; + bool touchOnly = false; + bool typingKeyboard = false; + int keyboardCount = 0; + Device* mice[MAX_DEVICES] {}; + int mouseCount = 0; + Device* touch = nullptr; + uint32_t lastScanMs = 0; + bool devicesChanged = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/IoTask.cpp b/Apps/Maps/main/Source/app/IoTask.cpp new file mode 100644 index 0000000..cc9a050 --- /dev/null +++ b/Apps/Maps/main/Source/app/IoTask.cpp @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "IoTask.h" + +#include "../platform/Platform.h" + +#include +#include + +namespace maps { + +namespace { + +/** Jobs run on this stack: folder walks, meta.json text (1 KB) parsed with sscanf, FatFs long names, paths. */ +constexpr uint32_t TASK_STACK = 8192; +/** + * The same as the app task and the render lanes. The SD card driver keeps the CPU busy while it transfers, + * and on the T-Deck the display shares the SPI bus, so a higher priority starved drawing (frames of 1-3 s). + */ +constexpr UBaseType_t TASK_PRIORITY = 4; +/** Longer jobs are logged, they hold up everything behind them. */ +constexpr uint32_t SLOW_JOB_MS = 300; + +} // namespace + +bool IoTask::start() { + mutex = xSemaphoreCreateMutex(); + wake = xSemaphoreCreateCounting(64, 0); + stopped = xSemaphoreCreateBinary(); + if (mutex == nullptr || wake == nullptr || stopped == nullptr) { + return false; + } + stopRequested = false; + // Not pinned, so it never takes a core from one render lane only + if (xTaskCreatePinnedToCore(taskMain, "maps-io", TASK_STACK, this, TASK_PRIORITY, &task, tskNO_AFFINITY) != pdPASS) { + task = nullptr; + return false; + } + runningTask = true; + // Job addresses in the log are matched against the ELF's symbols through this one + logInfo("I/O task started, taskMain at %p", reinterpret_cast(&IoTask::taskMain)); + return true; +} + +void IoTask::stop() { + if (task != nullptr) { + stopRequested = true; + xSemaphoreGive(wake); + xSemaphoreTake(stopped, portMAX_DELAY); + task = nullptr; + } + runningTask = false; + for (SemaphoreHandle_t* handle : { &mutex, &wake, &stopped }) { + if (*handle != nullptr) { + vSemaphoreDelete(*handle); + *handle = nullptr; + } + } +} + +void IoTask::taskMain(void* context) { + auto* io = static_cast(context); + io->loop(); + xSemaphoreGive(io->stopped); + vTaskDelete(nullptr); +} + +bool IoTask::run(IoPriority priority, IoJob job, void* context) { + if (!runningTask) { + return false; + } + SemaphoreHandle_t done = xSemaphoreCreateBinary(); + if (done == nullptr) { + return false; + } + Request request { job, context, done, false, nowMs(), prefetchGeneration.load() }; + xSemaphoreTake(mutex, portMAX_DELAY); + const bool queued = queue.push(&request, priority); + xSemaphoreGive(mutex); + if (!queued) { + vSemaphoreDelete(done); + logError("I/O queue full"); + return false; + } + xSemaphoreGive(wake); + xSemaphoreTake(done, portMAX_DELAY); + vSemaphoreDelete(done); + return request.result; +} + +void IoTask::waitWhileHeld() { + // A frame goes to the display in strips, each of which waited for a whole card chunk: 150-250 ms per frame + for (int wait = 0; held && !stopRequested && wait < MAX_HOLD_MS / 5; wait++) { + vTaskDelay(pdMS_TO_TICKS(5)); + } +} + +bool IoTask::shouldYield() const { + if (running == IoPriority::Visible) { + return false; + } + if (running == IoPriority::Prefetch && runningGeneration != prefetchGeneration.load()) { + return true; + } + xSemaphoreTake(mutex, portMAX_DELAY); + const bool urgent = queue.hasMoreUrgent(running); + xSemaphoreGive(mutex); + return urgent; +} + +void IoTask::loop() { + while (true) { + xSemaphoreTake(wake, portMAX_DELAY); + while (true) { + Request* request = nullptr; + IoPriority priority = IoPriority::Background; + xSemaphoreTake(mutex, portMAX_DELAY); + queue.pop(request, &priority); + xSemaphoreGive(mutex); + if (request == nullptr) { + break; + } + waitWhileHeld(); + if (stopRequested) { + request->result = false; + } else { + running = priority; + runningGeneration = request->prefetchGeneration; + runningYielded = false; + if (priority == IoPriority::Visible) { + const uint32_t waited = nowMs() - request->queuedAt; + longestVisibleWait = waited > longestVisibleWait ? waited : longestVisibleWait; + } + // A prefetch cancelled while it waited does not start + if (priority == IoPriority::Prefetch && runningGeneration != prefetchGeneration.load()) { + runningYielded = true; + request->result = false; + } else { + const uint32_t started = nowMs(); + request->result = request->job(*this, request->context); + const uint32_t took = nowMs() - started; + if (took > SLOW_JOB_MS) { + logInfo("Slow I/O job %u ms (priority %d, waited %u ms, job %p)", took, static_cast(priority), started - request->queuedAt, + reinterpret_cast(request->job)); + } + } + running = IoPriority::Background; + } + xSemaphoreGive(request->done); + } + if (stopRequested) { + break; + } + } +} + +bool IoTask::readFile(const char* path, FileBuffer& buffer, size_t limit) { + buffer.clear(); + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return false; + } + bool ok = fseek(file, 0, SEEK_END) == 0; + long size = ok ? ftell(file) : -1; + if (limit > 0) { + ok = ok && size >= static_cast(limit); + size = static_cast(limit); + } + ok = ok && size > 0 && fseek(file, 0, SEEK_SET) == 0 && buffer.reserve(static_cast(size)); + size_t done = 0; + while (ok && done < static_cast(size)) { + if (done > 0 && shouldYield()) { + markYielded(); + ok = false; + break; + } + if (done > 0) { + // Between chunks the display gets the shared SPI bus + vTaskDelay(1); + } + waitWhileHeld(); + const size_t wanted = static_cast(size) - done < CHUNK_BYTES ? static_cast(size) - done : CHUNK_BYTES; + const size_t count = fread(buffer.writable() + done, 1, wanted, file); + ok = count == wanted; + done += count; + } + fclose(file); + if (ok) { + buffer.setSize(done); + } + return ok; +} + +bool IoTask::writeFile(const char* path, const char* temporaryPath, const uint8_t* bytes, size_t size, bool canYield) { + const uint8_t* parts[1] = { bytes }; + const size_t sizes[1] = { size }; + return writeParts(path, temporaryPath, parts, sizes, 1, canYield); +} + +bool IoTask::writeFile(const char* path, const char* temporaryPath, const ChunkedBody& body, bool canYield) { + const uint8_t* parts[ChunkedBody::MAX_CHUNKS]; + size_t sizes[ChunkedBody::MAX_CHUNKS]; + const int count = body.chunkCount(); + for (int i = 0; i < count; i++) { + parts[i] = body.chunk(i); + sizes[i] = body.chunkSize(i); + } + return writeParts(path, temporaryPath, parts, sizes, count, canYield); +} + +bool IoTask::writeParts(const char* path, const char* temporaryPath, const uint8_t* const* parts, const size_t* sizes, int partCount, + bool canYield) { + FILE* file = fopen(temporaryPath, "wb"); + if (file == nullptr) { + return false; + } + bool ok = true; + bool started = false; + for (int part = 0; ok && part < partCount; part++) { + size_t done = 0; + while (ok && done < sizes[part]) { + if (canYield && started && shouldYield()) { + markYielded(); + ok = false; + break; + } + if (started) { + vTaskDelay(1); + } + waitWhileHeld(); + const size_t count = sizes[part] - done < CHUNK_BYTES ? sizes[part] - done : CHUNK_BYTES; + ok = fwrite(parts[part] + done, 1, count, file) == count; + done += count; + started = true; + } + } + ok = fclose(file) == 0 && ok; + // Most files are new: the old one is only removed when the rename finds it, each FAT operation holds the card + if (ok && rename(temporaryPath, path) != 0) { + remove(path); + ok = rename(temporaryPath, path) == 0; + } + if (!ok) { + remove(temporaryPath); + } + return ok; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/IoTask.h b/Apps/Maps/main/Source/app/IoTask.h new file mode 100644 index 0000000..c5a48e4 --- /dev/null +++ b/Apps/Maps/main/Source/app/IoTask.h @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../tiles/ChunkedBody.h" +#include "../mvt/ByteInput.h" +#include "../tiles/IoQueue.h" + +#include +#include +#include + +#include +#include +#include + +namespace maps { + +class IoTask; + +/** A piece of SD card work. It runs on the I/O task and returns whether it succeeded. */ +using IoJob = bool (*)(IoTask& io, void* context); + +/** + * The only task that reads or writes the SD card while the map runs. Render lanes, the downloader and the UI + * hand it jobs and wait for them; it runs the most urgent waiting job first. Files are read with few large + * reads into PSRAM buffers, and a long read or write of lower priority stops between chunks when a more + * urgent job arrives, so its owner can queue it again. + */ +class IoTask { +public: + /** Small enough that the display, on the same SPI bus on the T-Deck, waits at most about 40 ms. */ + static constexpr size_t CHUNK_BYTES = 16 * 1024; + + bool start(); + void stop(); + + /** Runs a job on the I/O task and waits for its result. Returns false right away when the task is not running. */ + bool run(IoPriority priority, IoJob job, void* context); + + /** For jobs: whether the running job should stop because a more urgent one waits, or prefetch was cancelled. */ + bool shouldYield() const; + /** + * Holds back card work while true: no new job and no next chunk starts, for at most MAX_HOLD_MS. For zoom + * animations, whose frames otherwise wait for the card on the SPI bus the T-Deck's display shares. + */ + void hold(bool value) { held = value; } + static constexpr int MAX_HOLD_MS = 400; + /** For jobs: whether the running job stopped early through shouldYield(). Reset for each job. */ + bool yielded() const { return runningYielded; } + void markYielded() { runningYielded = true; } + IoPriority runningPriority() const { return running; } + + /** Makes the running and waiting prefetch reads stop at their next chunk, after input moved the view. */ + void cancelPrefetch() { prefetchGeneration++; } + + /** + * For jobs: reads a whole file into buffer in large chunks, or only its first limit bytes. Returns false on error, + * when the file is shorter than limit, or when it yielded. + */ + bool readFile(const char* path, FileBuffer& buffer, size_t limit = 0); + /** For jobs: writes bytes to path through path.tmp and a rename, in large chunks. */ + bool writeFile(const char* path, const char* temporaryPath, const uint8_t* bytes, size_t size, bool canYield); + /** The same for a file in pieces. */ + bool writeFile(const char* path, const char* temporaryPath, const ChunkedBody& body, bool canYield); + + /** Statistics for the log: jobs run, and the longest wait of a visible job. */ + uint32_t longestVisibleWaitMs() const { return longestVisibleWait; } + /** The fewest bytes of stack the task had left so far. */ + TaskHandle_t taskHandle() const { return task; } + uint32_t stackLeft() const { return task != nullptr ? static_cast(uxTaskGetStackHighWaterMark(task)) : 0; } + +private: + bool writeParts(const char* path, const char* temporaryPath, const uint8_t* const* parts, const size_t* sizes, int partCount, bool canYield); + struct Request { + IoJob job; + void* context; + SemaphoreHandle_t done; + bool result; + uint32_t queuedAt; + uint32_t prefetchGeneration; + }; + + static void taskMain(void* context); + void loop(); + + TaskHandle_t task = nullptr; + SemaphoreHandle_t mutex = nullptr; + SemaphoreHandle_t wake = nullptr; + SemaphoreHandle_t stopped = nullptr; + std::atomic stopRequested = false; + std::atomic held = false; + void waitWhileHeld(); + std::atomic runningTask = false; + IoQueue queue; + IoPriority running = IoPriority::Background; + uint32_t runningGeneration = 0; + bool runningYielded = false; + std::atomic prefetchGeneration = 0; + uint32_t longestVisibleWait = 0; +}; + +/** Runs a lambda on the I/O task and waits for it, for the UI's occasional file access. */ +template +bool runOnCard(IoTask& io, Function& function, IoPriority priority = IoPriority::Visible) { + return io.run(priority, [](IoTask&, void* context) { return (*static_cast(context))(); }, &function); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/LabelFontMaker.cpp b/Apps/Maps/main/Source/app/LabelFontMaker.cpp new file mode 100644 index 0000000..0060b5f --- /dev/null +++ b/Apps/Maps/main/Source/app/LabelFontMaker.cpp @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "LabelFontMaker.h" + +#include "../mvt/ByteInput.h" +#include "../platform/Platform.h" + +#include +#include +#include + +#include + +#include +#include + +namespace maps { + +namespace { + +/** The stb_truetype rasteriser in the generator keeps its work areas on the stack. */ +constexpr uint32_t TASK_STACK = 12288; +constexpr UBaseType_t TASK_PRIORITY = 1; +/** When stop() logs that a font in progress holds it up. */ +constexpr int STOP_WAIT_MS = 6000; + +struct ReadJob { + const char* path; + FileBuffer* buffer; + bool yielded; +}; + +bool readJob(IoTask& io, void* context) { + auto* job = static_cast(context); + const bool ok = io.readFile(job->path, *job->buffer); + job->yielded = io.yielded(); + return ok; +} + +struct WriteJob { + const char* folder; + const char* path; + const uint8_t* bytes; + size_t size; +}; + +bool writeJob(IoTask& io, void* context) { + auto* job = static_cast(context); + // The folder is made on first use; it exists already afterwards + mkdir(job->folder, 0777); + char temporary[200]; + snprintf(temporary, sizeof(temporary), "%s.tmp", job->path); + return io.writeFile(job->path, temporary, job->bytes, job->size, false); +} + +} // namespace + +void LabelFontMaker::cachePath(const char* root, int i, char* out, size_t size) { + snprintf(out, size, "%s/fonts/lbl%d%c.bin", root, EM_SIZES[i], FONT_VERSION); +} + +namespace { + +struct RemoveOldJob { + const char* root; + int emSize; +}; + +/** Deletes the cached sizes an older font version made: the cache keeps one copy of each glyph. */ +bool removeOldJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + for (char version = 'a'; version < LabelFontMaker::FONT_VERSION; version++) { + char path[200]; + snprintf(path, sizeof(path), "%s/fonts/lbl%d%c.bin", job->root, job->emSize, version); + remove(path); + } + return true; +} + +} // namespace + +void LabelFontMaker::configure(IoTask& ioTask, const char* root, const char* ttf) { + io = &ioTask; + snprintf(rootPath, sizeof(rootPath), "%s", root); + snprintf(ttfPath, sizeof(ttfPath), "%s", ttf); +} + +bool LabelFontMaker::start(unsigned missingMask) { + if (io == nullptr || task != nullptr || missingMask == 0) { + return false; + } + wanted = missingMask; + finished = false; + stopRequested = false; + written = 0; + if (xTaskCreatePinnedToCoreWithCaps(taskMain, "maps-fonts", TASK_STACK, this, TASK_PRIORITY, &task, tskNO_AFFINITY, + MALLOC_CAP_SPIRAM) != pdPASS) { + task = nullptr; + logError("Label font task not started, the map keeps the system fonts"); + return false; + } + return true; +} + +void LabelFontMaker::taskMain(void* context) { + auto* maker = static_cast(context); + maker->run(); + maker->finished = true; + // Deleted by stop(), a task cannot free its own PSRAM stack + vTaskSuspend(nullptr); +} + +void LabelFontMaker::run() { + const uint32_t start = nowMs(); + FileBuffer ttf; + ReadJob read { ttfPath, &ttf, false }; + bool ok = false; + // A background read gives way to the map's tile reads; while the map is busy it is simply tried again + for (int attempt = 0; attempt < 600 && !stopRequested; attempt++) { + ok = io->run(IoPriority::Background, readJob, &read); + if (ok || !read.yielded) { + break; + } + vTaskDelay(pdMS_TO_TICKS(50)); + } + if (!ok || ttf.size() == 0) { + logError("Label font: cannot read %s, the map keeps the system fonts", ttfPath); + return; + } + char folder[180]; + snprintf(folder, sizeof(folder), "%s/fonts", rootPath); + for (int i = 0; i < COUNT && !stopRequested; i++) { + if ((wanted & (1u << i)) == 0) { + continue; + } + const uint32_t fontStart = nowMs(); + BinFontGeneratorConfig config {}; + config.ttf_data = ttf.bytes(); + config.ttf_size = ttf.size(); + config.size = static_cast(EM_SIZES[i]); + config.bpp = 4; + uint8_t* data = nullptr; + size_t size = 0; + const error_t error = binfont_generate(&config, &data, &size); + if (error != ERROR_NONE || data == nullptr) { + logError("Label font %d px: generator failed with error %d, the map keeps the system font", EM_SIZES[i], static_cast(error)); + memory_free(data); + continue; + } + const uint32_t generated = nowMs(); + char path[200]; + cachePath(rootPath, i, path, sizeof(path)); + WriteJob write { folder, path, data, size }; + const bool ok = io->run(IoPriority::Background, writeJob, &write); + memory_free(data); + if (ok) { + written = written.load() | (1u << i); + RemoveOldJob old { rootPath, EM_SIZES[i] }; + io->run(IoPriority::Background, removeOldJob, &old); + } + logInfo("Label font %d px: %u bytes, generated in %u ms, %s in %u ms", EM_SIZES[i], static_cast(size), + static_cast(generated - fontStart), ok ? "written" : "NOT written", static_cast(nowMs() - generated)); + // Gives the render lanes and the UI their turn between two fonts + vTaskDelay(pdMS_TO_TICKS(20)); + } + logInfo("Label fonts made in %u ms in total", static_cast(nowMs() - start)); +} + +void LabelFontMaker::stop() { + if (task == nullptr) { + return; + } + stopRequested = true; + // A font being made cannot be interrupted, and the task still uses this object and the I/O task: wait for it + for (int waited = 0; !finished.load(); waited += 10) { + if (waited == STOP_WAIT_MS) { + logError("Label font task still busy after %d ms, waiting", STOP_WAIT_MS); + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + vTaskDeleteWithCaps(task); + task = nullptr; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/LabelFontMaker.h b/Apps/Maps/main/Source/app/LabelFontMaker.h new file mode 100644 index 0000000..725b2ae --- /dev/null +++ b/Apps/Maps/main/Source/app/LabelFontMaker.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IoTask.h" + +#include +#include + +#include +#include + +namespace maps { + +/** + * Makes the map's label fonts once, from the small TrueType file the app ships (assets/fonts/LabelSans.ttf: Adwaita Sans + * with the Latin, Greek and Cyrillic letters common on maps, which Tactility's own fonts lack), with the binfont-module's binfont_generate(). The fonts are + * cached on the card as /fonts/lbl.bin: raise FONT_VERSION when the sizes or the subset + * change, the sizes of the older version are deleted once the new ones are written. A short-lived low priority task does the work, so input and the first frame are never held up; the + * card is only touched through the I/O task. The map loop looks at isFinished() and opens the cached files. + */ +class LabelFontMaker { +public: + static constexpr int COUNT = 3; + /** 'c': the subset from tools/label_codepoints.txt (0.3.0). */ + static constexpr char FONT_VERSION = 'c'; + /** + * Em sizes with the letter sizes of Tactility's small, default and large fonts on the T-Deck. The lines are taller + * (17, 21, 26 px) because the subset's tallest accents (Vietnamese, Cyrillic) count in the line height. + */ + static constexpr int EM_SIZES[COUNT] = { 11, 14, 18 }; + + ~LabelFontMaker() { stop(); } + + /** The cache file of font i. */ + static void cachePath(const char* root, int i, char* out, size_t size); + /** Whether the cache folder, the ttf and the paths are set up; call before start(). */ + void configure(IoTask& ioTask, const char* root, const char* ttfPath); + /** Starts making the fonts in the mask (bit i for font i); false when the task could not be started. */ + bool start(unsigned missingMask); + /** True once the task is done, whether or not it succeeded; it never starts again. */ + bool isFinished() const { return finished.load(); } + bool isRunning() const { return task != nullptr && !finished.load(); } + /** Bit i is set when the cache file of font i was written. */ + unsigned writtenMask() const { return written.load(); } + /** Stops the task (between two fonts) and frees it; the app calls it before the I/O task stops. */ + void stop(); + +private: + static void taskMain(void* context); + void run(); + + IoTask* io = nullptr; + char rootPath[160] = ""; + char ttfPath[260] = ""; + unsigned wanted = 0; + TaskHandle_t task = nullptr; + std::atomic finished = false; + std::atomic stopRequested = false; + std::atomic written = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/LabelStore.cpp b/Apps/Maps/main/Source/app/LabelStore.cpp new file mode 100644 index 0000000..e55791e --- /dev/null +++ b/Apps/Maps/main/Source/app/LabelStore.cpp @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "LabelStore.h" + +#include "../platform/Platform.h" + +#include + +namespace maps { + +bool LabelStore::begin() { + mutex = xSemaphoreCreateMutex(); + return mutex != nullptr; +} + +void LabelStore::end() { + for (int i = 0; i < entryCount; i++) { + largeFree(entries[i].cells != nullptr ? static_cast(entries[i].cells) : entries[i].bytes); + } + entryCount = 0; + bytesUsed = 0; + if (mutex != nullptr) { + vSemaphoreDelete(mutex); + mutex = nullptr; + } +} + +int LabelStore::find(const TileKey& tile) const { + for (int i = 0; i < entryCount; i++) { + if (entries[i].tile == tile) { + return i; + } + } + return -1; +} + +void LabelStore::evict(int index) { + bytesUsed -= entries[index].size; + largeFree(entries[index].cells != nullptr ? static_cast(entries[index].cells) : entries[index].bytes); + entries[index] = entries[--entryCount]; +} + +void LabelStore::put(const TileKey& source, const uint8_t* section, size_t size, int count, bool numbers, int region) { + if (mutex == nullptr || size == 0) { + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + const int existing = find(source); + if (existing >= 0 && entries[existing].size == size && entries[existing].numbers == numbers) { + // The same tile drawn again, for another zoom level or after the raster cache dropped it + entries[existing].lastUse = ++useCounter; + xSemaphoreGive(mutex); + return; + } + xSemaphoreGive(mutex); + if (size > MAX_SECTION_BYTES) { + logError("Labels of %d/%d/%d not kept: %u bytes", source.z, source.x, source.y, static_cast(size)); + return; + } + auto* copy = static_cast(largeAlloc(size)); + if (copy == nullptr) { + return; + } + memcpy(copy, section, size); + xSemaphoreTake(mutex, portMAX_DELAY); + store(source, copy, nullptr, size, count, numbers, region); + xSemaphoreGive(mutex); +} + +void LabelStore::adopt(const TileKey& source, uint8_t* block, size_t size, int count, bool numbers, int region) { + if (mutex == nullptr || block == nullptr || size > MAX_SECTION_BYTES) { + if (size > MAX_SECTION_BYTES) { + logError("Labels of %d/%d/%d not kept: %u bytes", source.z, source.x, source.y, static_cast(size)); + } + largeFree(block); + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + store(source, block + sizeof(LabelCells), reinterpret_cast(block), size, count, numbers, region); + xSemaphoreGive(mutex); +} + +void LabelStore::store(const TileKey& source, uint8_t* copy, LabelCells* cells, size_t size, int count, bool numbers, int region) { + const int existing = find(source); + if (existing >= 0) { + evict(existing); + } + while (entryCount > 0 && (entryCount >= MAX_ENTRIES || bytesUsed + size > MAX_BYTES)) { + int oldest = 0; + for (int i = 1; i < entryCount; i++) { + if (entries[i].lastUse < entries[oldest].lastUse) { + oldest = i; + } + } + evict(oldest); + } + entries[entryCount++] = { source, copy, size, count, ++useCounter, numbers, region, cells }; + bytesUsed += size; + changes++; +} + +bool LabelStore::isReading(const TileKey& tile) const { + for (int i = 0; i < readingCount; i++) { + if (reading[i].tile == tile) { + return true; + } + } + return false; +} + +void LabelStore::queue(const TileKey& tile, int region) { + const bool numbers = numbersWanted.load(); + // A lane reads it already: asked again afterwards only if the house numbers were left out + for (int i = 0; i < readingCount; i++) { + if (reading[i].tile == tile && (reading[i].numbers || !numbers)) { + return; + } + } + for (int i = 0; i < requestCount; i++) { + if (requests[i].tile == tile) { + requests[i].numbers = requests[i].numbers || numbers; + return; + } + } + if (requestCount == MAX_REQUESTS) { + // The oldest request goes: its tile is probably off screen by now + for (int i = 1; i < requestCount; i++) { + requests[i - 1] = requests[i]; + } + requestCount--; + } + requests[requestCount++] = { tile, region, numbers }; +} + +void LabelStore::request(const TileKey& source, int region) { + if (mutex == nullptr) { + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + const int index = find(source); + if (index < 0 || (numbersWanted && !entries[index].numbers)) { + queue(source, region); + } else { + entries[index].lastUse = ++useCounter; + } + xSemaphoreGive(mutex); +} + +bool LabelStore::takeRequest(Request& out) { + if (mutex == nullptr) { + return false; + } + xSemaphoreTake(mutex, portMAX_DELAY); + bool found = false; + while (requestCount > 0 && !found && readingCount < MAX_READING) { + // The newest first: the tiles drawn last are the ones on screen now + out = requests[--requestCount]; + const int index = find(out.tile); + found = !isReading(out.tile) && (index < 0 || (out.numbers && !entries[index].numbers)); + } + if (found) { + reading[readingCount++] = out; + } + xSemaphoreGive(mutex); + return found; +} + +void LabelStore::done(const TileKey& source) { + if (mutex == nullptr) { + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + for (int i = 0; i < readingCount; i++) { + if (reading[i].tile == source) { + reading[i] = reading[--readingCount]; + break; + } + } + xSemaphoreGive(mutex); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/LabelStore.h b/Apps/Maps/main/Source/app/LabelStore.h new file mode 100644 index 0000000..dd24e55 --- /dev/null +++ b/Apps/Maps/main/Source/app/LabelStore.h @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../render/PreparedLabels.h" +#include "../tiles/TileMath.h" + +#include +#include + +#include +#include +#include + +namespace maps { + +/** + * The label sections of the source tiles drawn lately, read by the map loop to place labels. They come from the + * render lanes: copied out of an older prepared tile with labels inside as it is drawn (TileRenderer's label hook), + * or, for split tiles (version 5 on), read from the tile's .prl file on request: a lane that draws a split tile asks + * for its labels, a lane loads them (TileWorker::loadLabels). House numbers are read only while the view wants them. + * Entries live in PSRAM, at most MAX_BYTES together; the least recently used goes first. One z14 entry serves the + * z15-z19 tiles drawn from it. + */ +class LabelStore { +public: + static constexpr int MAX_ENTRIES = 48; + /** 512 KB: v4 sections of dense city tiles (POIs and house numbers) are over 100 KB each (device test, 10 Nov 2026). */ + static constexpr size_t MAX_BYTES = 512 * 1024; + /** The largest section kept: a third of the store, so a view's tiles fit together. */ + static constexpr size_t MAX_SECTION_BYTES = MAX_BYTES / 3; + + bool begin(); + void end(); + /** + * Called on a render lane: keeps a copy of a tile's label section, replacing an older one. numbers tells whether it + * has the tile's house numbers; region is the region the .prl file was read from (-1 for an older tile). + */ + void put(const TileKey& source, const uint8_t* section, size_t size, int count, bool numbers = true, int region = -1); + /** + * For a split tile's labels: takes a largeAlloc block it then owns (freed on failure too), laid out as LabelCells + * followed by size bytes of records. + */ + void adopt(const TileKey& source, uint8_t* block, size_t size, int count, bool numbers, int region); + + struct Request { + TileKey tile; + int region; + bool numbers; + }; + /** Asks for the labels of a split tile drawn from region: queued unless they are stored already. */ + void request(const TileKey& source, int region); + /** For the lanes: the next labels to read, false when none are wanted. The tile counts as being read until done(). */ + bool takeRequest(Request& out); + /** The lane finished reading a request (stored or failed). */ + void done(const TileKey& source); + /** Set by the map from its zoom: house numbers are read with the labels, and stored labels without them read again. */ + void setNumbersWanted(bool wanted) { numbersWanted = wanted; } + bool wantsNumbers() const { return numbersWanted; } + /** + * Calls visit with the section of a source tile while holding the store's lock; false when it is not stored. The + * section must not be used after visit returns. + */ + template + bool with(const TileKey& source, Visit&& visit) { + if (mutex == nullptr) { + return false; + } + xSemaphoreTake(mutex, portMAX_DELAY); + const int index = find(source); + if (index >= 0) { + entries[index].lastUse = ++useCounter; + visit(entries[index].bytes, entries[index].size, entries[index].count, entries[index].cells); + // Placed without house numbers for now, they are read in the background + if (numbersWanted && !entries[index].numbers && entries[index].region >= 0) { + queue(entries[index].tile, entries[index].region); + } + } + xSemaphoreGive(mutex); + return index >= 0; + } + /** Changes whenever a section was added, so the map knows when to place its labels again. */ + uint32_t generation() const { return changes.load(); } + +private: + struct Entry { + TileKey tile; + uint8_t* bytes; + size_t size; + int count; + uint32_t lastUse; + bool numbers; + int region; + /** nullptr for an older tile's section; else the start of the block, with the records after it. */ + LabelCells* cells; + }; + static constexpr int MAX_REQUESTS = 16; + static constexpr int MAX_READING = 4; + + int find(const TileKey& tile) const; + void evict(int index); + /** With the lock held. */ + void queue(const TileKey& tile, int region); + void store(const TileKey& source, uint8_t* records, LabelCells* cells, size_t size, int count, bool numbers, int region); + bool isReading(const TileKey& tile) const; + + Request requests[MAX_REQUESTS] {}; + int requestCount = 0; + Request reading[MAX_READING] {}; + int readingCount = 0; + std::atomic numbersWanted { false }; + + Entry entries[MAX_ENTRIES] {}; + int entryCount = 0; + size_t bytesUsed = 0; + uint32_t useCounter = 0; + std::atomic changes { 0 }; + SemaphoreHandle_t mutex = nullptr; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/LocationSource.cpp b/Apps/Maps/main/Source/app/LocationSource.cpp new file mode 100644 index 0000000..b9d6d9b --- /dev/null +++ b/Apps/Maps/main/Source/app/LocationSource.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "LocationSource.h" + +#include "../platform/Platform.h" + +namespace maps { + +namespace { + +/** location_subscribe() of newer firmware, which takes what the subscriber is. Older firmware ignores the argument. */ +using SubscribeWithDescription = error_t (*)(LocationSubscription* sub, TaskEventGroup* eventGroup, const char* description); + +} // namespace + +bool LocationSource::subscribe(TaskEventGroup* eventGroup) { + if (subscribed) { + return true; + } + storage = {}; + // Through a function type without arguments, which GCC accepts as a deliberate cast between function types + const auto subscribeCall = reinterpret_cast(reinterpret_cast(&location_subscribe)); + subscribed = subscribeCall(&storage.subscription, eventGroup, "Maps") == ERROR_NONE; + logInfo("Location services %s, %s", location_is_enabled() ? "on" : "off", subscribed ? "subscribed" : "not subscribed"); + return subscribed; +} + +void LocationSource::unsubscribe() { + if (subscribed) { + location_unsubscribe(&storage.subscription); + subscribed = false; + } +} + +bool LocationSource::isEnabled() { + return location_is_enabled(); +} + +bool LocationSource::setEnabled(bool enabled) { + // The subscription stays registered while the service is stopped, new fixes signal it again once it runs + const error_t error = location_set_enabled(enabled); + logInfo("Location services switched %s: %s", enabled ? "on" : "off", error == ERROR_NONE ? "done" : "failed"); + return error == ERROR_NONE; +} + +PositionRead LocationSource::read(PositionFix& fix) { + if (!location_is_enabled()) { + return PositionRead::Off; + } + Location location {}; + if (location_get(&location) != ERROR_NONE) { + return PositionRead::NoFix; + } + fix.latitude = location.latitude; + fix.longitude = location.longitude; + fix.accuracy = location.horizontal_accuracy; + fix.course = location.course; + fix.speed = location.speed; + fix.timeMs = location.time_ms; + return PositionRead::Fix; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/LocationSource.h b/Apps/Maps/main/Source/app/LocationSource.h new file mode 100644 index 0000000..23582f6 --- /dev/null +++ b/Apps/Maps/main/Source/app/LocationSource.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../platform/Position.h" + +#include + +namespace maps { + +/** + * The position from Tactility's location module, the app's only position source. Subscribing makes the + * location service run its providers (GPS, IP lookup) while the map is open, and signals a bit of the app's + * event group for each new fix. Location settings belong to the user: only the menu's Location switch turns the + * service on or off, and the precision is never changed. + */ +class LocationSource final : public PositionSource { +public: + ~LocationSource() override { unsubscribe(); } + + bool subscribe(TaskEventGroup* eventGroup); + void unsubscribe(); + /** The event group bit that is signalled for a new fix, 0 when not subscribed. */ + uint32_t bit() const { return subscribed ? storage.subscription.bit : 0; } + + PositionRead read(PositionFix& fix) override; + + static bool isEnabled(); + /** Starts or stops Tactility's location service and stores the choice in its settings. */ + static bool setEnabled(bool enabled); + +private: + /** + * Firmware from November 2026 on keeps a 25-byte description in the subscription's internal part, so its + * subscription is larger than the one of the SDK this app is built with. The service owns those internal fields + * and the app only reads bit (the first field in both), so room for either layout keeps both firmwares working. + */ + union Storage { + LocationSubscription subscription; + uint8_t bytes[64]; + }; + Storage storage {}; + bool subscribed = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/MapApp.cpp b/Apps/Maps/main/Source/app/MapApp.cpp new file mode 100644 index 0000000..31bcad0 --- /dev/null +++ b/Apps/Maps/main/Source/app/MapApp.cpp @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MapView.h" + +#include "../platform/Platform.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace maps { + +namespace { + +/** + * One line per run in sessions.txt: uptime in seconds, then free PSRAM and internal RAM in KB at the start and at the + * exit. Memory that another run or app kept shows as a low start value. The file keeps its last lines only. + */ +void logSession(const char* root, uint32_t startPsram, uint32_t startInternal) { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/sessions.txt", root); + char kept[1536] = ""; + size_t used = 0; + if (FILE* file = fopen(path, "r")) { + used = fread(kept, 1, sizeof(kept) - 1, file); + fclose(file); + } + kept[used] = '\0'; + // Old lines go once the file passes about 1 KB + const char* from = kept; + while (strlen(from) > 1024) { + const char* next = strchr(from, '\n'); + if (next == nullptr) { + break; + } + from = next + 1; + } + char line[96]; + snprintf(line, sizeof(line), "uptime %u s start %u/%u KB exit %u/%u KB (PSRAM/internal)\n", static_cast(nowMs() / 1000), + static_cast(startPsram / 1024), static_cast(startInternal / 1024), + static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024), + static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024)); + if (FILE* file = fopen(path, "w")) { + fputs(from, file); + fputs(line, file); + fclose(file); + } +} + + +void runFullscreen(Device* display, const char* root, const char* dataPath) { + const bool lvglActive = module_is_started(&lvgl_module); + if (lvglActive) { + const uint32_t start = nowMs(); + module_stop(&lvgl_module); + logInfo("LVGL stopped in %u ms", nowMs() - start); + } + + // Above the render lanes and the I/O task (4): the loop sleeps between frames, so it only takes the CPU to + // answer input and draw. At the same priority frames took 150-270 ms while tiles were loading. + const UBaseType_t appPriority = uxTaskPriorityGet(nullptr); + vTaskPrioritySet(nullptr, appPriority + 1); + + // The view is large, it lives in external RAM where the device has it + void* memory = largeAlloc(sizeof(MapView)); + if (memory != nullptr) { + auto* view = new (memory) MapView(); + if (view->begin(display, root, dataPath)) { + view->run(); + } + view->end(); + view->~MapView(); + largeFree(memory); + } else { + logError("Out of memory"); + } + vTaskPrioritySet(nullptr, appPriority); + + // LVGL comes back on every exit path, also after errors + if (lvglActive && !module_is_started(&lvgl_module)) { + const uint32_t start = nowMs(); + module_start(&lvgl_module); + logInfo("LVGL started in %u ms", nowMs() - start); + } +} + +bool pathExists(const char* path) { + struct stat info {}; + return stat(path, &info) == 0; +} + +/** + * Tactility moved app data from /user/app/ to /appdata/ (Tactility main 2ae9194c). The OS moves the + * whole user/app folder once, but only when appdata/ does not exist yet. If the Maps folder was left behind, move its + * entries here: same card, so a rename each. An entry whose name already exists at the destination stays where it is + * and is reported; nothing is ever deleted except the old folder itself once it is empty. + */ +void migrateLegacyData(const char* dataPath, const char* root) { + char legacy[PATH_MAX_LENGTH]; + if (snprintf(legacy, sizeof(legacy), "%s/user/app/%s", dataPath, APP_ID) >= static_cast(sizeof(legacy)) || + !pathExists(legacy) || strcmp(legacy, root) == 0) { + return; + } + if (!pathExists(root)) { + if (rename(legacy, root) == 0) { + logInfo("Data moved from %s to %s", legacy, root); + } else { + logError("Data not moved from %s to %s, it stays there", legacy, root); + } + return; + } + DIR* folder = opendir(legacy); + if (folder == nullptr) { + return; + } + int moved = 0; + int kept = 0; + char names[16][64]; + int nameCount = 0; + // Names first, then the renames: renaming while reading a FAT folder can skip entries + for (dirent* entry = readdir(folder); entry != nullptr; entry = readdir(folder)) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + if (nameCount < 16) { + snprintf(names[nameCount++], sizeof(names[0]), "%s", entry->d_name); + } else { + kept++; + } + } + closedir(folder); + for (int i = 0; i < nameCount; i++) { + char from[PATH_MAX_LENGTH]; + char to[PATH_MAX_LENGTH]; + snprintf(from, sizeof(from), "%s/%s", legacy, names[i]); + snprintf(to, sizeof(to), "%s/%s", root, names[i]); + if (pathExists(to)) { + logError("Not moved, %s exists already: %s", to, from); + kept++; + } else if (rename(from, to) == 0) { + logInfo("Moved %s to %s", from, to); + moved++; + } else { + logError("Could not move %s", from); + kept++; + } + } + if (kept == 0 && rmdir(legacy) == 0) { + logInfo("Removed the empty old folder %s", legacy); + } + logInfo("Old data folder: %d entries moved, %d left in %s", moved, kept, legacy); +} + +} // namespace + +} // namespace maps + +extern "C" int main(int argc, char* argv[]) { + (void)argc; + (void)argv; + maps::logMemory("start"); + char root[maps::PATH_MAX_LENGTH]; + if (app_paths_get_user_data_directory(maps::APP_ID, root, sizeof(root)) != ERROR_NONE) { + maps::logError("No user data directory"); + return 0; + } + char dataPath[maps::PATH_MAX_LENGTH] = ""; + paths_get_data_path(dataPath, sizeof(dataPath)); + maps::migrateLegacyData(dataPath, root); + mkdir(root, 0777); + char mapsFolder[maps::PATH_MAX_LENGTH]; + snprintf(mapsFolder, sizeof(mapsFolder), "%s/maps", root); + mkdir(mapsFolder, 0777); + + Device* display = nullptr; + if (device_get_first_active_by_type(&DISPLAY_TYPE, &display) != ERROR_NONE) { + maps::logError("No display"); + return 0; + } + const auto startPsram = static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); + const auto startInternal = static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); + maps::runFullscreen(display, root, dataPath); + device_put(display); + maps::logMemory("exit"); + maps::logSession(root, startPsram, startInternal); + return 0; +} diff --git a/Apps/Maps/main/Source/app/MapInput.cpp b/Apps/Maps/main/Source/app/MapInput.cpp new file mode 100644 index 0000000..73f6592 --- /dev/null +++ b/Apps/Maps/main/Source/app/MapInput.cpp @@ -0,0 +1,816 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MapView.h" + +#include "../platform/Platform.h" +#include "../render/Compose.h" + +#include +#include +#include + +namespace maps { + +namespace { + +constexpr int KEY_PAN_PIXELS = 48; +constexpr int TRACKBALL_PAN_PIXELS = 16; +constexpr uint32_t LONG_PRESS_MS = 600; +/** Pointer movement while its button is held that makes the press a drag of the map instead of a click. */ +constexpr int MOUSE_DRAG_SLOP = 6; + +} // namespace + +void MapView::markInput(uint32_t now) { + lastInputAt = now; + if (updatesAllowed) { + // Idle-time updates pause at once, the fetch running now ends and the rest waits for the next quiet spell + updatesAllowed = false; + downloader.setUpdatesAllowed(false); + } + // Prefetch and other idle work stop at once, so the card and the lanes serve what is on screen + if (idle) { + idle = false; + worker.cancelPrefetch(); + downloader.setIdle(false); + } +} + +void MapView::handleKey(uint32_t key) { + dirty = true; + if (prompt.isOpen()) { + if (prompt.handleKey(key) == UiAction::Chosen) { + confirmPrompt(); + } + return; + } + if (workCard.isOpen()) { + // Its only button is Close: Enter, Esc or Backspace + if (key == keys::ENTER || key == keys::ESCAPE || key == keys::BACKSPACE || key == keys::YES || key == keys::NO) { + workCard.close(); + } + return; + } + if (dialog.isOpen()) { + if (dialog.handleKey(key) == UiAction::Chosen) { + confirmDialog(); + } + return; + } + if (downloadCard.isOpen()) { + handleCardButton(downloadCard.handleKey(key)); + return; + } + if (sheet.isOpen() && !menuOpen && screen == Screen::Map) { + handleSheetButton(sheet.handleKey(key)); + return; + } + if (routeCard.isOpen() && !menuOpen && screen == Screen::Map) { + handleRouteCardButton(routeCard.handleKey(key)); + return; + } + if (pickingStart && !menuOpen && screen == Screen::Map) { + // Enter takes the crosshair, Esc or Backspace cancels; the other keys move the map as always + if (key == keys::ENTER || key == keys::YES) { + endPickStart(true, worldYToLat(centerY, zoom), worldXToLon(centerX, zoom)); + return; + } + if (key == keys::ESCAPE || key == keys::BACKSPACE || key == keys::NO) { + endPickStart(false, 0.0, 0.0); + return; + } + } + if (menuOpen) { + if (key == 'm' || key == 'M') { + closeMenu(); + return; + } + handleListAction(menu.handleKey(key)); + return; + } + switch (screen) { + case Screen::Map: + handleMapKey(key); + break; + case Screen::Download: + handleDownloadKey(key); + break; + case Screen::About: + if (key == 'w' || key == 'W' || key == keys::UP) { + scrollAbout(-1); + } else if (key == 's' || key == 'S' || key == keys::DOWN) { + scrollAbout(1); + } else if (key == keys::ESCAPE || key == keys::BACKSPACE || key == keys::ENTER || key == ' ') { + closeScreen(); + } + break; + default: + if (screen == Screen::Regions && (key == 'r' || key == 'R')) { + askRenameRegion(); + break; + } + if (screen == Screen::Waypoints && (key == 'r' || key == 'R')) { + askRenameWaypoint(listedWaypoint()); + break; + } + handleListAction(activeList()->handleKey(key)); + break; + } +} + +void MapView::pressControl(Control control) { + dirty = true; + switch (control) { + case Control::Menu: + openMenu(); + break; + case Control::ZoomIn: + zoomBy(1, width / 2, height / 2); + break; + case Control::ZoomOut: + zoomBy(-1, width / 2, height / 2); + break; + case Control::Position: + startFollowing(); + break; + default: + break; + } +} + +bool MapView::pressAt(int x, int y) { + if ((isRotated() || turn.active) && compassRect().contains(x, y)) { + headingUp = false; + setBearing(0.0f, true); + return true; + } + const Control control = controls.hit(x, y); + if (control != Control::None) { + pressControl(control); + return true; + } + // A pin opens its sheet + const int pin = waypointAt(x, y); + if (pin >= 0) { + openWaypointSheet(pin); + return true; + } + // A POI or house number opens its sheet; a tap on empty map still shows or hides the controls + const int place = placeAt(x, y); + if (place >= 0) { + placeOrderCount = 0; + openPlaceSheet(place); + return true; + } + switch (attribution.tap(x, y, attributionBar, attributionMark(), nowMs())) { + case Attribution::Tap::Expanded: + dirty = true; + return true; + case Attribution::Tap::OpenAbout: + openAbout(); + return true; + default: + return false; + } +} + +void MapView::handleMapKey(uint32_t key) { + // Boards with buttons only move a focus through the visible controls + if (controls.isVisible() && controls.usesFocus()) { + switch (key) { + case keys::UP: + case keys::LEFT: + case keys::FOCUS_PREVIOUS: + controls.moveFocus(-1); + return; + case keys::DOWN: + case keys::RIGHT: + case keys::FOCUS_NEXT: + case keys::TAB: + controls.moveFocus(1); + return; + case keys::ESCAPE: + case keys::BACKSPACE: + controls.hide(); + return; + default: + break; + } + } + switch (key) { + case ' ': + controls.toggle(); + cursorShown = false; + break; + case keys::ESCAPE: + case keys::BACKSPACE: + // While navigating the first press stops it, the next one leaves the app + if (navigating) { + stopNavigation(); + showToast("Navigation stopped. To go on: Menu, Show route"); + } else { + exitRequested = true; + } + break; + case 'p': + case 'P': + openNewWaypointAtPosition(); + break; + case 'w': + case 'W': + pan(0, -KEY_PAN_PIXELS); + break; + case 's': + case 'S': + pan(0, KEY_PAN_PIXELS); + break; + case 'a': + case 'A': + pan(-KEY_PAN_PIXELS, 0); + break; + case 'd': + pan(KEY_PAN_PIXELS, 0); + break; + case keys::UP: + pan(0, -TRACKBALL_PAN_PIXELS); + break; + case keys::DOWN: + pan(0, TRACKBALL_PAN_PIXELS); + break; + case keys::LEFT: + pan(-TRACKBALL_PAN_PIXELS, 0); + break; + case keys::RIGHT: + pan(TRACKBALL_PAN_PIXELS, 0); + break; + case 'i': + case 'I': + zoomBy(1, width / 2, height / 2); + break; + case 'o': + case 'O': + zoomBy(-1, width / 2, height / 2); + break; + case 'g': + case 'G': + startFollowing(); + break; + case 'q': + case 'Q': + // Turned by hand the map stops turning with the direction of travel + headingUp = false; + setBearing((turn.active ? turn.to : bearing) - KEY_TURN_DEGREES, true); + break; + case 'e': + case 'E': + headingUp = false; + setBearing((turn.active ? turn.to : bearing) + KEY_TURN_DEGREES, true); + break; + case 'n': + case 'N': + headingUp = false; + setBearing(0.0f, true); + break; + case 'm': + case 'M': + openMenu(); + break; + case 'D': + // Lower case d pans, so the download frame is Shift and D + screen = Screen::Download; + // The download frame works north up + following = false; + headingUp = false; + setBearing(0.0f, false); + controls.hide(); + cursorShown = false; + break; + default: + break; + } +} + +void MapView::pointerClick() { + dirty = true; + if (!cursorShown) { + // The pointer hid itself after a while: a click brings it back where it was + cursorShown = true; + cursorMovedAt = nowMs(); + controls.setHover(menuOpen ? Control::None : controls.hit(cursorX, cursorY)); + return; + } + cursorMovedAt = nowMs(); + if (prompt.isOpen() || dialog.isOpen() || menuOpen || sheet.isOpen() || downloadCard.isOpen() || screen != Screen::Map || + (pillRect.width > 0 && pillRect.contains(cursorX, cursorY))) { + handleTap(cursorX, cursorY); + return; + } + // On the map: a button presses, a pin opens its sheet, a click on empty map shows or hides the controls + if (!pressAt(cursorX, cursorY)) { + controls.toggle(); + } + controls.setHover(controls.hit(cursorX, cursorY)); +} + +void MapView::click(bool fromPointer) { + dirty = true; + if (fromPointer && input.hasPointer()) { + pointerClick(); + return; + } + if (prompt.isOpen()) { + if (prompt.handleKey(keys::ENTER) == UiAction::Chosen) { + confirmPrompt(); + } + } else if (dialog.isOpen()) { + if (dialog.press() == UiAction::Chosen) { + confirmDialog(); + } + } else if (sheet.isOpen() && sheet.mode() == WaypointSheet::Mode::Place && sheet.focused() == WaypointSheet::Button::Close && + placeOrderCount > 0 && !menuOpen && !downloadCard.isOpen()) { + // Enter again: the next nearest place + selectNextPlace(); + } else if (menuOpen || sheet.isOpen() || downloadCard.isOpen() || workCard.isOpen() || screen != Screen::Map || pickingStart) { + handleKey(keys::ENTER); + } else if (controls.isVisible() && controls.usesFocus()) { + pressControl(controls.focused()); + } else if (controls.isVisible() && selectNearestPlace()) { + // With the controls (and the crosshair) shown, Enter picks the place nearest the centre + } else { + controls.toggle(); + } +} + +void MapView::longClick() { + // The menu opens from the map, a long click elsewhere goes back + if (prompt.isOpen()) { + prompt.close(); + } else if (dialog.isOpen()) { + dialog.close(); + } else if (downloadCard.isOpen()) { + downloadCard.close(); + } else if (sheet.isOpen() && screen == Screen::Map) { + handleSheetButton(WaypointSheet::Button::Cancel); + } else if (menuOpen) { + closeMenu(); + } else if (screen == Screen::Map) { + openMenu(); + } else { + closeScreen(); + } + dirty = true; +} + +void MapView::handleEnter(bool pressed, uint32_t now) { + // The trackball repeats its press while held, the keyboard only sends one press + if (pressed) { + if (!enterHeld) { + enterHeld = true; + enterLong = false; + enterDownAt = now; + } + enterSeenAt = now; + return; + } + if (enterHeld) { + enterHeld = false; + if (!enterLong) { + click(false); + } + } +} + +void MapView::handleMouse(uint32_t now) { + int32_t rawX; + int32_t rawY; + const bool pressed = input.readMouse(rawX, rawY); + if (pressed || rawX != 0 || rawY != 0) { + markInput(now); + } + // A trackball in mouse mode is a pointer on every screen: it moves the pointer, a click is a tap where it points + int dx; + int dy; + pointerAcceleration.apply(rawX, rawY, now, dx, dy); + if (dx != 0 || dy != 0) { + int overflowX; + int overflowY; + const int fromX = cursorX; + const int fromY = cursorY; + moveCursor(dx, dy, now, overflowX, overflowY); + dirty = true; + ListView* list = menuOpen ? &menu : activeList(); + const bool onMap = !menuOpen && !dialog.isOpen() && !prompt.isOpen() && (screen == Screen::Map || screen == Screen::Download); + if (mouseHeld && onMap && !mouseLong) { + // Rolling with the button held drags the map under the pointer, which stays where it is + mouseDragDistance += std::abs(dx) + std::abs(dy); + if (mouseDragged || mouseDragDistance >= MOUSE_DRAG_SLOP) { + mouseDragged = true; + cursorX = fromX; + cursorY = fromY; + pan(-dx, -dy); + } + } else if (onMap && (overflowX != 0 || overflowY != 0)) { + // Pushing against a screen edge moves the map that way + pan(overflowX, overflowY); + } else if (list != nullptr && !dialog.isOpen() && !prompt.isOpen()) { + // The row under the pointer is the highlighted one; pushing against the top or bottom scrolls the list + const int row = list->rowAt(cursorX, cursorY); + if (row >= 0) { + list->select(row); + list->setHighlighted(true); + } + mouseRowAccumulator += overflowY; + while (std::abs(mouseRowAccumulator) >= list->rowHeight() / 2) { + const int step = mouseRowAccumulator > 0 ? 1 : -1; + mouseRowAccumulator -= step * list->rowHeight() / 2; + list->move(step); + } + } else if (screen == Screen::About && overflowY != 0) { + mouseRowAccumulator += overflowY; + while (std::abs(mouseRowAccumulator) >= normalFont.lineHeight() / 2) { + const int step = mouseRowAccumulator > 0 ? 1 : -1; + mouseRowAccumulator -= step * normalFont.lineHeight() / 2; + scrollAbout(step); + } + } + } + if (pressed && !mouseHeld) { + mouseHeld = true; + mouseLong = false; + mouseDragged = false; + mouseDragDistance = 0; + mouseDownAt = now; + } else if (!pressed && mouseHeld) { + mouseHeld = false; + // Holding the button does nothing by itself, the menu is on the button at the top left + if (!mouseDragged && now - mouseDownAt < LONG_PRESS_MS) { + click(true); + } + mouseDragged = false; + } +} + +void MapView::moveCursor(int dx, int dy, uint32_t now, int& overflowX, int& overflowY) { + if (!cursorShown) { + cursorShown = true; + if (cursorMovedAt == 0) { + cursorX = width / 2; + cursorY = height / 2; + } + } + const int wantedX = cursorX + dx; + const int wantedY = cursorY + dy; + cursorX = std::clamp(wantedX, 0, width - 1); + cursorY = std::clamp(wantedY, 0, height - 1); + overflowX = wantedX - cursorX; + overflowY = wantedY - cursorY; + cursorMovedAt = now; + controls.setHover(menuOpen || screen != Screen::Map ? Control::None : controls.hit(cursorX, cursorY)); +} + +void MapView::syntheticTouchState(uint32_t now, TouchState& state) { + // A debug script's touch: one or two fingers along a path over its duration, then lifted + const DebugScript::Step& step = syntheticTouch; + const uint32_t elapsed = now - syntheticTouchStart; + state = {}; + // A slow frame must not swallow a short touch: the fingers are always seen down, and at their end position, once + if (elapsed >= step.durationMs && syntheticTouchEnded) { + syntheticTouchActive = false; + return; + } + if (elapsed >= step.durationMs) { + syntheticTouchEnded = true; + } + const float t = step.durationMs > 0 ? std::min(1.0f, static_cast(elapsed) / static_cast(step.durationMs)) : 1.0f; + const float* v = step.values; + auto put = [&](int finger, float x, float y) { + state.x[finger] = static_cast(std::clamp(x, 0.0f, static_cast(width - 1))); + state.y[finger] = static_cast(std::clamp(y, 0.0f, static_cast(height - 1))); + }; + switch (step.touch) { + case DebugScript::TouchKind::Tap: + case DebugScript::TouchKind::Hold: + state.count = 1; + put(0, v[0], v[1]); + break; + case DebugScript::TouchKind::Drag: + state.count = 1; + put(0, v[0] + (v[2] - v[0]) * t, v[1] + (v[3] - v[1]) * t); + break; + case DebugScript::TouchKind::Pinch: { + const float half = (v[2] + (v[3] - v[2]) * t) / 2.0f; + state.count = 2; + put(0, v[0] - half, v[1]); + put(1, v[0] + half, v[1]); + break; + } + case DebugScript::TouchKind::Twist: { + const float radians = (v[3] + (v[4] - v[3]) * t) * 0.017453292f; + const float dx = v[2] * std::cos(radians); + const float dy = v[2] * std::sin(radians); + state.count = 2; + put(0, v[0] - dx, v[1] - dy); + put(1, v[0] + dx, v[1] + dy); + break; + } + } +} + +void MapView::handleTouch(uint32_t now) { + TouchState state; + if (syntheticTouchActive) { + syntheticTouchState(now, state); + } else { + input.readTouch(state); + } + if (state.count > 0) { + markInput(now); + } + const TouchSample sample { state.count, { state.x[0], state.x[1] }, { state.y[0], state.y[1] } }; + Gesture found[Gestures::MAX_GESTURES]; + const int count = gestures.update(sample, now, found); + for (int i = 0; i < count; i++) { + handleGesture(found[i]); + } +} + +void MapView::handleTap(int x, int y) { + // Taps on something answer at once, taps on the map wait to tell a single from a double tap + dirty = true; + // The on-screen keyboard types into the open text field as a keyboard would + syncTouchKeyboard(); + if (touchKeyboard.isOpen() && touchKeyboard.contains(x, y)) { + const uint32_t code = touchKeyboard.tap(x, y); + if (code != 0) { + handleKey(code); + } + gestures.consumeTap(); + return; + } + if (prompt.isOpen()) { + if (prompt.handleTap(x, y) == UiAction::Chosen) { + confirmPrompt(); + } + gestures.consumeTap(); + return; + } + if (dialog.isOpen()) { + if (dialog.handleTap(x, y) == UiAction::Chosen) { + confirmDialog(); + } + gestures.consumeTap(); + return; + } + if (menuOpen) { + handleListAction(menu.handleTap(x, y)); + gestures.consumeTap(); + return; + } + if (downloadCard.isOpen()) { + handleCardButton(downloadCard.handleTap(x, y)); + gestures.consumeTap(); + return; + } + if (workCard.isOpen()) { + // Close, or a tap anywhere else: the card only informs + workCard.close(); + gestures.consumeTap(); + return; + } + if (screen == Screen::Map && workDotShown && workDotTarget(WORK_DOT_X, WORK_DOT_Y).contains(x, y)) { + workCard.open(); + gestures.consumeTap(); + return; + } + if (screen == Screen::Map && !menuOpen && pillRect.width > 0 && pillRect.contains(x, y) && + !(routeCard.isOpen() && routeCard.modeBounds().contains(x, y))) { + openDownloadCard(); + gestures.consumeTap(); + return; + } + if (sheet.isOpen() && screen == Screen::Map) { + // A tap outside the sheet closes it and does nothing else + handleSheetButton(sheet.handleTap(x, y)); + gestures.consumeTap(); + return; + } + if (navigating && screen == Screen::Map && !menuOpen && y < navBar.height(sheetFont())) { + // The navigation bar: its X stops navigating, the rest of it does nothing (the controls are not under it) + if (navBar.stopHit(x, y)) { + stopNavigation(); + showToast("Navigation stopped. To go on: Menu, Show route"); + } + gestures.consumeTap(); + return; + } + if (pickingStart && screen == Screen::Map && !menuOpen) { + // The tapped point is the start + double worldX; + double worldY; + screenToWorldOffset(x - width / 2.0, y - height / 2.0, worldX, worldY); + endPickStart(true, worldYToLat(centerY + worldY, zoom), worldXToLon(centerX + worldX, zoom)); + gestures.consumeTap(); + return; + } + if (routeCard.isOpen() && screen == Screen::Map && !menuOpen) { + // The same for the route card: a tap on the map closes it (the route stays) + handleRouteCardButton(routeCard.handleTap(x, y)); + gestures.consumeTap(); + return; + } + if (screen == Screen::About) { + // The < in the header goes back, the page itself scrolls by dragging + if (aboutHeader.back.contains(x, y)) { + closeScreen(); + } + gestures.consumeTap(); + return; + } + if (ListView* list = activeList()) { + handleListAction(list->handleTap(x, y)); + gestures.consumeTap(); + return; + } + // Buttons and the attribution answer at once, the map itself only to a single tap + if (screen == Screen::Download) { + if (downloadView.button.contains(x, y)) { + startDownload(); + gestures.consumeTap(); + } else if (downloadView.back.contains(x, y)) { + closeScreen(); + gestures.consumeTap(); + } else if (const Attribution::Tap result = attribution.tap(x, y, attributionBar, attributionMark(), nowMs()); result != Attribution::Tap::Missed) { + if (result == Attribution::Tap::OpenAbout) { + openAbout(); + } + gestures.consumeTap(); + } else if (downloadView.bar.contains(x, y)) { + gestures.consumeTap(); + } + return; + } + if (screen == Screen::Map && pressAt(x, y)) { + gestures.consumeTap(); + } +} + +void MapView::handleLongPress(int x, int y) { + dirty = true; + // Holding a row opens its actions, like a tap + if (screen == Screen::Regions && !dialog.isOpen() && !menuOpen) { + const int row = regionList.rowAt(x, y); + if (row >= 0) { + regionList.select(row); + openRegionActions(); + } + return; + } + if (screen == Screen::Waypoints && !dialog.isOpen() && !menuOpen) { + const int row = waypointList.rowAt(x, y); + if (row >= 0) { + waypointList.select(row); + openWaypointActions(); + } + return; + } + if (screen == Screen::Map && !dialog.isOpen()) { + if (menuOpen) { + closeMenu(); + } else if (controls.hit(x, y) == Control::Position) { + // Holding the position button opens a waypoint sheet where I am + openNewWaypointAtPosition(); + } else if (controls.hit(x, y) == Control::None && !sheet.contains(x, y)) { + // Holding the map opens a waypoint sheet for that point; the menu is on the button + double offsetX; + double offsetY; + screenToWorldOffset(x - width / 2.0, y - height / 2.0, offsetX, offsetY); + openNewWaypoint(worldYToLat(centerY + offsetY, zoom), worldXToLon(centerX + offsetX, zoom)); + } else if (!sheet.isOpen()) { + openMenu(); + } + } +} + +void MapView::handleGesture(const Gesture& gesture) { + const bool onMap = !menuOpen && !dialog.isOpen() && !isFullScreen(); + switch (gesture.type) { + case GestureType::Tap: + handleTap(gesture.x, gesture.y); + break; + case GestureType::SingleTap: + // A tap on the map shows or hides the controls, dragging, pinching and double taps never do + if (onMap && screen == Screen::Map) { + controls.toggle(); + cursorShown = false; + dirty = true; + } + break; + case GestureType::DoubleTap: + if (onMap) { + zoomBy(1, gesture.x, gesture.y); + } + break; + case GestureType::LongPress: + handleLongPress(gesture.x, gesture.y); + break; + case GestureType::Drag: + if (onMap) { + pan(-gesture.dx, -gesture.dy); + } else if (!dialog.isOpen() && !prompt.isOpen() && std::abs(gesture.dy) >= std::abs(gesture.dx)) { + // Lists and the About page scroll with the finger, a row at a time; the highlight stays where it is + ListView* list = menuOpen ? &menu : activeList(); + const int rowHeight = list != nullptr ? list->rowHeight() : normalFont.lineHeight(); + dragRowAccumulator -= gesture.dy; + while (std::abs(dragRowAccumulator) >= rowHeight) { + const int step = dragRowAccumulator > 0 ? 1 : -1; + dragRowAccumulator -= step * rowHeight; + if (list != nullptr) { + list->scrollBy(step); + } else { + scrollAbout(step); + } + dirty = true; + } + } + break; + case GestureType::SwipeBack: + // One level back in lists, popups and About; on the map a swipe is only a drag + if (!onMap && !dialog.isOpen() && !prompt.isOpen()) { + dragRowAccumulator = 0; + goBack(); + } + break; + case GestureType::PinchStart: + if (!onMap) { + break; + } + pinchStepped = false; + if (smoothZoom()) { + // The cached tiles scaled live while the fingers move, without drawing vectors again + animation.active = false; + io.hold(false); + pinching = true; + // Card work waits while the fingers move, so frames do not wait for the shared bus + io.hold(true); + double offsetX; + double offsetY; + screenToWorldOffset(gesture.startX - width / 2.0, gesture.startY - height / 2.0, offsetX, offsetY); + pinchWorldX = centerX + offsetX; + pinchWorldY = centerY + offsetY; + if (turn.active) { + // A turn under way ends where it was going + turn.active = false; + applyBearing(turn.to, false); + } + pinchTurning = false; + pinchStartBearing = bearing; + headingUp = false; + pinchScale = 1.0f; + pinchStartX = gesture.startX; + pinchStartY = gesture.startY; + pinchX = gesture.x; + pinchY = gesture.y; + following = false; + dirty = true; + } + break; + case GestureType::PinchMove: + if (pinching) { + pinchScale = gesture.scale; + pinchX = gesture.x; + pinchY = gesture.y; + // Turning starts once the fingers twisted far enough, and from there follows them; fingers turning + // clockwise turn the map clockwise, so the bearing goes the other way + if (!pinchTurning && std::fabs(gesture.rotation) >= (pinchStartBearing == 0.0f ? PINCH_TURN_START_NORTH : PINCH_TURN_START)) { + pinchTurning = true; + pinchTwistStart = gesture.rotation; + } + if (pinchTurning) { + applyBearing(pinchStartBearing - (gesture.rotation - pinchTwistStart), false); + } + dirty = true; + } else if (!pinchStepped && onMap && std::fabs(gesture.rotation) >= 3.0f * PINCH_TURN_START) { + // Without live drawing (e-paper, or switched off) a clear twist turns the map once by 45 degrees + setBearing(bearing + (gesture.rotation > 0.0f ? -45.0f : 45.0f), false); + pinchStepped = true; + } else if (!pinchStepped && onMap) { + // Without live scaling (e-paper, or switched off) one zoom step per pinch + if (gesture.scale > 1.5f) { + setZoom(zoom + 1, gesture.x, gesture.y); + pinchStepped = true; + } else if (gesture.scale < 0.67f) { + setZoom(zoom - 1, gesture.x, gesture.y); + pinchStepped = true; + } + } + break; + case GestureType::PinchEnd: + if (pinching) { + endPinch(gesture.scale, gesture.startX, gesture.startY, gesture.x, gesture.y); + } + break; + default: + break; + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/MapNavigation.cpp b/Apps/Maps/main/Source/app/MapNavigation.cpp new file mode 100644 index 0000000..a8a4e7f --- /dev/null +++ b/Apps/Maps/main/Source/app/MapNavigation.cpp @@ -0,0 +1,547 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Navigation on the map (M5c-3, M5c-4): asking for a route, its preview, drawing it and following it. +#include "MapView.h" + +#include "../platform/Platform.h" + +#include +#include +#include +#include + +namespace maps { + +namespace { + +/** Route colours: a clear blue with a white casing; passed parts grey while following. */ +constexpr uint16_t ROUTE_COLOR = 0x1B3F; +constexpr uint16_t ROUTE_CASING = 0xFFFF; +constexpr uint16_t ROUTE_PASSED = 0x9CF3; +constexpr float ROUTE_WIDTH = 5.0f; +constexpr float ROUTE_CASING_WIDTH = 8.0f; +/** At least this long between two recalculations after leaving the route. */ +constexpr uint32_t RECALCULATE_MS = 20000; +constexpr uint32_t ARRIVED_BAR_MS = 8000; +constexpr char CAR_NOTE[] = "Car routes follow road types only, without turn restrictions or access rules."; + +double worldOfUnits(int32_t units, int zoom) { + // Graph units: 4096 per z14 tile; world pixels: 256 per tile at the view zoom + return std::ldexp(static_cast(units), zoom - 14 - 4); +} + +} // namespace + +// --------------------------------------------------------------------------------------------------------------- +// GPX replay: positions from a GPX file, one point a second, for testing navigation indoors + +bool ReplaySource::load(const char* text, size_t size) { + count = 0; + next = 0; + const char* cursor = text; + const char* end = text + size; + while (cursor < end && count < MAX_POINTS) { + const char* at = strstr(cursor, "= end) { + break; + } + const char* close = strchr(at, '>'); + if (close == nullptr) { + break; + } + const char* lat = strstr(at, "lat=\""); + const char* lon = strstr(at, "lon=\""); + if (lat != nullptr && lon != nullptr && lat < close && lon < close) { + points[count][0] = strtod(lat + 5, nullptr); + points[count][1] = strtod(lon + 5, nullptr); + count++; + } + cursor = close + 1; + } + return count > 1; +} + +PositionRead ReplaySource::read(PositionFix& fix) { + if (count == 0) { + return PositionRead::NoFix; + } + // The point for the time since the replay started, however often the position is read + const uint32_t now = nowMs(); + if (next == 0) { + next = 1; + lastStep = now; + } + const int index = std::min(count - 1, static_cast((now - lastStep) / STEP_MS)); + const int previous = std::max(0, index - 1); + fix = {}; + fix.latitude = points[index][0]; + fix.longitude = points[index][1]; + fix.accuracy = 5.0f; + // Course and speed from the step since the last point + const double dy = (points[index][0] - points[previous][0]) * 111320.0; + const double dx = (points[index][1] - points[previous][1]) * 111320.0 * std::cos(points[index][0] * 3.14159265358979 / 180.0); + fix.speed = static_cast(std::sqrt(dx * dx + dy * dy) * 1000.0 / STEP_MS); + fix.course = fix.speed > 0.1f ? static_cast(std::fmod(std::atan2(dx, dy) * 180.0 / 3.14159265358979 + 360.0, 360.0)) : NAN; + fix.timeMs = static_cast(now); + return PositionRead::Fix; +} + +void MapView::startReplay(const char* name) { + if (strcmp(name, "stop") == 0) { + replay.clear(); + replaying = false; + showToast("Replay stopped"); + return; + } + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/%s", rootPath, name); + FileBuffer file; + auto read = [&]() { return io.readFile(path, file); }; + if (!runOnCard(io, read) || !replay.load(reinterpret_cast(file.bytes()), file.size())) { + showToast("Replay: no track points in that file"); + return; + } + replaying = true; + logInfo("Replay: %d points from %s", replay.size(), path); +} + +// --------------------------------------------------------------------------------------------------------------- + +void MapView::navigateTo(double lat, double lon, RouteProfile profile, bool ask) { + goalLat = lat; + goalLon = lon; + routeProfile = profile; + sheet.close(); + if (position.hasFix() && position.state() == PositionState::Precise) { + requestRoute(position.fix().latitude, position.fix().longitude, true, nullptr); + } else if (!ask) { + requestRoute(worldYToLat(centerY, zoom), worldXToLon(centerX, zoom), false, "No position: the route starts at the screen centre"); + } else { + openRouteStartChoice(); + } +} + +void MapView::requestRoute(double fromLat, double fromLon, bool fromPosition, const char* toast) { + if (!navigator.request(fromLat, fromLon, goalLat, goalLon, routeProfile)) { + showToast("A route is being searched already"); + return; + } + startLat = fromLat; + startLon = fromLon; + startFromPosition = fromPosition; + recalculating = false; + routeRequested = true; + restartRoute = false; + routeCard.open("", nullptr); + routeCard.setMode(static_cast(routeProfile)); + routeCard.setBusy(true); + routeCard.layout(width, height, sheetFont()); + if (toast != nullptr) { + showToast(toast); + } + dirty = true; +} + +namespace { + +constexpr int START_ON_MAP = 0; +constexpr int START_CENTRE = 1; +constexpr int START_POSITION = 2; + +} // namespace + +void MapView::openRouteStartChoice() { + // No GPS fix: where does the route start + beginPopup(Popup::RouteStart, "Route from"); + menu.add("Choose on the map", input.hasTouch() ? "tap it" : "crosshair"); + popupActions[popupActionCount++] = START_ON_MAP; + menu.add("Screen centre"); + popupActions[popupActionCount++] = START_CENTRE; + if (position.hasFix()) { + // An approximate position (Wi-Fi or IP based): offered with how far off it may be + char accuracy[24]; + formatDistance(position.fix().accuracy, accuracy, sizeof(accuracy)); + char detail[32]; + snprintf(detail, sizeof(detail), "\xC2\xB1%s", accuracy); + menu.add("My position", detail); + popupActions[popupActionCount++] = START_POSITION; + } + layoutPopup(); +} + +void MapView::chooseRouteStart(int choice) { + switch (choice) { + case START_ON_MAP: + beginPickStart(); + break; + case START_CENTRE: + requestRoute(worldYToLat(centerY, zoom), worldXToLon(centerX, zoom), false, nullptr); + break; + case START_POSITION: + if (position.hasFix()) { + requestRoute(position.fix().latitude, position.fix().longitude, true, "Route from your approximate position"); + } + break; + default: + break; + } +} + +void MapView::beginPickStart() { + pickingStart = true; + routeCard.close(); + following = false; + controls.hide(); + // Short: the bar has room for about 35 characters a line on the T-Deck + pickBar.set(Turn::Arrive, "Where does the route start?", + input.hasTouch() && input.hasKeyboard() ? "Tap it, or Enter for the +" : + input.hasTouch() ? "Tap it on the map" : + "Move the map, then Enter"); + dirty = true; +} + +void MapView::endPickStart(bool chosen, double lat, double lon) { + pickingStart = false; + dirty = true; + if (chosen) { + requestRoute(lat, lon, false, nullptr); + } +} + +void MapView::drawPickStart() { + // The crosshair the start is taken from, and the bar saying what to do + const auto x = static_cast(width / 2); + const auto y = static_cast(height / 2); + canvas.line(x - 9.0f, y, x + 9.0f, y, 4.0f, theme.panel); + canvas.line(x, y - 9.0f, x, y + 9.0f, 4.0f, theme.panel); + canvas.line(x - 8.0f, y, x + 8.0f, y, 2.0f, theme.text); + canvas.line(x, y - 8.0f, x, y + 8.0f, 2.0f, theme.text); + pickBar.draw(canvas, sheetFont(), theme, width); +} + +void MapView::fillRouteCard() { + navigator.lock(); + const Route& route = navigator.route(); + char distance[16]; + char time[24]; + formatDistance(route.lengthM, distance, sizeof(distance)); + formatDuration(route.seconds, time, sizeof(time)); + const RouteProfile profile = route.profile; + navigator.unlock(); + char summary[64]; + snprintf(summary, sizeof(summary), "%s \xC2\xB7 %s \xC2\xB7 %s", distance, time, routeProfileName(profile)); + routeCard.open(summary, profile == RouteProfile::Car ? CAR_NOTE : nullptr); + routeCard.setMode(static_cast(profile)); + routeCard.layout(width, height, sheetFont()); +} + +void MapView::chooseRouteMode(RouteProfile next) { + routeProfile = next; + routeCard.setMode(static_cast(next)); + dirty = true; + if (navigator.state() == Navigator::State::Searching) { + // The running search stops; the new one starts when it has (updateNavigation) + navigator.cancel(); + restartRoute = true; + routeCard.setBusy(true); + return; + } + searchRouteAgain(); +} + +void MapView::searchRouteAgain() { + // The same start again: the position when it was the position, else the point chosen + if (startFromPosition && position.hasFix()) { + requestRoute(position.fix().latitude, position.fix().longitude, true, nullptr); + } else { + requestRoute(startLat, startLon, false, nullptr); + } +} + +void MapView::fitRoute() { + navigator.lock(); + const Route& route = navigator.route(); + if (route.isEmpty()) { + navigator.unlock(); + return; + } + int32_t minX = INT32_MAX; + int32_t minY = INT32_MAX; + int32_t maxX = INT32_MIN; + int32_t maxY = INT32_MIN; + for (const RoutePoint& point : route.points) { + minX = std::min(minX, point.x); + minY = std::min(minY, point.y); + maxX = std::max(maxX, point.x); + maxY = std::max(maxY, point.y); + } + navigator.unlock(); + // The largest zoom at which the route's box fits between the mode bar and the card, north up + const int cardTop = routeCard.isOpen() ? routeCard.bounds().y : height; + const int barBottom = routeCard.isOpen() ? routeCard.modeBounds().y + routeCard.modeBounds().height : 0; + const double availableWidth = std::max(40, width - 32); + const double availableHeight = std::max(40, cardTop - barBottom - 24); + int fit = 3; + for (int z = 18; z >= 3; z--) { + if (worldOfUnits(maxX - minX, z) <= availableWidth && worldOfUnits(maxY - minY, z) <= availableHeight) { + fit = z; + break; + } + } + following = false; + headingUp = false; + setBearing(0.0f, false); + double lat; + double lon; + routePointTo({ (minX + maxX) / 2, (minY + maxY) / 2 }, lat, lon); + centerOn(lat, lon, fit); + // Moved so the route is in the middle of the space between the bar and the card + centerY += (height - cardTop - barBottom) / 2.0; + updateWanted(); + dirty = true; +} + +void MapView::handleRouteCardButton(RouteCard::Button button) { + dirty = true; + switch (button) { + case RouteCard::Button::Start: + startNavigation(); + break; + case RouteCard::Button::Profile: + // Walk, bike, car in turn: the same destination again (the default stays as Settings has it) + chooseRouteMode(static_cast((static_cast(routeProfile) + 1) % static_cast(RouteProfile::Count))); + break; + case RouteCard::Button::Walk: + case RouteCard::Button::Bike: + case RouteCard::Button::Car: { + const auto next = static_cast(static_cast(button) - static_cast(RouteCard::Button::Walk)); + // The mode shown already: searched again only when it has no route (failed, or the first search was cancelled) + if (next != routeProfile || (!routeCard.isBusy() && !routeCard.canStartRoute())) { + chooseRouteMode(next); + } + break; + } + case RouteCard::Button::Close: + routeCard.close(); + break; + default: + break; + } +} + +void MapView::startNavigation() { + routeCard.close(); + navigator.lock(); + follower.begin(navigator.route()); + navigator.unlock(); + navigating = true; + arrivedAt = 0; + lastRecalculation = 0; + navBar.showStop(true); + layoutControls(); + char instruction[NavigationBar::INSTRUCTION_LENGTH]; + char remaining[NavigationBar::REMAINING_LENGTH]; + navigator.lock(); + follower.instruction(navigator.route(), instruction, sizeof(instruction)); + navigator.unlock(); + follower.remaining(remaining, sizeof(remaining)); + navBar.set(follower.status().nextTurn, instruction, remaining); + if (!position.hasFix()) { + navBar.setMessage("Waiting for a GPS position"); + } + // Heading up while moving, like the second press of the position button + if (position.hasFix()) { + following = true; + headingUp = true; + zoom = std::max(zoom, 16); + centerOnPosition(); + } + dirty = true; +} + +void MapView::stopNavigation() { + navigating = false; + follower.end(); + headingUp = false; + navBar.showStop(false); + layoutControls(); + // The position back in the middle + if (following && position.hasFix()) { + centerOnPosition(); + } + dirty = true; +} + +void MapView::clearRoute() { + stopNavigation(); + routeCard.close(); + navigator.clearRoute(); + showToast("Route cleared"); +} + +void MapView::updateNavigation(uint32_t now) { + const Navigator::State state = navigator.state(); + if (state == Navigator::State::Done || state == Navigator::State::Failed) { + const RouteResult result = navigator.takeResult(); + const bool asked = routeRequested; + routeRequested = false; + if (restartRoute) { + // Another mode was chosen while it searched + restartRoute = false; + searchRouteAgain(); + } else if (!asked) { + // A bench run from a debug script: nothing to show + } else if (result == RouteResult::Found) { + if (recalculating && navigating) { + navigator.lock(); + follower.begin(navigator.route()); + navigator.unlock(); + navBar.setMessage("New route"); + } else { + fillRouteCard(); + fitRoute(); + } + } else { + if (recalculating && navigating) { + navBar.setMessage(routeResultText(result)); + } else { + // The card stays with the reason, so another mode can be tried + routeCard.setBusy(false); + routeCard.setCanStart(false); + routeCard.setSummary(routeResultText(result)); + routeCard.setNote(nullptr); + routeCard.layout(width, height, sheetFont()); + if (!routeCard.isOpen()) { + showToast(routeResultText(result)); + } + } + } + recalculating = false; + dirty = true; + } + // Also without a new position: standing still off the route still counts its 10 s + if (navigating && now - lastFollowAt >= 1000) { + followRoute(now); + } + if (navigating && arrivedAt != 0 && now - arrivedAt >= ARRIVED_BAR_MS) { + // Arrived: following stops, the line stays until it is cleared + stopNavigation(); + arrivedAt = 0; + } +} + +void MapView::followRoute(uint32_t now) { + lastFollowAt = now; + if (!navigating || !position.hasFix() || arrivedAt != 0) { + return; + } + const PositionFix& fix = position.fix(); + navigator.lock(); + const Route& route = navigator.route(); + if (route.isEmpty()) { + navigator.unlock(); + stopNavigation(); + return; + } + const RouteFollower::Status status = follower.update(route, fix.latitude, fix.longitude, fix.accuracy, now); + char instruction[NavigationBar::INSTRUCTION_LENGTH]; + follower.instruction(route, instruction, sizeof(instruction)); + navigator.unlock(); + char remaining[NavigationBar::REMAINING_LENGTH]; + follower.remaining(remaining, sizeof(remaining)); + switch (status.state) { + case RouteFollower::State::Arrived: + navBar.set(Turn::Arrive, "You have arrived", ""); + following = false; + headingUp = false; + arrivedAt = now; + logInfo("Navigation: arrived"); + break; + case RouteFollower::State::OffRoute: + if (!recalculating && (lastRecalculation == 0 || now - lastRecalculation >= RECALCULATE_MS) && + navigator.state() == Navigator::State::Idle) { + lastRecalculation = now; + recalculating = navigator.request(fix.latitude, fix.longitude, goalLat, goalLon, routeProfile); + routeRequested = recalculating; + logInfo("Navigation: off route by %.0f m, recalculating", status.distanceFromRouteM); + } + navBar.setMessage("Off route \xE2\x80\x94 recalculating"); + break; + default: + if (!recalculating) { + navBar.set(status.nextTurn, instruction, remaining); + } + break; + } + // Redrawn when the bar or the state changed, not every second (e-paper) + const uint32_t signature = (static_cast(status.state) << 24) ^ static_cast(status.toTurnM) ^ + (static_cast(status.remainingM) << 8) ^ static_cast(status.progressM * 10.0f); + if (signature != lastFollowSignature) { + lastFollowSignature = signature; + dirty = true; + } +} + +void MapView::drawRoute() { + if (!navigator.hasRoute()) { + return; + } + navigator.lock(); + const Route& route = navigator.route(); + if (route.isEmpty()) { + navigator.unlock(); + return; + } + const float passed = navigating ? follower.status().progressM : -1.0f; + const bool binary = palette->binary; + // A dark, thick line on monochrome displays; colour with a casing otherwise + const uint16_t casing = monochrome ? palette->halo : ROUTE_CASING; + const uint16_t fill = monochrome ? palette->text : ROUTE_COLOR; + const uint16_t done = monochrome ? palette->halo : ROUTE_PASSED; + const float fillWidth = binary ? 6.0f : ROUTE_WIDTH; + const float casingWidth = binary ? 9.0f : ROUTE_CASING_WIDTH; + auto project = [&](const RoutePoint& point, float& x, float& y) { + double sx; + double sy; + worldToScreenOffset(worldOfUnits(point.x, zoom) - centerX, worldOfUnits(point.y, zoom) - centerY, sx, sy); + x = static_cast(sx + width / 2.0); + y = static_cast(sy + height / 2.0); + }; + // Two passes: the casing under the whole line, then the line, passed parts grey + for (int pass = 0; pass < 2; pass++) { + float lastX; + float lastY; + project(route.points[0], lastX, lastY); + for (size_t i = 1; i < route.points.size(); i++) { + float x; + float y; + project(route.points[i], x, y); + const float dx = x - lastX; + const float dy = y - lastY; + if (dx * dx + dy * dy < 4.0f && i + 1 < route.points.size()) { + continue; + } + const bool visible = !((lastX < -10 && x < -10) || (lastY < -10 && y < -10) || (lastX > width + 10 && x > width + 10) || + (lastY > height + 10 && y > height + 10)); + if (visible) { + if (pass == 0) { + canvas.line(lastX, lastY, x, y, casingWidth, casing); + } else { + const bool isPassed = passed >= 0.0f && route.distances[i] <= passed; + canvas.line(lastX, lastY, x, y, isPassed && !monochrome ? fillWidth - 1.0f : fillWidth, isPassed ? done : fill); + } + } + lastX = x; + lastY = y; + } + } + // The destination: a ring at the end + float goalX; + float goalY; + project(route.points[route.points.size() - 1], goalX, goalY); + navigator.unlock(); + canvas.fillCircle(goalX, goalY, 6.0f, casing); + canvas.fillCircle(goalX, goalY, 4.0f, fill); + canvas.fillCircle(goalX, goalY, 1.5f, casing); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/MapPlaces.cpp b/Apps/Maps/main/Source/app/MapPlaces.cpp new file mode 100644 index 0000000..5954d9c --- /dev/null +++ b/Apps/Maps/main/Source/app/MapPlaces.cpp @@ -0,0 +1,871 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Waypoints, track recording and GPX files on the map. +#include "MapView.h" + +#include "../gpx/Track.h" +#include "../render/PoiTypes.h" +#include "../ui/Pins.h" +#include "../platform/Platform.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace maps { + +namespace { + +constexpr int PIN_HIT = 14; +/** A tap this close to a POI or house number picks it; Enter reaches places this far from the crosshair. */ +constexpr int PLACE_HIT = 14; +constexpr int PLACE_REACH = 70; +constexpr uint16_t TRACK_COLOR = 0xD8A4; +constexpr uint16_t GPX_TRACK_COLOR = 0x3A7F; +constexpr uint16_t GPX_ROUTE_COLOR = 0xA81F; +constexpr uint16_t PIN_COLOR = 0xFB20; +/** Points of a line closer than this on screen are skipped when drawing. */ +constexpr float MIN_STEP_PIXELS = 2.0f; + +const char* compass(double bearing) { + static const char* const NAMES[] = { "N", "NE", "E", "SE", "S", "SW", "W", "NW" }; + return NAMES[static_cast(std::floor((bearing + 22.5) / 45.0)) % 8]; +} + +void formatCoordinates(double lat, double lon, char* out, size_t size) { + snprintf(out, size, "%.5f %c %.5f %c", std::fabs(lat), lat >= 0.0 ? 'N' : 'S', std::fabs(lon), lon >= 0.0 ? 'E' : 'W'); +} + +void formatDistance(double metres, char* out, size_t size) { + if (metres < 1000.0) { + snprintf(out, size, "%d m", static_cast(metres + 0.5)); + } else if (metres < 10000.0) { + snprintf(out, size, "%.1f km", metres / 1000.0); + } else { + snprintf(out, size, "%d km", static_cast(metres / 1000.0 + 0.5)); + } +} + +} // namespace + +bool MapView::worldToScreen(double lat, double lon, float& x, float& y) const { + const double worldSize = std::ldexp(static_cast(TILE_SIZE), zoom); + double dx = lonToWorldX(lon, zoom) - centerX; + // The shorter way round the antimeridian + if (dx > worldSize / 2.0) { + dx -= worldSize; + } else if (dx < -worldSize / 2.0) { + dx += worldSize; + } + double sx; + double sy; + worldToScreenOffset(dx, latToWorldY(lat, zoom) - centerY, sx, sy); + x = static_cast(sx + width / 2.0); + y = static_cast(sy + height / 2.0); + return x > -20.0f && y > -20.0f && x < width + 20.0f && y < height + 20.0f; +} + +void MapView::describeDistance(double lat, double lon, char* out, size_t size) const { + const bool fromHere = position.hasFix(); + const double fromLat = fromHere ? position.fix().latitude : worldYToLat(centerY, zoom); + const double fromLon = fromHere ? position.fix().longitude : worldXToLon(centerX, zoom); + char distance[24]; + formatDistance(distanceMetres(fromLat, fromLon, lat, lon), distance, sizeof(distance)); + snprintf(out, size, "%s %s%s", distance, compass(bearingDegrees(fromLat, fromLon, lat, lon)), fromHere ? "" : " of the centre"); +} + +// --------------------------------------------------------------------------------------------------------------- +// Waypoints + +void MapView::loadWaypoints() { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/waypoints.gpx", rootPath); + FileBuffer file; + auto read = [&] { return file.load(path); }; + if (runOnCard(io, read, IoPriority::Background) && + !waypoints.load(reinterpret_cast(file.bytes()), file.size())) { + logError("waypoints.gpx is damaged, %d waypoints read", waypoints.count()); + } +} + +void MapView::saveWaypoints() { + ByteWriter text; + if (!waypoints.save(text, APP_VERSION)) { + showToast("Could not save the waypoints"); + return; + } + char path[PATH_MAX_LENGTH]; + char temporary[PATH_MAX_LENGTH + 8]; + snprintf(path, sizeof(path), "%s/waypoints.gpx", rootPath); + snprintf(temporary, sizeof(temporary), "%s.tmp", path); + auto write = [&] { return io.writeFile(path, temporary, text.bytes(), text.size(), false); }; + if (!runOnCard(io, write, IoPriority::Background)) { + showToast("Could not save the waypoints"); + } +} + +void MapView::openNewWaypoint(double lat, double lon, const char* givenName) { + if (waypoints.count() >= Waypoints::MAX_WAYPOINTS) { + showToast("200 waypoints is the most, delete some first"); + return; + } + sheetLat = lat; + sheetLon = lon; + selectedWaypoint = -1; + char name[WaypointSheet::NAME_LENGTH]; + if (givenName != nullptr && givenName[0] != '\0') { + snprintf(name, sizeof(name), "%s", givenName); + } else { + waypoints.nextName(name, sizeof(name)); + } + char coordinates[48]; + formatCoordinates(lat, lon, coordinates, sizeof(coordinates)); + char distance[64]; + describeDistance(lat, lon, distance, sizeof(distance)); + sheet.openNew(name, coordinates, distance, canType()); + sheet.layout(width, height, sheetFont()); + controls.hide(); + dirty = true; +} + +void MapView::openNewWaypointAtPosition() { + if (!position.hasFix() || position.state() == PositionState::Approximate) { + showToast(position.hasFix() ? "The position is only approximate" : position.unavailableMessage()); + return; + } + openNewWaypoint(position.fix().latitude, position.fix().longitude); +} + +void MapView::openWaypointSheet(int index) { + if (index < 0 || index >= waypoints.count()) { + return; + } + const Waypoint& waypoint = waypoints.at(index); + selectedWaypoint = index; + sheetLat = waypoint.lat; + sheetLon = waypoint.lon; + char coordinates[48]; + formatCoordinates(waypoint.lat, waypoint.lon, coordinates, sizeof(coordinates)); + char distance[64]; + describeDistance(waypoint.lat, waypoint.lon, distance, sizeof(distance)); + sheet.openExisting(waypoint.name, coordinates, distance); + sheet.layout(width, height, sheetFont()); + dirty = true; +} + +void MapView::saveNewWaypoint() { + const int index = waypoints.add(sheetLat, sheetLon, static_cast(time(nullptr)), NAN, sheet.name()); + if (index < 0) { + showToast("200 waypoints is the most, delete some first"); + return; + } + saveWaypoints(); + char text[64]; + snprintf(text, sizeof(text), "%s saved", waypoints.at(index).name); + showToast(text); +} + +void MapView::handleSheetButton(WaypointSheet::Button button) { + dirty = true; + switch (button) { + case WaypointSheet::Button::Save: + sheet.close(); + saveNewWaypoint(); + break; + case WaypointSheet::Button::Cancel: + case WaypointSheet::Button::Close: + sheet.close(); + selectedWaypoint = -1; + placeOrderCount = 0; + break; + case WaypointSheet::Button::SaveAsWaypoint: { + // A new waypoint at the place, its name filled in (editable on boards with a keyboard) + char name[WaypointSheet::NAME_LENGTH]; + snprintf(name, sizeof(name), "%s", sheet.name()); + placeOrderCount = 0; + openNewWaypoint(sheetLat, sheetLon, name); + break; + } + case WaypointSheet::Button::Rename: + if (!canType()) { + showToast("Renaming needs a keyboard"); + break; + } + askRenameWaypoint(selectedWaypoint); + break; + case WaypointSheet::Button::Delete: + askDeleteWaypoint(selectedWaypoint); + break; + case WaypointSheet::Button::Navigate: + // "Navigate here", with the profile chosen last + placeOrderCount = 0; + selectedWaypoint = -1; + navigateTo(sheetLat, sheetLon, static_cast(std::clamp(settings.routeProfile, 0, 2))); + break; + default: + break; + } +} + +bool MapView::labelOffset(int& x, int& y) const { + if (!labelsReady || labelZoom != zoom || labelBearing != bearing || labels.count() == 0) { + return false; + } + double moveX; + double moveY; + worldToScreenOffset(labelCenterX - centerX, labelCenterY - centerY, moveX, moveY); + x = static_cast(std::floor(moveX + 0.5)); + y = static_cast(std::floor(moveY + 0.5)); + return true; +} + +int MapView::placeAt(int x, int y) const { + int offsetX; + int offsetY; + if (!labelOffset(offsetX, offsetY)) { + return -1; + } + return labels.pick(x, y, PLACE_HIT, offsetX, offsetY); +} + +void MapView::openPlaceSheet(int index) { + PlacedItem item {}; + int offsetX; + int offsetY; + if (!labelOffset(offsetX, offsetY) || !labels.item(index, item)) { + return; + } + // The labelled point back into the world + double worldX; + double worldY; + screenToWorldOffset(item.x + offsetX - width / 2.0, item.y + offsetY - height / 2.0, worldX, worldY); + sheetLon = worldXToLon(centerX + worldX, zoom); + sheetLat = worldYToLat(centerY + worldY, zoom); + selectedWaypoint = -1; + const char* type = item.kind == LabelKind::HouseNumber ? "House number" : poiType(item.poiType).label; + char name[WaypointSheet::NAME_LENGTH]; + if (item.kind == LabelKind::HouseNumber) { + snprintf(name, sizeof(name), "No. %s", item.name); + } else { + // A POI without a name is called by its type + snprintf(name, sizeof(name), "%s", item.name[0] != '\0' ? item.name : type); + } + char coordinates[48]; + formatCoordinates(sheetLat, sheetLon, coordinates, sizeof(coordinates)); + char distance[64]; + describeDistance(sheetLat, sheetLon, distance, sizeof(distance)); + sheet.openPlace(name, type, coordinates, distance); + sheet.layout(width, height, sheetFont()); + dirty = true; +} + +bool MapView::selectNearestPlace() { + int offsetX; + int offsetY; + placeOrderCount = 0; + if (!labelOffset(offsetX, offsetY)) { + return false; + } + int order[MAX_PLACE_ORDER]; + const int count = labels.byDistance(width / 2, height / 2, order, MAX_PLACE_ORDER, offsetX, offsetY); + // Only places near the crosshair: farther ones are better reached by panning + for (int i = 0; i < count; i++) { + PlacedItem item {}; + labels.item(order[i], item); + const int dx = item.x + offsetX - width / 2; + const int dy = item.y + offsetY - height / 2; + if (dx * dx + dy * dy <= PLACE_REACH * PLACE_REACH) { + placeOrder[placeOrderCount++] = order[i]; + } + } + if (placeOrderCount == 0) { + return false; + } + placeOrderIndex = 0; + openPlaceSheet(placeOrder[0]); + return true; +} + +void MapView::selectNextPlace() { + if (placeOrderCount == 0) { + sheet.close(); + dirty = true; + return; + } + placeOrderIndex = (placeOrderIndex + 1) % placeOrderCount; + openPlaceSheet(placeOrder[placeOrderIndex]); +} + +int MapView::waypointAt(int x, int y) const { + int best = -1; + float bestDistance = static_cast(PIN_HIT * PIN_HIT); + for (int i = 0; i < waypoints.count(); i++) { + float px; + float py; + if (!worldToScreen(waypoints.at(i).lat, waypoints.at(i).lon, px, py)) { + continue; + } + // The pin's head is above its point + const float dx = px - static_cast(x); + const float dy = py - PIN_HEAD_OFFSET - static_cast(y); + if (dx * dx + dy * dy < bestDistance) { + bestDistance = dx * dx + dy * dy; + best = i; + } + } + return best; +} + +void MapView::openWaypoints() { + if (waypoints.count() == 0) { + showToast(input.hasTouch() ? "No waypoints yet: hold the map, or Menu, Add waypoint" : "No waypoints yet: P, or Menu, Add waypoint"); + return; + } + fillWaypointList(); + waypointList.select(0); + waypointList.setHighlighted(false); + screen = Screen::Waypoints; + dirty = true; +} + +void MapView::fillWaypointList() { + waypointList.clear(); + waypointList.setTitle("Waypoints"); + const double lat = position.hasFix() ? position.fix().latitude : worldYToLat(centerY, zoom); + const double lon = position.hasFix() ? position.fix().longitude : worldXToLon(centerX, zoom); + const int count = waypoints.sortedByDistance(lat, lon, waypointRows, ListView::MAX_ITEMS); + for (int row = 0; row < count; row++) { + const Waypoint& waypoint = waypoints.at(waypointRows[row]); + char detail[ListView::TEXT_LENGTH]; + describeDistance(waypoint.lat, waypoint.lon, detail, sizeof(detail)); + waypointList.add(waypoint.name, detail); + } + setListHint(waypointList, "Enter: actions R: rename X: delete", "Tap: actions Swipe right: back"); + waypointList.layoutFullScreen(width, height, overlay.font()); +} + +void MapView::goToWaypoint() { + const int row = waypointList.selected(); + if (row < 0 || row >= waypointList.count()) { + return; + } + const int index = waypointRows[row]; + following = false; + centerOn(waypoints.at(index).lat, waypoints.at(index).lon, std::max(zoom, 14)); + returnToMenu = false; + screen = Screen::Map; + openWaypointSheet(index); + dirty = true; +} + +int MapView::listedWaypoint() const { + const int row = waypointList.selected(); + return row >= 0 && row < waypointList.count() ? waypointRows[row] : -1; +} + +void MapView::openWaypointActions() { + const int row = waypointList.selected(); + if (row < 0 || row >= waypointList.count()) { + return; + } + beginPopup(Popup::WaypointActions, waypoints.at(waypointRows[row]).name); + // 0 go to, 1 rename, 2 delete; renaming needs a keyboard or a touch screen (its on-screen keyboard) + menu.add("Go to"); + popupActions[popupActionCount++] = 0; + if (canType()) { + menu.add("Rename"); + popupActions[popupActionCount++] = 1; + } + menu.add("Delete"); + popupActions[popupActionCount++] = 2; + layoutPopup(); +} + +void MapView::chooseWaypointAction(int action) { + switch (action) { + case 0: + goToWaypoint(); + break; + case 1: + askRenameWaypoint(listedWaypoint()); + break; + default: + askDeleteWaypoint(listedWaypoint()); + break; + } +} + +void MapView::askDeleteWaypoint(int index) { + if (index < 0 || index >= waypoints.count()) { + return; + } + editingWaypoint = index; + char message[96]; + snprintf(message, sizeof(message), "Delete %s?", waypoints.at(index).name); + dialogPurpose = DialogPurpose::DeleteWaypoint; + dialog.open(message, "Delete", true); + dialog.layout(width, height, overlay.font()); + dirty = true; +} + +void MapView::deleteWaypoint() { + if (editingWaypoint < 0 || editingWaypoint >= waypoints.count()) { + return; + } + waypoints.remove(editingWaypoint); + editingWaypoint = -1; + // Indices after the deleted one moved, so nothing stays selected + selectedWaypoint = -1; + sheet.close(); + saveWaypoints(); + if (screen != Screen::Waypoints) { + return; + } + if (waypoints.count() == 0) { + closeScreen(); + return; + } + const int row = waypointList.selected(); + fillWaypointList(); + waypointList.select(std::min(row, waypointList.count() - 1)); +} + +void MapView::askRenameWaypoint(int index) { + if (!canType() || index < 0 || index >= waypoints.count()) { + return; + } + editingWaypoint = index; + promptPurpose = PromptPurpose::RenameWaypoint; + prompt.open("Name of this waypoint", waypoints.at(index).name); + prompt.layout(width, height, overlay.font()); + dirty = true; +} + +void MapView::renameWaypoint(const char* name) { + if (editingWaypoint < 0 || editingWaypoint >= waypoints.count() || name[0] == '\0') { + return; + } + waypoints.rename(editingWaypoint, name); + saveWaypoints(); + if (sheet.isOpen() && selectedWaypoint == editingWaypoint) { + openWaypointSheet(editingWaypoint); + } + if (screen == Screen::Waypoints) { + const int row = waypointList.selected(); + fillWaypointList(); + waypointList.select(row); + } +} + +// --------------------------------------------------------------------------------------------------------------- +// Drawing: GPX lines, the recorded track, waypoint pins + +void MapView::drawPlaces() { + double left; + double top; + double right; + double bottom; + viewBounds(left, top, right, bottom); + // GPX files under everything else of the user's + for (int f = 0; f < gpxFileCount; f++) { + const GpxLayer* layer = gpxFiles[f].shown ? gpxFiles[f].layer : nullptr; + if (layer == nullptr) { + continue; + } + const int shift = GpxLayer::CACHE_ZOOM - zoom; + auto toWorld = [&](uint32_t value) { + return shift >= 0 ? static_cast(value >> shift) + 0.5 : std::ldexp(static_cast(value), -shift); + }; + // World to screen, turned like the map + auto project = [&](uint32_t valueX, uint32_t valueY, float& x, float& y) { + double sx; + double sy; + worldToScreenOffset(toWorld(valueX) - centerX, toWorld(valueY) - centerY, sx, sy); + x = static_cast(sx + width / 2.0); + y = static_cast(sy + height / 2.0); + }; + const uint16_t pinColor = monochrome ? palette->text : GPX_ROUTE_COLOR; + for (int l = 0; l < layer->lineCount(); l++) { + const GpxLayer::Line& line = layer->line(l); + // Lines that cannot reach the screen are skipped by their box + if (toWorld(line.maxX) < left - 4.0 || toWorld(line.minX) > right + 4.0 || toWorld(line.maxY) < top - 4.0 || + toWorld(line.minY) > bottom + 4.0) { + continue; + } + const uint16_t color = monochrome ? palette->text : (line.kind == GpxKind::RoutePoint ? GPX_ROUTE_COLOR : GPX_TRACK_COLOR); + const uint32_t* points = layer->points() + 2 * line.first; + float lastX; + float lastY; + project(points[0], points[1], lastX, lastY); + for (uint32_t i = 1; i < line.count; i++) { + float x; + float y; + project(points[2 * i], points[2 * i + 1], x, y); + const float dx = x - lastX; + const float dy = y - lastY; + if (dx * dx + dy * dy < MIN_STEP_PIXELS * MIN_STEP_PIXELS && i + 1 < line.count) { + continue; + } + const bool visible = !((lastX < 0 && x < 0) || (lastY < 0 && y < 0) || (lastX > width && x > width) || (lastY > height && y > height)); + if (visible) { + canvas.line(lastX, lastY, x, y, 3.0f, color); + } + lastX = x; + lastY = y; + } + } + for (int p = 0; p < layer->pinCount(); p++) { + float x; + float y; + project(layer->pin(p).x, layer->pin(p).y, x, y); + if (x > -8 && y > -8 && x < width + 8 && y < height + 8) { + canvas.fillCircle(x, y, 4.0f, palette->halo); + canvas.fillCircle(x, y, 2.5f, pinColor); + } + } + } + // The track being recorded, above the map and the GPX lines + if (recorder.pointCount() > 1) { + const uint16_t color = monochrome ? palette->text : TRACK_COLOR; + const TrackRecorder::Point* points = recorder.points(); + float lastX = 0.0f; + float lastY = 0.0f; + worldToScreen(points[0].lat, points[0].lon, lastX, lastY); + for (int i = 1; i < recorder.pointCount(); i++) { + float x; + float y; + worldToScreen(points[i].lat, points[i].lon, x, y); + const float dx = x - lastX; + const float dy = y - lastY; + if (dx * dx + dy * dy < MIN_STEP_PIXELS * MIN_STEP_PIXELS && i + 1 < recorder.pointCount()) { + continue; + } + if (!recorder.segmentStartsAt(i)) { + canvas.line(lastX, lastY, x, y, 3.0f, color); + } + lastX = x; + lastY = y; + } + } + // Waypoint pins at every zoom level, all the same size; the one whose sheet is open has the accent colour + const PinStyle style { monochrome ? palette->text : PIN_COLOR, theme.accent, palette->halo, palette->text }; + for (int i = 0; i < waypoints.count(); i++) { + float x; + float y; + if (!worldToScreen(waypoints.at(i).lat, waypoints.at(i).lon, x, y)) { + continue; + } + drawPin(canvas, x, y, style, sheet.isOpen() && i == selectedWaypoint); + } + // The point a new waypoint is being made for + float x; + float y; + if (sheet.isOpen() && sheet.mode() == WaypointSheet::Mode::New && worldToScreen(sheetLat, sheetLon, x, y)) { + drawPin(canvas, x, y, style, true); + } +} + +// --------------------------------------------------------------------------------------------------------------- +// Track recording + +void MapView::drawRecording() { + if (!recorder.isActive()) { + return; + } + const uint32_t seconds = recorder.elapsedMs(nowMs()) / 1000; + char text[48]; + const int used = seconds >= 3600 ? snprintf(text, sizeof(text), "%u:%02u:%02u", static_cast(seconds / 3600), + static_cast(seconds / 60 % 60), static_cast(seconds % 60)) + : snprintf(text, sizeof(text), "%u:%02u", static_cast(seconds / 60), static_cast(seconds % 60)); + if (controls.isVisible() && used > 0) { + char distance[24]; + formatDistance(recorder.distanceMetres(), distance, sizeof(distance)); + snprintf(text + used, sizeof(text) - static_cast(used), " %s", distance); + } + if (recorder.state() == TrackRecorder::State::Paused) { + const size_t length = strlen(text); + snprintf(text + length, sizeof(text) - length, " paused"); + } + Font& font = overlay.font(); + const int textWidth = font.textWidth(text); + const int boxWidth = textWidth + 24; + const int boxHeight = font.lineHeight() + 6; + const int x = width - boxWidth - 4; + const int y = 4; + canvas.fillRoundRect(x, y, boxWidth, boxHeight, boxHeight / 2, theme.panel, theme.panelOpacity); + canvas.fillCircle(static_cast(x + 10), static_cast(y + boxHeight / 2), 4.0f, monochrome ? palette->text : 0xF800); + font.drawText(canvas, x + 18, y + 3, text, theme.text); +} + +void MapView::openTrackMenu() { + beginPopup(Popup::Track, "Track"); + // What can happen next, then the list of tracks: 0 start, 1 pause, 2 resume, 3 stop, 4 recorded tracks + if (recorder.state() == TrackRecorder::State::Off) { + menu.add("Start recording"); + popupActions[popupActionCount++] = 0; + } else { + menu.add(recorder.state() == TrackRecorder::State::Recording ? "Pause" : "Resume"); + popupActions[popupActionCount++] = recorder.state() == TrackRecorder::State::Recording ? 1 : 2; + menu.add("Stop and save"); + popupActions[popupActionCount++] = 3; + } + menu.add("Recorded tracks"); + popupActions[popupActionCount++] = 4; + layoutPopup(); +} + +void MapView::chooseTrackMenu(int action) { + const uint32_t now = nowMs(); + switch (action) { + case 0: + if (!recorder.start(io, rootPath, now)) { + showToast("Could not start the track file"); + } else { + showToast(position.hasFix() ? "Recording" : "Recording, waiting for a GPS position"); + } + break; + case 1: + recorder.pause(now); + break; + case 2: + recorder.resume(now); + break; + case 3: { + recorder.stop(io, now); + char text[64]; + snprintf(text, sizeof(text), "Saved tracks/%s.gpx", recorder.fileName()); + showToast(text); + break; + } + case 4: + returnToMenu = true; + openTracks(); + break; + default: + break; + } + dirty = true; +} + +void MapView::openTracks() { + trackList.clear(); + char title[ListView::TEXT_LENGTH]; + snprintf(title, sizeof(title), "Tracks"); + trackList.setTitle(title); + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/tracks", rootPath); + struct Context { + ListView* list; + const char* folder; + } context { &trackList, folder }; + auto list = [&] { + listDirectory(folder, [](const char* fileName, bool isDirectory, void* data) { + auto* context = static_cast(data); + const size_t length = strlen(fileName); + if (isDirectory || length < 5 || strcmp(fileName + length - 4, ".gpx") != 0 || context->list->count() >= ListView::MAX_ITEMS) { + return true; + } + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/%s", context->folder, fileName); + struct stat info {}; + char detail[ListView::TEXT_LENGTH]; + snprintf(detail, sizeof(detail), "%.1f KB", stat(path, &info) == 0 ? info.st_size / 1024.0 : 0.0); + context->list->add(fileName, detail); + return true; + }, &context); + return true; + }; + runOnCard(io, list, IoPriority::Background); + if (trackList.count() == 0) { + showToast("No tracks yet"); + return; + } + // Where the files are, so they can be copied off the card: the path on two lines, split at a folder + char hint[160]; + const char* split = strstr(folder, "/user/app/"); + if (split != nullptr) { + split += strlen("/user/app/"); + snprintf(hint, sizeof(hint), "Files in %.*s\n%s", static_cast(split - folder), folder, split); + } else { + snprintf(hint, sizeof(hint), "Files in %s", folder); + } + trackList.setHint(hint); + trackList.layoutFullScreen(width, height, overlay.font()); + trackList.select(0); + trackList.setHighlighted(false); + screen = Screen::Tracks; + dirty = true; +} + +// --------------------------------------------------------------------------------------------------------------- +// GPX files + +void MapView::openGpxFiles() { + gpxList.clear(); + gpxList.setTitle("GPX files"); + // Files dropped into gpx/ on the card; the list keeps the files already known with their state + struct Context { + MapView* view; + } context { this }; + auto list = [&] { + listDirectory(gpxFolder, [](const char* fileName, bool isDirectory, void* data) { + MapView* view = static_cast(data)->view; + const size_t length = strlen(fileName); + if (isDirectory || length < 5 || (strcmp(fileName + length - 4, ".gpx") != 0 && strcmp(fileName + length - 4, ".GPX") != 0)) { + return true; + } + for (int i = 0; i < view->gpxFileCount; i++) { + if (strcmp(view->gpxFiles[i].name, fileName) == 0) { + return true; + } + } + if (view->gpxFileCount < MAX_GPX_FILES) { + GpxFile& file = view->gpxFiles[view->gpxFileCount++]; + file = {}; + snprintf(file.name, sizeof(file.name), "%s", fileName); + } + return true; + }, &context); + return true; + }; + runOnCard(io, list, IoPriority::Background); + for (int i = 0; i < gpxFileCount; i++) { + const GpxFile& file = gpxFiles[i]; + gpxList.add(file.name, i == gpxImporting ? "reading" : (file.failed ? "not readable" : (file.shown ? "shown" : "hidden"))); + } + if (gpxFileCount == 0) { + char text[96]; + snprintf(text, sizeof(text), "No GPX files: copy them into %s", gpxFolder); + showToast(text); + return; + } + setListHint(gpxList, "Enter: show/hide Esc: back", "Tap: show/hide Swipe right: back"); + gpxList.layoutFullScreen(width, height, overlay.font()); + gpxList.select(0); + gpxList.setHighlighted(false); + screen = Screen::GpxFiles; + dirty = true; +} + +void MapView::toggleGpxFile() { + const int index = gpxList.selected(); + if (index < 0 || index >= gpxFileCount) { + return; + } + GpxFile& file = gpxFiles[index]; + if (file.shown) { + file.shown = false; + // The memory goes, the cache stays for the next time + if (file.layer != nullptr) { + file.layer->~GpxLayer(); + largeFree(file.layer); + file.layer = nullptr; + } + } else if (file.layer != nullptr) { + file.shown = true; + } else { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/%s", gpxFolder, file.name); + if (!gpxImport.start(io, path)) { + showToast("Another file is still being read"); + return; + } + gpxImporting = index; + file.failed = false; + } + saveShownGpx(); + gpxList.setDetail(index, index == gpxImporting ? "reading" : (file.shown ? "shown" : "hidden")); + dirty = true; +} + +void MapView::pollGpxImport() { + const GpxImport::State state = gpxImport.state(); + if (gpxImporting < 0 || (state != GpxImport::State::Done && state != GpxImport::State::Failed)) { + return; + } + GpxFile& file = gpxFiles[gpxImporting]; + const int index = gpxImporting; + gpxImporting = -1; + if (state == GpxImport::State::Done) { + file.layer = gpxImport.takeLayer(); + file.shown = file.layer != nullptr; + } else { + char text[96]; + snprintf(text, sizeof(text), "%s: %s", file.name, gpxImport.error()); + gpxImport.acknowledge(); + file.failed = true; + file.shown = false; + showToast(text); + } + if (screen == Screen::GpxFiles) { + gpxList.setDetail(index, file.failed ? "not readable" : (file.shown ? "shown" : "hidden")); + } + saveShownGpx(); + // The next file that was shown last time + for (int i = 0; i < gpxFileCount; i++) { + if (gpxFiles[i].shown && gpxFiles[i].layer == nullptr && !gpxFiles[i].failed) { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/%s", gpxFolder, gpxFiles[i].name); + if (gpxImport.start(io, path)) { + gpxImporting = i; + } + break; + } + } + dirty = true; +} + +void MapView::saveShownGpx() { + char text[MAX_GPX_FILES * 50 + 1] = ""; + size_t used = 0; + for (int i = 0; i < gpxFileCount; i++) { + if (gpxFiles[i].shown || i == gpxImporting) { + used += static_cast(snprintf(text + used, sizeof(text) - used, "%s\n", gpxFiles[i].name)); + } + } + char path[PATH_MAX_LENGTH]; + char temporary[PATH_MAX_LENGTH + 8]; + snprintf(path, sizeof(path), "%s/gpxshown.txt", rootPath); + snprintf(temporary, sizeof(temporary), "%s.tmp", path); + auto write = [&] { return io.writeFile(path, temporary, reinterpret_cast(text), used, false); }; + runOnCard(io, write, IoPriority::Background); +} + +void MapView::loadShownGpx() { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/gpxshown.txt", rootPath); + FileBuffer file; + auto read = [&] { return file.load(path); }; + if (!runOnCard(io, read, IoPriority::Background) || file.size() == 0) { + return; + } + // One file name per line; they are read one after another in the background + const char* text = reinterpret_cast(file.bytes()); + size_t start = 0; + for (size_t i = 0; i <= file.size() && gpxFileCount < MAX_GPX_FILES; i++) { + if (i == file.size() || text[i] == '\n') { + if (i > start && i - start < sizeof(GpxFile::name)) { + GpxFile& entry = gpxFiles[gpxFileCount++]; + entry = {}; + memcpy(entry.name, text + start, i - start); + entry.name[i - start] = '\0'; + entry.shown = true; + } + start = i + 1; + } + } + for (int i = 0; i < gpxFileCount; i++) { + char gpxPath[PATH_MAX_LENGTH]; + snprintf(gpxPath, sizeof(gpxPath), "%s/%s", gpxFolder, gpxFiles[i].name); + if (gpxImport.start(io, gpxPath)) { + gpxImporting = i; + break; + } + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/MapScreens.cpp b/Apps/Maps/main/Source/app/MapScreens.cpp new file mode 100644 index 0000000..03a22fa --- /dev/null +++ b/Apps/Maps/main/Source/app/MapScreens.cpp @@ -0,0 +1,1059 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MapView.h" + +#include "../platform/Platform.h" + +#include + +#include + +#include +#include +#include +#include +#include + +namespace maps { + +namespace { + +constexpr int MENU_WIDTH = 220; +constexpr int ABOUT_LINES = 40; +constexpr int ABOUT_LINE_LENGTH = 64; + +const char* const MENU_LABELS[] = { "Download map", "Downloaded maps", "Waypoints", "Add waypoint", "Record track", "GPX files", "Location", "Settings", + "About and licences", "Exit" }; +const char* const DOWNLOAD_CHOICES[] = { "Choose area", "Choose region" }; + +const char* const ABOUT_PARAGRAPHS[] = { + "Tactility Maps " APP_VERSION, + "Designed by Adam Szilv\xC3\xA1" "si", + "Offline vector maps for Tactility.", + "", + "Map data \xC2\xA9 OpenStreetMap contributors, available under the Open Database Licence (ODbL): openstreetmap.org/copyright", + "", + "Vector tiles \xC2\xA9 OpenMapTiles (openmaptiles.org), served by OpenFreeMap (openfreemap.org).", + "", + "Root certificates of Google Trust Services and Let's Encrypt are included to reach OpenFreeMap over HTTPS.", + "", + "Label font: Adwaita Sans (SIL Open Font License 1.1). Place icons: Maki by Mapbox (CC0).", + "", + "W/S or drag to scroll. Esc, Backspace, < or a swipe to the right go back.", +}; + +/** The menu row with the running download's details, before the menu items. */ +constexpr int MENU_DOWNLOAD_DETAILS = 100; +constexpr int MENU_ROUTE_SHOW = 101; +constexpr int MENU_ROUTE_STOP = 102; +constexpr int MENU_ROUTE_CLEAR = 103; + +/** What can be done with a region or a waypoint, the rows of their action popups. */ +enum RegionAction : int { + REGION_SHOW, + REGION_UPDATE, + REGION_CONTINUE, + REGION_PAUSE, + REGION_RENAME, + REGION_DELETE +}; + +} // namespace + +void MapView::setListHint(ListView& list, const char* keyHint, const char* touchHint) { + // One line per input the board has, keys first + char text[128]; + const bool touch = input.hasTouch(); + const bool keys = input.hasKeyboard() || !touch; + snprintf(text, sizeof(text), "%s%s%s", keys ? keyHint : "", keys && touch ? "\n" : "", touch ? touchHint : ""); + list.setHint(text); +} + +ListView* MapView::activeList() { + switch (screen) { + case Screen::Regions: + return ®ionList; + case Screen::Settings: + return &settingList; + case Screen::Catalog: + return &catalogList; + case Screen::Waypoints: + return &waypointList; + case Screen::GpxFiles: + return &gpxList; + case Screen::Tracks: + return &trackList; + default: + return nullptr; + } +} + +void MapView::closeScreen() { + const bool backToMenu = returnToMenu && isFullScreen(); + screen = Screen::Map; + returnToMenu = false; + dialog.close(); + dirty = true; + updateWanted(); + if (backToMenu) { + openMenu(); + } +} + +void MapView::goBack() { + if (menuOpen) { + handlePopupAction(UiAction::Back); + } else if (screen == Screen::About) { + closeScreen(); + } else if (activeList() != nullptr) { + handleListAction(UiAction::Back); + } + dirty = true; +} + +// --------------------------------------------------------------------------------------------------------------- +// Menu + +void MapView::beginPopup(Popup kind, const char* title) { + menu.clear(); + menu.setHighlighted(false); + menu.setTitle(title); + popup = kind; + popupActionCount = 0; +} + +void MapView::layoutPopup() { + menu.layoutPopup(width, height, overlay.font(), std::min(MENU_WIDTH, width - 16)); + menuOpen = true; + dirty = true; +} + +void MapView::openMenu() { + beginPopup(Popup::Main, "Menu"); + // While a download runs or is paused its details come first, so every input reaches Pause and Cancel + const Downloader::Status status = downloader.status(); + const bool running = status.state == Downloader::State::Downloading || status.state == Downloader::State::Updating || + status.state == Downloader::State::Connecting; + if (running || pausedRegion[0] != '\0') { + char detail[16]; + snprintf(detail, sizeof(detail), "%s", running ? "starting" : "paused"); + if (running && status.total > 0) { + snprintf(detail, sizeof(detail), "%d%%", static_cast(status.done * 100 / status.total)); + } + menu.add(status.state == Downloader::State::Updating ? "Update" : "Download", detail); + popupActions[popupActionCount++] = MENU_DOWNLOAD_DETAILS; + } + // A route: show it again, stop following it, or clear it (it is kept until then, also across restarts) + if (navigator.hasRoute()) { + if (navigating) { + menu.add("Stop navigation"); + popupActions[popupActionCount++] = MENU_ROUTE_STOP; + } else { + menu.add("Show route"); + popupActions[popupActionCount++] = MENU_ROUTE_SHOW; + } + menu.add("Clear route"); + popupActions[popupActionCount++] = MENU_ROUTE_CLEAR; + } + int item = 0; + for (const char* label : MENU_LABELS) { + popupActions[popupActionCount++] = item++; + // The track entry says what it does now + if (strcmp(label, "Record track") == 0 && recorder.isActive()) { + menu.add(recorder.state() == TrackRecorder::State::Paused ? "Track (paused)" : "Track (recording)"); + } else if (strcmp(label, "Location") == 0) { + menu.add(label, LocationSource::isEnabled() ? "On" : "Off"); + } else { + menu.add(label); + } + } + layoutPopup(); +} + +void MapView::closeMenu() { + menuOpen = false; + dirty = true; +} + +void MapView::openDownloadChoice() { + // The same popup, now with the two ways to pick an area + beginPopup(Popup::DownloadChoice, "Download map"); + for (const char* label : DOWNLOAD_CHOICES) { + menu.add(label); + } + layoutPopup(); +} + +void MapView::chooseMenu(MenuItem item) { + menuOpen = false; + dirty = true; + controls.hide(); + cursorShown = false; + // Screens opened from here go back to the menu + returnToMenu = true; + switch (item) { + case MenuItem::Download: + openDownloadChoice(); + break; + case MenuItem::Regions: + openRegions(); + break; + case MenuItem::Waypoints: + openWaypoints(); + break; + case MenuItem::AddWaypoint: + openNewWaypoint(worldYToLat(centerY, zoom), worldXToLon(centerX, zoom)); + break; + case MenuItem::RecordTrack: + openTrackMenu(); + break; + case MenuItem::GpxFiles: + openGpxFiles(); + break; + case MenuItem::Location: + toggleLocation(); + break; + case MenuItem::Settings: + openSettings(); + break; + case MenuItem::About: + openAbout(); + break; + case MenuItem::Exit: + // The only way out by trackball or touch, LVGL's swipe back does not run while the map has the screen + exitRequested = true; + break; + default: + break; + } + if (!isFullScreen()) { + returnToMenu = false; + } +} + +void MapView::handlePopupAction(UiAction action) { + dirty = true; + const Popup kind = popup; + if (action == UiAction::Back) { + // Sub-menus go back to the menu, the actions of a row back to their list, the menu closes + closeMenu(); + if (kind == Popup::DownloadChoice || kind == Popup::Track) { + openMenu(); + } + return; + } + if (action != UiAction::Chosen) { + return; + } + const int row = menu.selected(); + const int chosen = row >= 0 && row < popupActionCount ? popupActions[row] : row; + switch (kind) { + case Popup::Main: + if (chosen == MENU_DOWNLOAD_DETAILS) { + closeMenu(); + openDownloadCard(); + } else if (chosen == MENU_ROUTE_SHOW) { + closeMenu(); + fillRouteCard(); + fitRoute(); + } else if (chosen == MENU_ROUTE_STOP) { + closeMenu(); + stopNavigation(); + showToast("Navigation stopped, the route stays"); + } else if (chosen == MENU_ROUTE_CLEAR) { + closeMenu(); + clearRoute(); + } else { + chooseMenu(static_cast(chosen)); + } + break; + case Popup::DownloadChoice: + closeMenu(); + returnToMenu = true; + if (row == 0) { + returnToMenu = false; + screen = Screen::Download; + // The download frame works north up + following = false; + headingUp = false; + setBearing(0.0f, false); + } else { + openCatalog(-1); + } + break; + case Popup::Track: + closeMenu(); + chooseTrackMenu(chosen); + break; + case Popup::RegionActions: + closeMenu(); + chooseRegionAction(chosen); + break; + case Popup::WaypointActions: + closeMenu(); + chooseWaypointAction(chosen); + break; + case Popup::RouteStart: + closeMenu(); + chooseRouteStart(chosen); + break; + } +} + +void MapView::handleListAction(UiAction action) { + dirty = true; + if (menuOpen) { + handlePopupAction(action); + return; + } + if (screen == Screen::Catalog) { + if (action == UiAction::Chosen) { + chooseCatalog(); + } else if (action == UiAction::Back) { + catalogBack(); + } + return; + } + if (action == UiAction::Back) { + closeScreen(); + return; + } + switch (screen) { + case Screen::Regions: + if (action == UiAction::Chosen) { + openRegionActions(); + } else if (action == UiAction::Delete) { + askDeleteRegion(); + } + break; + case Screen::Waypoints: + if (action == UiAction::Chosen) { + openWaypointActions(); + } else if (action == UiAction::Delete) { + askDeleteWaypoint(listedWaypoint()); + } + break; + case Screen::GpxFiles: + if (action == UiAction::Chosen) { + toggleGpxFile(); + } + break; + case Screen::Settings: + if (action == UiAction::Chosen) { + toggleSetting(static_cast(settingList.selected())); + } + break; + default: + break; + } +} + +// --------------------------------------------------------------------------------------------------------------- +// Regions + +void MapView::openRegions() { + regionCount = 0; + auto read = [&] { + for (int i = 0; i < store.regionCount() && regionCount < TileStore::MAX_REGIONS; i++) { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/meta.json", rootPath, store.regionName(i)); + RegionMeta& meta = regions[regionCount]; + if (readRegionMeta(path, meta)) { + snprintf(meta.name, sizeof(meta.name), "%s", store.regionName(i)); + regionCount++; + } + } + return true; + }; + runOnCard(io, read); + const int selected = screen == Screen::Regions ? regionList.selected() : 0; + fillRegionList(); + regionList.select(selected); + if (screen != Screen::Regions) { + regionList.setHighlighted(false); + } + screen = Screen::Regions; + dirty = true; +} + +void MapView::fillRegionList() { + regionList.clear(); + regionList.setTitle("Downloaded maps"); + if (regionCount == 0) { + regionList.setHint("No maps yet. Menu, Download map."); + } else { + setListHint(regionList, "Enter: actions R: rename X: delete", "Tap: actions Swipe right: back"); + } + const Downloader::Status status = downloader.status(); + for (int i = 0; i < regionCount; i++) { + const RegionMeta& meta = regions[i]; + char label[ListView::TEXT_LENGTH]; + char detail[ListView::TEXT_LENGTH]; + const bool active = (status.state == Downloader::State::Downloading || status.state == Downloader::State::Connecting) && + strcmp(status.region, meta.name) == 0; + if (active || !meta.isComplete()) { + const int64_t done = active ? status.done : meta.done; + snprintf(label, sizeof(label), "%s %s %d%%", regionTitle(meta), active ? "downloading" : "paused", + static_cast(meta.total > 0 ? done * 100 / meta.total : 0)); + } else if (status.state == Downloader::State::Updating && strcmp(status.region, meta.name) == 0) { + snprintf(label, sizeof(label), "%s updating %d%%", regionTitle(meta), static_cast(status.total > 0 ? status.done * 100 / status.total : 0)); + } else if (isOutdated(meta)) { + snprintf(label, sizeof(label), "%s outdated", regionTitle(meta)); + } else { + snprintf(label, sizeof(label), "%s %s", regionTitle(meta), strcmp(meta.name, BROWSE_REGION) == 0 ? "viewed online" : "complete"); + } + snprintf(detail, sizeof(detail), "%.1f MB %s", meta.bytes / 1e6, meta.date[0] != '\0' ? meta.date + 2 : ""); + regionList.add(label, detail); + } + regionList.layoutFullScreen(width, height, overlay.font()); +} + +void MapView::openRegionActions() { + if (regionList.selected() >= regionCount) { + return; + } + const RegionMeta& meta = regions[regionList.selected()]; + const Downloader::Status status = downloader.status(); + const bool active = downloader.isBusy() && strcmp(status.region, meta.name) == 0; + beginPopup(Popup::RegionActions, regionTitle(meta)); + auto add = [&](const char* label, int action) { + menu.add(label); + popupActions[popupActionCount++] = action; + }; + add("Show on map", REGION_SHOW); + if (isOutdated(meta)) { + add("Update now", REGION_UPDATE); + } + if (active && status.state != Downloader::State::Deleting) { + add("Pause download", REGION_PAUSE); + } else if (!meta.isComplete()) { + add("Continue download", REGION_CONTINUE); + } + if (canType()) { + add("Rename", REGION_RENAME); + } + add("Delete", REGION_DELETE); + layoutPopup(); +} + +void MapView::chooseRegionAction(int action) { + if (regionList.selected() >= regionCount) { + return; + } + const RegionMeta& meta = regions[regionList.selected()]; + switch (action) { + case REGION_SHOW: + returnToMenu = false; + showRegion(meta); + screen = Screen::Map; + break; + case REGION_UPDATE: { + // Updating costs a download of the whole region again, so it is asked first + char message[96]; + snprintf(message, sizeof(message), "%s has newer map data (about %.0f MB). Update now?", regionTitle(meta), meta.bytes / 1e6); + dialogPurpose = DialogPurpose::Update; + dialog.open(message, "Update now", false); + dialog.layout(width, height, overlay.font()); + break; + } + case REGION_CONTINUE: + downloader.download(meta.name); + showToast("Download continues"); + break; + case REGION_PAUSE: + downloader.cancel(); + showToast("Download paused"); + break; + case REGION_RENAME: + askRenameRegion(); + break; + case REGION_DELETE: + askDeleteRegion(); + break; + default: + break; + } + dirty = true; +} + +bool MapView::isOutdated(const RegionMeta& meta) const { + const char* latest = downloader.latestVersion(); + return meta.isComplete() && latest[0] != '\0' && meta.version[0] != '\0' && strcmp(latest, meta.version) != 0 && + strcmp(meta.name, BROWSE_REGION) != 0; +} + +void MapView::askRenameRegion() { + if (regionList.selected() >= regionCount) { + return; + } + if (!canType()) { + return; + } + const RegionMeta& meta = regions[regionList.selected()]; + const Downloader::Status status = downloader.status(); + if (downloader.isBusy() && strcmp(status.region, meta.name) == 0) { + // Its meta.json is being written by the download + showToast("Rename it when its download is finished"); + return; + } + promptPurpose = PromptPurpose::RenameRegion; + prompt.open("Name of this region", regionTitle(meta)); + prompt.layout(width, height, overlay.font()); + dirty = true; +} + +void MapView::confirmPrompt() { + if (promptPurpose == PromptPurpose::RenameWaypoint) { + renameWaypoint(prompt.text()); + return; + } + if (screen != Screen::Regions || regionList.selected() >= regionCount) { + return; + } + RegionMeta& meta = regions[regionList.selected()]; + setRegionTitle(meta, prompt.text()); + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/meta.json", rootPath, meta.name); + // The folder keeps its short name, only the title changes + RegionMeta stored {}; + auto write = [&] { + if (!readRegionMeta(path, stored)) { + return false; + } + setRegionTitle(stored, prompt.text()); + return writeRegionMeta(path, stored); + }; + if (!runOnCard(io, write)) { + showToast("Could not save the name"); + } + const int selected = regionList.selected(); + fillRegionList(); + regionList.select(selected); +} + +void MapView::confirmDialog() { + if (dialogPurpose == DialogPurpose::CatalogDownload) { + startCatalogDownload(); + return; + } + if (dialogPurpose == DialogPurpose::DeleteWaypoint) { + deleteWaypoint(); + return; + } + if (dialogPurpose == DialogPurpose::CancelDownload) { + // The downloader stops the job and removes the region, keeping tiles other regions use + downloader.remove(cancelRegion); + store.removeRegion(cancelRegion); + pausedRegion[0] = '\0'; + showToast("Download cancelled"); + return; + } + if (dialogPurpose == DialogPurpose::Update) { + if (screen == Screen::Regions && regionList.selected() < regionCount) { + const RegionMeta& meta = regions[regionList.selected()]; + downloader.update(meta.name); + returnToMenu = false; + showRegion(meta); + screen = Screen::Map; + } + return; + } + deleteRegion(); +} + +void MapView::askDeleteRegion() { + if (regionList.selected() >= regionCount) { + return; + } + char message[80]; + snprintf(message, sizeof(message), "Delete %s? Tiles other regions use are kept.", regionTitle(regions[regionList.selected()])); + dialogPurpose = DialogPurpose::Delete; + dialog.open(message, "Delete", true); + dialog.layout(width, height, overlay.font()); + dirty = true; +} + +void MapView::deleteRegion() { + if (screen != Screen::Regions || regionList.selected() >= regionCount) { + return; + } + const char* name = regions[regionList.selected()].name; + downloader.remove(name); + store.removeRegion(name); + worker.lock(); + worker.cache().clear(); + worker.unlock(); + // The list without it, the files go in the background + for (int i = regionList.selected(); i + 1 < regionCount; i++) { + regions[i] = regions[i + 1]; + } + regionCount--; + const int selected = regionList.selected(); + fillRegionList(); + regionList.select(selected); + dirty = true; +} + +void MapView::showRegion(const RegionMeta& meta) { + // The highest zoom level at which the whole region fits on the screen + int fitZoom = meta.maxZoom; + while (fitZoom > 0) { + const double regionWidth = lonToWorldX(meta.box.east, fitZoom) - lonToWorldX(meta.box.west, fitZoom); + const double regionHeight = latToWorldY(meta.box.south, fitZoom) - latToWorldY(meta.box.north, fitZoom); + if (regionWidth <= width && regionHeight <= height) { + break; + } + fitZoom--; + } + following = false; + centerOn((meta.box.south + meta.box.north) / 2.0, (meta.box.west + meta.box.east) / 2.0, fitZoom); +} + +// --------------------------------------------------------------------------------------------------------------- +// Settings + +void MapView::openSettings() { + fillSettingList(); + settingList.select(0); + settingList.setHighlighted(false); + screen = Screen::Settings; + dirty = true; +} + +void MapView::fillSettingList() { + const int selected = settingList.selected(); + settingList.clear(); + settingList.setTitle("Settings"); + setListHint(settingList, "Enter: change Esc: back", "Tap: change Swipe right: back"); + settingList.add("SD card cache", settings.sdCache ? "On" : "Off"); + settingList.add("Prepared tiles", settings.deflatePrepared ? "Deflated" : "Raw"); + settingList.add("Smooth zoom", monochrome ? "Off (display)" : (settings.smoothPinch ? "On" : "Off")); + settingList.add("Download while browsing", settings.browseDownload ? "On" : "Off"); + static const char* const POINTER_SPEEDS[] = { "Slow", "Normal", "Fast" }; + settingList.add("Pointer speed", POINTER_SPEEDS[std::clamp(settings.pointerSpeed, 0, 2)]); + static const char* const LABEL_DENSITIES[] = { "Off", "Few", "Normal" }; + settingList.add("Labels", LABEL_DENSITIES[std::clamp(settings.labels, 0, 2)]); + settingList.add("Latin names", settings.latinNames ? "On" : "Off"); + settingList.add("Route mode", routeProfileName(static_cast(std::clamp(settings.routeProfile, 0, 2)))); + settingList.layoutFullScreen(width, height, overlay.font()); + settingList.select(selected); +} + +void MapView::toggleSetting(SettingItem item) { + switch (item) { + case SettingItem::SdCache: + settings.sdCache = !settings.sdCache; + worker.setSdCache(settings.sdCache); + break; + case SettingItem::PreparedTiles: + // Tiles converted from now on use the new kind, both kinds are read + settings.deflatePrepared = !settings.deflatePrepared; + worker.setDeflatePrepared(settings.deflatePrepared); + break; + case SettingItem::BrowseDownload: + settings.browseDownload = !settings.browseDownload; + break; + case SettingItem::Labels: + settings.labels = (settings.labels + 1) % 3; + applyLabelSettings(); + break; + case SettingItem::LatinNames: + settings.latinNames = !settings.latinNames; + applyLabelSettings(); + break; + case SettingItem::RouteMode: + // The mode a new route starts with; the card can switch it for one route + settings.routeProfile = (settings.routeProfile + 1) % static_cast(RouteProfile::Count); + break; + case SettingItem::PointerSpeed: + settings.pointerSpeed = (settings.pointerSpeed + 1) % PointerAcceleration::SPEEDS; + pointerAcceleration.setSpeed(settings.pointerSpeed); + break; + case SettingItem::SmoothPinch: + if (monochrome) { + return; + } + settings.smoothPinch = !settings.smoothPinch; + break; + default: + return; + } + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/settings.txt", rootPath); + auto write = [&] { return writeSettings(path, settings); }; + if (!runOnCard(io, write)) { + showToast("Could not save the settings"); + } + fillSettingList(); + dirty = true; +} + +// --------------------------------------------------------------------------------------------------------------- +// About and licences + +void MapView::openAbout() { + // From the menu it goes back there, from the attribution on the map back to the map + returnToMenu = menuOpen || returnToMenu; + controls.hide(); + cursorShown = false; + aboutScroll = 0; + aboutLines = 0; + if (aboutBuffer == nullptr) { + aboutBuffer = static_cast(largeAlloc(ABOUT_LINES * ABOUT_LINE_LENGTH)); + if (aboutBuffer == nullptr) { + return; + } + } + screen = Screen::About; + menuOpen = false; + dirty = true; +} + +void MapView::scrollAbout(int lines) { + const int visible = (height - normalFont.lineHeight() - 8) / normalFont.lineHeight(); + aboutScroll = std::clamp(aboutScroll + lines, 0, std::max(0, aboutLines - visible)); + dirty = true; +} + +void MapView::drawAbout() { + Font& font = overlay.font(); + constexpr int padding = 8; + if (aboutLines == 0) { + // Wrapped once when the page opens + for (const char* paragraph : ABOUT_PARAGRAPHS) { + // The last paragraph names keys: a board with only a touch screen gets the touch words + if (paragraph == ABOUT_PARAGRAPHS[sizeof(ABOUT_PARAGRAPHS) / sizeof(ABOUT_PARAGRAPHS[0]) - 1] && !input.hasKeyboard()) { + paragraph = "Drag to scroll. < or a swipe to the right goes back."; + } + if (aboutLines >= ABOUT_LINES) { + break; + } + if (paragraph[0] == '\0') { + aboutBuffer[aboutLines++ * ABOUT_LINE_LENGTH] = '\0'; + continue; + } + aboutLines += wrapText(font, paragraph, width - 2 * padding, aboutBuffer + aboutLines * ABOUT_LINE_LENGTH, ABOUT_LINE_LENGTH, + ABOUT_LINES - aboutLines); + } + } + canvas.fillRect(0, 0, width, height, theme.panel); + const int lineHeight = font.lineHeight(); + const int top = lineHeight + padding + 4; + aboutHeader = headerLayout(0, 0, width, top - 2); + drawHeader(canvas, font, theme, aboutHeader, "About and licences", false); + const int visible = (height - top) / lineHeight; + for (int i = 0; i < visible && aboutScroll + i < aboutLines; i++) { + font.drawText(canvas, padding, top + i * lineHeight, aboutBuffer + (aboutScroll + i) * ABOUT_LINE_LENGTH, theme.text); + } + if (aboutLines > visible) { + const int barHeight = std::max(8, (height - top) * visible / aboutLines); + const int barTop = top + (height - top - barHeight) * aboutScroll / std::max(1, aboutLines - visible); + canvas.fillRect(width - 3, barTop, 2, barHeight, theme.muted); + } +} + +// --------------------------------------------------------------------------------------------------------------- +// Download area + +BoundingBox MapView::frameBox() const { + return screenBounds(downloadView.frame, centerX, centerY, zoom, width, height); +} + +void MapView::drawDownloadFrame() { + const DownloadPlan plan = planDownload(frameBox(), MAX_SOURCE_ZOOM); + // How many of its tiles are browsed already, counted in the background once the frame stays put + browsedCounter.request(plan, nowMs()); + int64_t onCard = -1; + browsedCounter.count(plan, onCard); + drawDownloadScreen(canvas, overlay.font(), theme, downloadView, plan, onCard); +} + +bool MapView::makeRegionFolder(const char* title, RegionMeta& meta) { + auto makeFolder = [&] { + for (int number = 0; number < 100; number++) { + regionFolderName(title, number, meta.name, sizeof(meta.name)); + // "browsed" belongs to browsing, a region called Browsed gets browsed2 + if (strcmp(meta.name, BROWSE_REGION) == 0) { + continue; + } + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/maps/%s", rootPath, meta.name); + struct stat info {}; + if (stat(folder, &info) != 0) { + return mkdir(folder, 0777) == 0; + } + } + return false; + }; + return runOnCard(io, makeFolder); +} + +void MapView::startDownload() { + const DownloadPlan plan = planDownload(frameBox(), MAX_SOURCE_ZOOM); + if (!plan.fits) { + showToast("Too large for one download. Zoom in to cover less."); + return; + } + // An area a region already covers is not downloaded a second time: an unfinished region continues instead + int existing = -1; + RegionMeta found {}; + auto findCovering = [&] { + constexpr double SLACK = 1e-6; + for (int i = 0; i < store.regionCount() && existing < 0; i++) { + if (strcmp(store.regionName(i), BROWSE_REGION) == 0) { + continue; + } + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/meta.json", rootPath, store.regionName(i)); + if (readRegionMeta(path, found) && found.outline[0] == '\0' && found.minZoom <= 0 && found.maxZoom >= plan.maxZoom && + found.box.west <= plan.box.west + SLACK && found.box.south <= plan.box.south + SLACK && + found.box.east >= plan.box.east - SLACK && found.box.north >= plan.box.north - SLACK) { + existing = i; + } + } + return true; + }; + runOnCard(io, findCovering); + if (existing >= 0) { + char text[96]; + if (found.isComplete()) { + snprintf(text, sizeof(text), "Already downloaded: %s", regionTitle(found)); + } else { + snprintf(text, sizeof(text), "Continuing the download of %s", regionTitle(found)); + pausedRegion[0] = '\0'; + downloader.download(store.regionName(existing)); + } + logInfo("Frame covered by region %s", store.regionName(existing)); + showToast(text); + screen = Screen::Map; + dirty = true; + updateWanted(); + return; + } + RegionMeta meta {}; + // Named after the smallest catalog region around the frame's centre for now; once its tiles are on the card the + // downloader looks for the nearest town in them + char title[sizeof(meta.title)] = ""; + const double centerLat = (plan.box.south + plan.box.north) / 2.0; + const double centerLon = (plan.box.west + plan.box.east) / 2.0; + if (!catalogTitle(centerLat, centerLon, title, sizeof(title))) { + title[0] = '\0'; + } + if (!makeRegionFolder(title, meta)) { + showToast("Could not make a folder on the SD card"); + return; + } + setRegionTitle(meta, title); + meta.box = plan.box; + meta.minZoom = 0; + meta.maxZoom = plan.maxZoom; + meta.total = plan.tiles; + logInfo("New region %s (%s): %d tiles up to z%d, about %d KB", meta.name, regionTitle(meta), static_cast(plan.tiles), plan.maxZoom, + static_cast(plan.estimatedBytes / 1024)); + if (beginRegionDownload(meta)) { + screen = Screen::Map; + dirty = true; + updateWanted(); + } +} + +bool MapView::beginRegionDownload(RegionMeta& meta) { + meta.done = 0; + meta.bytes = 0; + time_t now = time(nullptr); + struct tm local {}; + localtime_r(&now, &local); + strftime(meta.date, sizeof(meta.date), "%Y-%m-%d", &local); + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/meta.json", rootPath, meta.name); + auto write = [&] { return writeRegionMeta(path, meta); }; + if (!runOnCard(io, write)) { + logError("Could not write %s", path); + return false; + } + store.addRegion(meta.name); + store.setRegionBounds(meta.name, meta.box, meta.minZoom, meta.maxZoom); + downloader.download(meta.name); + return true; +} + +// --------------------------------------------------------------------------------------------------------------- +// Region catalog + +bool MapView::loadCatalog() { + if (catalog.isOpen()) { + return true; + } + if (catalogPath[0] == '\0') { + app_paths_get_assets_path(APP_ID, "regions.bin", catalogPath, sizeof(catalogPath)); + downloader.setCatalogPath(catalogPath); + } + // About 350 KB in PSRAM while the app runs, read once + auto read = [&] { return catalogFile.load(catalogPath); }; + if (!runOnCard(io, read, IoPriority::Background) || !catalog.open(catalogFile.bytes(), catalogFile.size())) { + logError("Region catalog %s not readable", catalogPath); + return false; + } + logInfo("Region catalog: %d regions, %u bytes", catalog.count(), static_cast(catalogFile.size())); + return true; +} + +bool MapView::catalogTitle(double lat, double lon, char* out, size_t size) { + if (!loadCatalog()) { + return false; + } + const int index = catalog.smallestContaining(lat, lon); + if (index < 0) { + return false; + } + catalog.copyName(index, out, size); + return true; +} + +void MapView::openCatalog(int parent) { + if (!loadCatalog()) { + showToast("The region list is missing, reinstall the app"); + return; + } + catalogParent = parent; + catalogList.clear(); + char name[ListView::TEXT_LENGTH]; + int rows = 0; + if (parent >= 0) { + catalog.copyName(parent, name, sizeof(name)); + catalogList.setTitle(name); + char label[ListView::TEXT_LENGTH]; + snprintf(label, sizeof(label), "All of %s", name); + catalogList.add(label); + catalogRows[rows++] = parent; + } else { + catalogList.setTitle("Choose region"); + } + int children[ListView::MAX_ITEMS]; + const int count = catalog.children(parent, children, ListView::MAX_ITEMS - rows); + for (int i = 0; i < count; i++) { + catalog.copyName(children[i], name, sizeof(name)); + catalogList.add(name, catalog.region(children[i]).childCount > 0 ? ">" : ""); + catalogRows[rows++] = children[i]; + } + setListHint(catalogList, "Enter: open/choose Esc: back", "Tap: open/choose Swipe right: back"); + catalogList.layoutFullScreen(width, height, overlay.font()); + catalogList.select(0); + catalogList.setHighlighted(false); + screen = Screen::Catalog; + dirty = true; +} + +void MapView::chooseCatalog() { + const int row = catalogList.selected(); + if (row < 0 || row >= catalogList.count()) { + return; + } + const int region = catalogRows[row]; + // A region with parts opens its list; "All of ..." and regions without parts are downloaded + if (region != catalogParent && catalog.region(region).childCount > 0) { + openCatalog(region); + return; + } + planCatalogRegion(region); +} + +void MapView::catalogBack() { + if (catalogParent < 0) { + // Back to the choice it came from + returnToMenu = false; + closeScreen(); + openDownloadChoice(); + return; + } + const int child = catalogParent; + openCatalog(catalog.region(catalogParent).parent); + // Back on the row it came from + for (int row = 0; row < catalogList.count(); row++) { + if (catalogRows[row] == child) { + catalogList.select(row); + } + } +} + +void MapView::planCatalogRegion(int index) { + // Zoom by zoom from the lowest, as long as the whole region still fits OpenFreeMap's limits + CatalogPlan plan { index, -1, 0, 0 }; + for (int z = 0; z <= MAX_SOURCE_ZOOM; z++) { + const int64_t room = MAX_DOWNLOAD_TILES - plan.tiles; + const int64_t tiles = catalog.forEachTile(index, z, nullptr, nullptr, room); + const uint64_t bytes = static_cast(tiles) * typicalTileBytes(z); + if (tiles > room || plan.bytes + bytes > MAX_DOWNLOAD_BYTES) { + break; + } + plan.maxZoom = z; + plan.tiles += tiles; + plan.bytes += bytes; + } + char name[48]; + catalog.copyName(index, name, sizeof(name)); + if (plan.maxZoom < 0 || plan.tiles == 0) { + showToast("This region is too large for one download"); + return; + } + catalogPlan = plan; + char message[96]; + snprintf(message, sizeof(message), "%s: %d tiles, about %d MB, detail up to zoom %d. Download?", name, static_cast(plan.tiles), + static_cast((plan.bytes + 500000) / 1000000), plan.maxZoom); + dialogPurpose = DialogPurpose::CatalogDownload; + dialog.open(message, "Download", false); + dialog.layout(width, height, overlay.font()); + dirty = true; +} + +void MapView::startCatalogDownload() { + const CatalogPlan& plan = catalogPlan; + const CatalogRegion region = catalog.region(plan.region); + RegionMeta meta {}; + char name[sizeof(meta.outline)]; + catalog.copyName(plan.region, name, sizeof(name)); + if (!makeRegionFolder(name, meta)) { + showToast("Could not make a folder on the SD card"); + return; + } + setRegionTitle(meta, name); + snprintf(meta.outline, sizeof(meta.outline), "%s", name); + meta.box = region.box; + meta.minZoom = 0; + meta.maxZoom = plan.maxZoom; + meta.total = plan.tiles; + logInfo("New region %s (%s, catalog outline): %d tiles up to z%d, about %d KB", meta.name, name, static_cast(plan.tiles), plan.maxZoom, + static_cast(plan.bytes / 1024)); + if (beginRegionDownload(meta)) { + returnToMenu = false; + showRegion(meta); + screen = Screen::Map; + dirty = true; + } +} + +void MapView::handleDownloadKey(uint32_t key) { + switch (key) { + case keys::ENTER: + case keys::YES: + startDownload(); + break; + case keys::ESCAPE: + case keys::BACKSPACE: + case keys::NO: + closeScreen(); + break; + case ' ': + case 'm': + case 'M': + case 'g': + case 'G': + // Only moving and zooming the map under the frame here + break; + default: + handleMapKey(key); + break; + } + dirty = true; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/MapView.cpp b/Apps/Maps/main/Source/app/MapView.cpp new file mode 100644 index 0000000..14914dc --- /dev/null +++ b/Apps/Maps/main/Source/app/MapView.cpp @@ -0,0 +1,1854 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MapView.h" + +#include + +#include "../platform/Platform.h" +#include "../render/Compose.h" +#include "../render/PoiIconData.h" +#include "../render/PoiTypes.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace maps { + +namespace { + +/** + * 2.5 MB of rendered tiles in PSRAM, enough for a screen and its neighbours at two zoom levels. With 28 a download of + * low-zoom tiles (up to 1.2 MB each) started with only 0.5 MB of PSRAM free and failed. + */ +constexpr int TILE_CACHE_SLOTS = 20; +constexpr uint32_t LOOP_INTERVAL_MS = 15; +/** How long a +/- or double tap zoom step is animated. */ +constexpr float ZOOM_ANIMATION_MS = 220.0f; +constexpr uint32_t POINTER_HIDE_MS = 3000; +/** Idle-time updates start after this long without input, on Wi-Fi, charging or above UPDATE_MIN_BATTERY percent. */ +constexpr uint32_t UPDATE_IDLE_MS = 30000; +constexpr uint32_t POWER_CHECK_MS = 10000; +constexpr int UPDATE_MIN_BATTERY = 40; +/** The map opens at ESA ESTEC, Noordwijk, the first time, then where it was left. */ +constexpr double DEFAULT_LAT = 52.2186; +constexpr double DEFAULT_LON = 4.4206; +constexpr int DEFAULT_ZOOM = 14; +/** Prefetch and other idle work start after this long without input. */ +constexpr uint32_t IDLE_AFTER_MS = 1000; +/** Location is read on each signal of the location service, and every few seconds to notice it being switched off. */ +constexpr uint32_t LOCATION_CHECK_MS = 3000; +constexpr uint32_t TOAST_SHOW_MS = 3500; +constexpr uint32_t STATUS_POLL_MS = 500; +constexpr uint32_t MESSAGE_SHOW_MS = 4000; +/** A failed download says so longer, and tries again after a while, a few times. */ +constexpr uint32_t FAILED_MESSAGE_SHOW_MS = 10000; +constexpr uint32_t RETRY_AFTER_MS = 30000; +constexpr int MAX_RETRIES = 3; +constexpr uint32_t ENTER_RELEASE_MS = 150; +constexpr uint32_t GAP_POLL_MS = 300; +constexpr uint32_t SLOW_LOOP_MS = 150; +/** The message about missing detail comes back at most this often. */ +constexpr uint32_t GAP_HINT_MS = 30000; +constexpr uint32_t LONG_PRESS_MS = 600; +constexpr const char* NORMAL_FONTS[] = { "text_default_", "mono_mono_", "text_small_" }; +constexpr const char* SMALL_FONTS[] = { "text_small_" }; +/** Text at least 12 px high: a smaller font is only used when its lines are this tall. */ +constexpr int MIN_LINE_HEIGHT = 15; + +} // namespace + +namespace { + +/** The icon mask of each POI type, found once by name in the generated Maki masks. */ +const uint8_t* poiIcons[256] {}; +bool poiMonochrome = false; +uint16_t poiMonochromeColor = 0; + +const uint8_t* iconMask(const char* name) { + int low = 0; + int high = POI_ICON_COUNT - 1; + while (low <= high) { + const int middle = (low + high) / 2; + const int order = strcmp(POI_ICON_MASKS[middle].name, name); + if (order == 0) { + return POI_ICON_MASKS[middle].alpha; + } + if (order < 0) { + low = middle + 1; + } else { + high = middle - 1; + } + } + return nullptr; +} + +/** Icon colours by group, dark enough to read on the light map with a white halo. */ +uint16_t groupColor(PoiGroup group) { + switch (group) { + case PoiGroup::Food: return 0xC300; // orange + case PoiGroup::Shop: return 0x9113; // purple + case PoiGroup::Transport: return 0x2A5A; // blue + case PoiGroup::Health: return 0xC104; // red + case PoiGroup::Money: return 0x3A8A; // slate + case PoiGroup::Lodging: return 0x0451; // teal + case PoiGroup::Culture: return 0x8209; // plum brown + case PoiGroup::Leisure: return 0x3445; // green + case PoiGroup::Education: return 0x8A42; // brown + case PoiGroup::Worship: return 0x4208; // dark grey + default: return 0x52AA; // grey + } +} + +void preparePoiIcons(bool monochrome, uint16_t monochromeColor) { + poiMonochrome = monochrome; + poiMonochromeColor = monochromeColor; + const uint8_t* fallback = iconMask("marker"); + for (int id = 1; id <= poiTypeCount() && id < 256; id++) { + const uint8_t* mask = iconMask(poiIconName(poiType(static_cast(id)).icon)); + poiIcons[id] = mask != nullptr ? mask : fallback; + } +} + +bool lookupPoi(uint8_t id, PoiAppearance& out) { + if (id == 0 || id > poiTypeCount() || poiIcons[id] == nullptr) { + return false; + } + const PoiType& type = poiType(id); + out = { poiIcons[id], POI_ICON_SIZE, poiMonochrome ? poiMonochromeColor : groupColor(type.group), type.minZoom }; + return true; +} + +} // namespace + +bool MapView::begin(Device* display, const char* root, const char* dataPath) { + // Idle time is counted from the start, not from boot + lastInputAt = nowMs(); + snprintf(rootPath, sizeof(rootPath), "%s", root); + if (!output.begin(display)) { + logError("Display output failed"); + return false; + } + width = output.width(); + height = output.height(); + monochrome = output.isMonochrome(); + palette = monochrome ? &monochromePalette() : &colorPalette(); + theme = uiTheme(*palette, monochrome); + frame = static_cast(largeAlloc(static_cast(width) * height * sizeof(uint16_t))); + preview = static_cast(largeAlloc(TileCache::TILE_BYTES)); + scratch = static_cast(largeAlloc(sizeof(AffineScratch))); + if (frame == nullptr || preview == nullptr || scratch == nullptr) { + logError("Out of memory"); + return false; + } + canvas = { frame, width, height }; + // From here on only the I/O task touches the SD card + if (!io.start()) { + logError("I/O task not started"); + return false; + } + browsedCounter.configure(io, root); + int regionsFound = 0; + auto loadState = [&] { + char settingsPath[PATH_MAX_LENGTH]; + snprintf(settingsPath, sizeof(settingsPath), "%s/settings.txt", root); + readSettings(settingsPath, settings); + snprintf(settingsPath, sizeof(settingsPath), "%s/view.txt", root); + // The palette can be forced to monochrome, to check its contrast on a colour display + if (settings.devMonochrome && !monochrome) { + monochrome = true; + palette = &monochromePalette(); + theme = uiTheme(*palette, monochrome); + } + hasSavedView = readSavedView(settingsPath, savedView); + store.setRoot(root); + regionsFound = store.scanRegions(); + char folder[160]; + snprintf(folder, sizeof(folder), "%s/cache/fonts", dataPath); + if (!normalFont.borrow(FONT_SIZE_DEFAULT) && !normalFont.open(folder, NORMAL_FONTS, 3)) { + logError("No font from the system or in %s, the map shows no text", folder); + } + if (!smallFont.borrow(FONT_SIZE_SMALL)) { + smallFont.open(folder, SMALL_FONTS, 1); + } + largeFont.borrow(FONT_SIZE_LARGE); + // The label fonts made on an earlier run; the ones missing are made in the background after the labels start + for (int i = 0; i < LabelFontMaker::COUNT; i++) { + char fontPath[200]; + LabelFontMaker::cachePath(root, i, fontPath, sizeof(fontPath)); + if (!fileExists(fontPath) || !labelFonts[i].openFile(fontPath)) { + missingLabelFonts |= 1u << i; + } + } + script.open(root); + return true; + }; + runOnCard(io, loadState); + logInfo("Map root %s, %d regions", root, regionsFound); + loadWaypoints(); + // Known before any download resumes: a region with a catalog outline needs it for its tile list + app_paths_get_assets_path(APP_ID, "regions.bin", catalogPath, sizeof(catalogPath)); + downloader.setCatalogPath(catalogPath); + TrackRecorder::closeUnfinished(io, rootPath); + // GPX files are dropped into gpx/ at the top of the data volume (the SD card), where users find it + char volume[PATH_MAX_LENGTH] = ""; + if (paths_get_data_root_path(volume, sizeof(volume)) == ERROR_NONE) { + snprintf(gpxFolder, sizeof(gpxFolder), "%s/gpx", volume); + } else { + const char* second = strchr(rootPath + 1, '/'); + snprintf(gpxFolder, sizeof(gpxFolder), "%.*s/gpx", second != nullptr ? static_cast(second - rootPath) : 0, rootPath); + } + logInfo("GPX folder %s", gpxFolder); + loadShownGpx(); + overlay.setFonts(&normalFont, smallFont.isOpen() && smallFont.lineHeight() >= MIN_LINE_HEIGHT ? &smallFont : &normalFont); + worker.setDeflatePrepared(settings.deflatePrepared); + worker.setSdCache(settings.sdCache); + // Labels: sections copied by the render lanes, placed by the map loop; without memory the map has none + labelsReady = labelStore.begin() && labels.begin(largeAlloc, largeFree); + if (labelsReady) { + worker.setLabelStore(&labelStore); + labels.setClock([] { return static_cast(esp_timer_get_time()); }); + applyLabelFonts(); + applyLabelSettings(); + } else { + logError("No memory for labels"); + } + if (labelsReady && missingLabelFonts != 0) { + char ttfPath[260]; + app_paths_get_assets_path(APP_ID, "fonts/LabelSans.ttf", ttfPath, sizeof(ttfPath)); + fontMaker.configure(io, root, ttfPath); + fontMaker.start(missingLabelFonts); + } + if (!worker.start(&store, &io, palette, TILE_CACHE_SLOTS, root)) { + return false; + } + input.begin(); + pointerAcceleration.setSpeed(settings.pointerSpeed); + cursorX = width / 2; + cursorY = height / 2; + // The overlay adapts to the inputs the board has: key hints with a keyboard, focus without touch or pointer + controls.setInputs({ input.hasKeyboard(), input.hasTouch(), input.hasPointer() }); + layoutControls(); + downloadView = downloadLayout(width, height, overlay.font().lineHeight()); + downloader.setTiming(settings.devProbe); + downloader.setMemoryRelief([](void* context, size_t bytes) { return static_cast(context)->relieveMemory(bytes); }, &worker); + if (!downloader.start(root, &store, &io)) { + logError("Downloader not started"); + } + routeProfile = static_cast(std::clamp(settings.routeProfile, 0, 2)); + navigatorStarted = navigator.start(io, store, worker, root); + if (!navigatorStarted) { + logError("Route search not started"); + } + probe.watch(xTaskGetCurrentTaskHandle(), "main"); + probe.watch(worker.laneTask(0), "lane0"); + probe.watch(worker.laneTask(1), "lane1"); + probe.watch(io.taskHandle(), "io"); + probe.watch(downloader.controlTaskHandle(), "download"); + probe.watch(downloader.browseTaskHandle(), "browse"); + if (!(settings.devProbe ? probe.start() : probe.startWatchdog())) { + logError("Stall probes not started"); + } + + if (hasSavedView) { + centerOn(savedView.lat, savedView.lon, savedView.zoom); + } else { + centerOn(DEFAULT_LAT, DEFAULT_LON, DEFAULT_ZOOM); + } + viewReady = true; + + task_event_group_construct(&eventGroup); + eventGroupReady = true; + subscribed = app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE; + location.subscribe(&eventGroup); + readLocation(); + attribution.expand(nowMs()); + logMemory("map ready"); + return true; +} + +void MapView::end() { + if (viewReady) { + saveView(); + viewReady = false; + } + // The track file gets its end tags; recording only runs while the app is open + recorder.stop(io, nowMs()); + for (int wait = 0; gpxImport.state() == GpxImport::State::Busy && wait < 500; wait++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + gpxImport.acknowledge(); + for (int i = 0; i < gpxFileCount; i++) { + if (gpxFiles[i].layer != nullptr) { + gpxFiles[i].layer->~GpxLayer(); + largeFree(gpxFiles[i].layer); + gpxFiles[i].layer = nullptr; + } + } + fontMaker.stop(); + browsedCounter.stop(); + downloader.stop(); + if (navigatorStarted) { + navigator.stop(); + navigatorStarted = false; + } + worker.stop(); + io.stop(); + probe.stop(); + location.unsubscribe(); + if (subscribed) { + app_event_unsubscribe(&appEvents); + subscribed = false; + } + if (eventGroupReady) { + task_event_group_destruct(&eventGroup); + eventGroupReady = false; + } + input.end(); + labels.end(); + labelStore.end(); + normalFont.close(); + smallFont.close(); + largeFont.close(); + for (BinFontText& font : labelFonts) { + font.close(); + } + output.end(); + largeFree(frame); + frame = nullptr; + largeFree(preview); + preview = nullptr; + largeFree(scratch); + scratch = nullptr; + largeFree(aboutBuffer); + aboutBuffer = nullptr; + for (int i = 0; i < squeezedCount; i++) { + largeFree(squeezed[i]); + } + squeezedCount = 0; +} + +void MapView::squeezeMemory(size_t largest) { + // Blocks of 64 KB are taken until no free block is as large as asked, then every other one is given back: much + // PSRAM stays free, but only in small pieces, like after a long session + constexpr size_t BLOCK = 64 * 1024; + int taken = 0; + void* blocks[MAX_SQUEEZED * 2]; + while (taken < MAX_SQUEEZED * 2 && heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) >= largest) { + void* block = largeAlloc(BLOCK); + if (block == nullptr) { + break; + } + blocks[taken++] = block; + } + for (int i = 0; i < taken; i++) { + if (i % 2 == 0 || squeezedCount >= MAX_SQUEEZED) { + largeFree(blocks[i]); + } else { + squeezed[squeezedCount++] = blocks[i]; + } + } + logInfo("debug: squeeze: %d blocks of 64 KB held, PSRAM %u KB free, largest block %u KB", squeezedCount, + static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024)); +} + +void MapView::centerOn(double lat, double lon, int newZoom) { + zoom = newZoom; + centerX = lonToWorldX(lon, zoom); + centerY = latToWorldY(lat, zoom); + dirty = true; + updateWanted(); +} + +void MapView::layoutControls() { + controls.layout(width, height, overlay.scaleBarTop(canvas), navigating ? navBar.height(sheetFont()) : 0); +} + +void MapView::centerOnPosition() { + const PositionFix& fix = position.fix(); + zoom = std::clamp(zoom, 0, MAX_VIEW_ZOOM); + centerX = lonToWorldX(fix.longitude, zoom); + centerY = latToWorldY(fix.latitude, zoom); + if (navigating) { + // The position at NAVIGATION_POSITION_Y of the height: the screen centre is that far ahead of it, in the + // direction that is up on the (turned) screen + double aheadX; + double aheadY; + screenToWorldOffset(0.0, height / 2.0 - height * NAVIGATION_POSITION_Y, aheadX, aheadY); + const double worldSize = std::ldexp(static_cast(TILE_SIZE), zoom); + centerX = std::fmod(centerX + aheadX + worldSize, worldSize); + centerY = std::fmin(std::fmax(centerY + aheadY, 0.0), worldSize); + } + dirty = true; + updateWanted(); +} + +void MapView::setZoom(int newZoom, int anchorX, int anchorY) { + if (newZoom < 0 || newZoom > MAX_VIEW_ZOOM || newZoom == zoom) { + return; + } + // The world point under the anchor stays where it is on the screen + double offsetX; + double offsetY; + screenToWorldOffset(anchorX - width / 2.0, anchorY - height / 2.0, offsetX, offsetY); + const double scale = std::ldexp(1.0, newZoom - zoom); + centerX = (centerX + offsetX) * scale - offsetX; + centerY = (centerY + offsetY) * scale - offsetY; + zoom = newZoom; + dirty = true; + updateWanted(); +} + +void MapView::zoomBy(int steps, int anchorX, int anchorY) { + const int target = std::min(std::max(zoom + steps, 0), MAX_VIEW_ZOOM); + if (target == zoom) { + return; + } + if (!smoothZoom()) { + animation.active = false; + io.hold(false); + setZoom(target, anchorX, anchorY); + return; + } + // Where the animation shows the map now: the current scale, or 1 when none runs. A second press during an + // animation starts from what is on screen. + const uint32_t now = nowMs(); + float scale = 1.0f; + double anchorWorldX; + double anchorWorldY; + screenToWorldOffset(anchorX - width / 2.0, anchorY - height / 2.0, anchorWorldX, anchorWorldY); + double worldX = centerX + anchorWorldX; + double worldY = centerY + anchorWorldY; + if (animation.active) { + const float t = std::min(1.0f, static_cast(now - animation.startMs) / ZOOM_ANIMATION_MS); + const float eased = 1.0f - (1.0f - t) * (1.0f - t); + scale = powf(animation.fromScale, 1.0f - eased); + double movedX; + double movedY; + screenToWorldOffset((anchorX - animation.anchorX) / scale, (anchorY - animation.anchorY) / scale, movedX, movedY); + worldX = animation.worldX + movedX; + worldY = animation.worldY + movedY; + } + // The world point under the anchor stays there through the animation and after it + const double factor = std::ldexp(1.0, target - zoom); + const double worldSize = std::ldexp(static_cast(TILE_SIZE), target); + worldX *= factor; + worldY *= factor; + centerX = std::fmod(worldX - anchorWorldX + worldSize, worldSize); + centerY = std::fmin(std::fmax(worldY - anchorWorldY, 0.0), worldSize); + animation.active = true; + io.hold(true); + animation.startMs = now; + animation.fromScale = scale / static_cast(factor); + animation.anchorX = static_cast(anchorX); + animation.anchorY = static_cast(anchorY); + animation.worldX = worldX; + animation.worldY = worldY; + animation.frames = 0; + animation.composeMs = 0; + animation.slowestFrameMs = 0; + animation.slowestLockMs = 0; + animation.slowestComposeMs = 0; + zoom = target; + dirty = true; + // The tiles of the new level are asked for when the animation ends: their reads from the card hold the SPI bus + // the T-Deck's display shares, which stalled animation frames by up to 240 ms +} + +void MapView::screenToWorldOffset(double sx, double sy, double& wx, double& wy) const { + wx = bearingCos * sx - bearingSin * sy; + wy = bearingSin * sx + bearingCos * sy; +} + +void MapView::worldToScreenOffset(double wx, double wy, double& sx, double& sy) const { + sx = bearingCos * wx + bearingSin * wy; + sy = -bearingSin * wx + bearingCos * wy; +} + +void MapView::viewBounds(double& left, double& top, double& right, double& bottom) const { + // The world box around the four turned screen corners + const double halfWidth = width / 2.0; + const double halfHeight = height / 2.0; + const double spanX = std::fabs(bearingCos) * halfWidth + std::fabs(bearingSin) * halfHeight; + const double spanY = std::fabs(bearingSin) * halfWidth + std::fabs(bearingCos) * halfHeight; + left = centerX - spanX; + right = centerX + spanX; + top = centerY - spanY; + bottom = centerY + spanY; +} + +void MapView::applyBearing(float degrees, bool want) { + degrees -= 360.0f * std::floor(degrees / 360.0f); + // Within a tenth of a degree of north is north, so the fast unturned drawing comes back + if (degrees < 0.1f || degrees > 359.9f) { + degrees = 0.0f; + } + if (degrees == bearing) { + return; + } + bearing = degrees; + const float radians = degrees * 0.017453292f; + bearingCos = degrees == 0.0f ? 1.0f : std::cos(radians); + bearingSin = degrees == 0.0f ? 0.0f : std::sin(radians); + dirty = true; + if (want) { + updateWanted(); + } +} + +Rect MapView::compassRect() const { + return { width - 6 - 30, 6, 30, 30 }; +} + +void MapView::drawCompass() { + // A needle to north, red half towards it, in a round panel; only on a turned map + const Rect r = compassRect(); + const float cx = r.x + r.width / 2.0f; + const float cy = r.y + r.height / 2.0f; + float shown = bearing; + if (turn.active) { + const float t = std::min(1.0f, static_cast(nowMs() - turn.startMs) / TURN_ANIMATION_MS); + shown = turn.from + (turn.to - turn.from) * (1.0f - (1.0f - t) * (1.0f - t)); + } + const float radians = shown * 0.017453292f; + // North on screen is the world's up turned back by the bearing + const float northX = -std::sin(radians); + const float northY = -std::cos(radians); + canvas.fillCircle(cx, cy, 14.0f, theme.panel); + canvas.ring(cx, cy, 14.0f, 1.5f, theme.text); + canvas.line(cx, cy, cx + northX * 10.0f, cy + northY * 10.0f, 3.0f, monochrome ? theme.text : static_cast(0xD8A3)); + canvas.line(cx, cy, cx - northX * 10.0f, cy - northY * 10.0f, 3.0f, monochrome ? theme.panel : theme.text); + canvas.fillCircle(cx, cy, 2.0f, theme.text); +} + +void MapView::setBearing(float degrees, bool animate) { + // A turn already running continues from where it shows the map now + float current = bearing; + if (turn.active) { + const float t = std::min(1.0f, static_cast(nowMs() - turn.startMs) / TURN_ANIMATION_MS); + current = turn.from + (turn.to - turn.from) * (1.0f - (1.0f - t) * (1.0f - t)); + turn.active = false; + io.hold(false); + } + if (!animate || !smoothZoom()) { + applyBearing(degrees); + return; + } + // The shorter way round + float target = degrees - 360.0f * std::floor(degrees / 360.0f); + float delta = target - current; + delta -= 360.0f * std::floor((delta + 180.0f) / 360.0f); + if (std::fabs(delta) < 0.1f) { + applyBearing(target); + return; + } + turn.active = true; + // Card work waits during the turn, its reads would hold the display's shared bus + io.hold(true); + turn.from = current; + turn.to = current + delta; + turn.startMs = nowMs(); + turn.frames = 0; + turn.composeMs = 0; + dirty = true; +} + +void MapView::pan(double dx, double dy) { + if (dx == 0.0 && dy == 0.0) { + return; + } + following = false; + headingUp = false; + double worldDx; + double worldDy; + screenToWorldOffset(dx, dy, worldDx, worldDy); + const double worldSize = std::ldexp(static_cast(TILE_SIZE), zoom); + centerX = std::fmod(centerX + worldDx + worldSize, worldSize); + centerY = std::fmin(std::fmax(centerY + worldDy, 0.0), worldSize); + dirty = true; + updateWanted(); +} + +void MapView::updateWanted() { + // The visible tiles first, nearest to the centre first, then a ring around them; a turned view needs the tiles + // of the box around it (up to its 400 px diagonal on the T-Deck) + double left; + double top; + double right; + double bottom; + viewBounds(left, top, right, bottom); + const int firstX = static_cast(std::floor(left / TILE_SIZE)) - 1; + const int firstY = static_cast(std::floor(top / TILE_SIZE)) - 1; + const int lastX = static_cast(std::floor((right - 1) / TILE_SIZE)) + 1; + const int lastY = static_cast(std::floor((bottom - 1) / TILE_SIZE)) + 1; + TileKey tiles[TileWorker::MAX_WANTED]; + double distances[TileWorker::MAX_WANTED]; + int count = 0; + const int tileCount = 1 << zoom; + for (int y = firstY; y <= lastY; y++) { + if (y < 0 || y >= tileCount) { + continue; + } + for (int x = firstX; x <= lastX && count < TileWorker::MAX_WANTED; x++) { + const double tileCenterX = (x + 0.5) * TILE_SIZE; + const double tileCenterY = (y + 0.5) * TILE_SIZE; + const bool visible = x > firstX && x < lastX && y > firstY && y < lastY; + double distance = std::sqrt((tileCenterX - centerX) * (tileCenterX - centerX) + (tileCenterY - centerY) * (tileCenterY - centerY)); + if (!visible) { + distance += 1e6; + } + int slot = count; + while (slot > 0 && distances[slot - 1] > distance) { + tiles[slot] = tiles[slot - 1]; + distances[slot] = distances[slot - 1]; + slot--; + } + tiles[slot] = { zoom, wrapTileX(x, zoom), y }; + distances[slot] = distance; + count++; + } + } + int visibleCount = 0; + while (visibleCount < count && distances[visibleCount] < 1e6) { + visibleCount++; + } + worker.want(tiles, count, visibleCount); + // Detail asked for an earlier view is not fetched any more once the view has moved on from it + const int sourceZoom = std::min(zoom, MAX_SOURCE_ZOOM); + const int shift = zoom - sourceZoom; + // A view as wide as the world keeps every column + const bool allColumns = lastX - firstX + 1 >= tileCount; + downloader.keepBrowse(sourceZoom, allColumns ? 0 : wrapTileX(firstX, zoom) >> shift, std::max(firstY, 0) >> shift, + allColumns ? (1 << sourceZoom) - 1 : wrapTileX(lastX, zoom) >> shift, std::min(lastY, tileCount - 1) >> shift); + downloader.setView({ worldXToLon(left, zoom), worldYToLat(bottom, zoom), worldXToLon(right, zoom), worldYToLat(top, zoom) }); + viewChangedAt = nowMs(); + viewTimed = false; +} + +const uint16_t* MapView::lookupTile(const TileKey& tile, void* context) { + return static_cast(context)->findTile(tile); +} + +const uint16_t* MapView::lookupCached(const TileKey& tile, void* context) { + return static_cast(context)->worker.cache().find(tile); +} + +const uint16_t* MapView::findTile(const TileKey& tile) { + TileCache& cache = worker.cache(); + if (const uint16_t* pixels = cache.find(tile)) { + return pixels; + } + // A magnified part of a cached ancestor stands in until the tile is ready + for (int level = 1; level <= 4 && tile.z - level >= 0; level++) { + const TileKey parent = ancestorOf(tile, tile.z - level); + const uint16_t* pixels = cache.find(parent); + if (pixels == nullptr) { + continue; + } + const int size = TILE_SIZE >> level; + const int originX = (tile.x - (parent.x << level)) * size; + const int originY = (tile.y - (parent.y << level)) * size; + for (int y = 0; y < TILE_SIZE; y++) { + const uint16_t* source = pixels + (originY + (y >> level)) * TILE_SIZE + originX; + uint16_t* target = preview + y * TILE_SIZE; + for (int x = 0; x < TILE_SIZE; x++) { + target[x] = source[x >> level]; + } + } + return preview; + } + // After zooming out, the finer tiles that were just on screen stand in, shrunk, instead of an empty screen + for (int level = 1; level <= 2 && tile.z + level <= MAX_VIEW_ZOOM; level++) { + const int span = 1 << level; + const int size = TILE_SIZE >> level; + bool any = false; + for (int childY = 0; childY < span; childY++) { + for (int childX = 0; childX < span; childX++) { + const TileKey child { tile.z + level, (tile.x << level) + childX, (tile.y << level) + childY }; + const uint16_t* pixels = cache.find(child); + for (int y = 0; y < size; y++) { + uint16_t* target = preview + (childY * size + y) * TILE_SIZE + childX * size; + if (pixels == nullptr) { + for (int x = 0; x < size; x++) { + target[x] = palette->placeholder; + } + continue; + } + // Average of two pixels on a row: halves aliasing for little work + const uint16_t* source = pixels + (y << level) * TILE_SIZE; + for (int x = 0; x < size; x++) { + const uint16_t a = source[x << level]; + const uint16_t b = source[(x << level) + 1]; + target[x] = static_cast(((a & 0xF7DE) >> 1) + ((b & 0xF7DE) >> 1) + (a & b & 0x0821)); + } + } + any = any || pixels != nullptr; + } + } + if (any) { + return preview; + } + } + return nullptr; +} + +bool MapView::isViewComplete() { + double left; + double top; + double right; + double bottom; + viewBounds(left, top, right, bottom); + const int tileCount = 1 << zoom; + worker.lock(); + bool complete = true; + for (int y = static_cast(std::floor(top / TILE_SIZE)); y <= static_cast(std::floor((bottom - 1) / TILE_SIZE)) && complete; y++) { + for (int x = static_cast(std::floor(left / TILE_SIZE)); x <= static_cast(std::floor((right - 1) / TILE_SIZE)) && complete; x++) { + if (y < 0 || y >= tileCount) { + continue; + } + const TileKey tile = { zoom, wrapTileX(x, zoom), y }; + complete = worker.isSettled(tile); + } + } + worker.unlock(); + return complete; +} + +bool MapView::markerScreenPosition(int& x, int& y) const { + if (!position.hasFix()) { + return false; + } + const double worldSize = std::ldexp(static_cast(TILE_SIZE), zoom); + double dx = lonToWorldX(position.fix().longitude, zoom) - centerX; + if (dx > worldSize / 2) { + dx -= worldSize; + } else if (dx < -worldSize / 2) { + dx += worldSize; + } + double sx; + double sy; + worldToScreenOffset(dx, latToWorldY(position.fix().latitude, zoom) - centerY, sx, sy); + x = static_cast(std::floor(width / 2.0 + sx + 0.5)); + y = static_cast(std::floor(height / 2.0 + sy + 0.5)); + // Far outside the screen only the accuracy circle can still show + const int reach = 4 * (width + height); + return x > -reach && y > -reach && x < width + reach && y < height + reach; +} + +void MapView::drawStatus() { + char text[96]; + const Downloader::Status& status = lastStatus; + pillRect = {}; + statusShown = false; + bool progress = true; + if (status.state == Downloader::State::Downloading && status.total > 0) { + formatProgressPill(TransferKind::Download, statusTitle, status.done, status.total, text, sizeof(text)); + } else if (status.state == Downloader::State::Updating && status.total > 0) { + formatProgressPill(TransferKind::Update, statusTitle, status.done, status.total, text, sizeof(text)); + } else if (downloader.isBrowsing() && browseTotal > 0) { + formatProgressPill(TransferKind::Browse, "", std::min(browseDone + 1, browseTotal), browseTotal, text, sizeof(text)); + } else if (downloader.isBrowsing()) { + snprintf(text, sizeof(text), "Downloading map"); + } else if (status.state == Downloader::State::Connecting || status.state == Downloader::State::Deleting) { + snprintf(text, sizeof(text), "%s", status.message); + } else if (static_cast(nowMs() - messageUntil) < 0) { + snprintf(text, sizeof(text), "%s", status.message); + } else if (store.regionCount() == 0) { + snprintf(text, sizeof(text), "No maps yet: Menu, Download map"); + progress = false; + } else { + return; + } + const Rect rect = overlay.drawPill(canvas, text, *palette); + statusShown = true; + if (progress) { + pillRect = rect; + } +} + +void MapView::openDownloadCard() { + downloadCard.open(); + fillDownloadCard(); + controls.hide(); + dirty = true; +} + +void MapView::fillDownloadCard() { + const Downloader::Status& status = lastStatus; + const bool running = status.state == Downloader::State::Downloading || status.state == Downloader::State::Updating || + status.state == Downloader::State::Connecting; + char lines[5][64]; + const char* rows[5]; + int count = 0; + auto add = [&](const char* format, auto... values) { + snprintf(lines[count], sizeof(lines[count]), format, values...); + rows[count] = lines[count]; + count++; + }; + if (running && status.total > 0) { + add("%s %d%%, %d of %d tiles", status.state == Downloader::State::Updating ? "Updating" : "Downloading", + static_cast(status.done * 100 / status.total), static_cast(status.done), static_cast(status.total)); + // The size so far against an estimate from the tiles done + char done[16]; + char estimate[16]; + formatBytes(status.bytes, done, sizeof(done)); + const uint64_t total = status.done > 0 ? status.bytes * static_cast(status.total) / static_cast(status.done) : 0; + formatBytes(total, estimate, sizeof(estimate)); + add("%s of about %s", done, status.done > 0 ? estimate : "?"); + const double rate = transferRate.bytesPerSecond(); + if (rate > 0.0) { + char speed[16]; + formatBytes(static_cast(rate), speed, sizeof(speed)); + const int64_t left = transferRate.secondsLeft(status.bytes, total); + char remaining[24] = ""; + if (left >= 0) { + formatDuration(left, remaining, sizeof(remaining)); + } + add("%s/s%s%s", speed, left >= 0 ? ", " : "", left >= 0 ? remaining : ""); + if (left >= 0) { + snprintf(lines[count - 1] + strlen(lines[count - 1]), sizeof(lines[0]) - strlen(lines[count - 1]), " left"); + } + } else { + add("Measuring the speed..."); + } + } else if (status.state == Downloader::State::Connecting) { + add("%s", status.message); + } else if (downloader.isBrowsing() && browseTotal > 0) { + add("Fetching detail for the screen:"); + add("%d of %d tiles", std::min(browseDone + 1, browseTotal), browseTotal); + add("They are kept in Downloaded maps,"); + add("region \"browsed\"."); + } else if (pausedRegion[0] != '\0') { + add("Paused. Resume continues where it stopped."); + } else { + add("%s", status.message[0] != '\0' ? status.message : "Nothing is downloading."); + } + // Tiles fetched while browsing can't be paused or cancelled from the card, only the card closed + const bool browsing = !running && status.state != Downloader::State::Connecting && downloader.isBrowsing() && browseTotal > 0; + const char* title = browsing ? "Map detail" : statusTitle[0] != '\0' ? statusTitle : status.region; + downloadCard.set(title, rows, count, !running, browsing); + downloadCard.layout(width, height, overlay.font()); +} + +void MapView::handleCardButton(DownloadCard::Button button) { + dirty = true; + switch (button) { + case DownloadCard::Button::PauseResume: { + const Downloader::Status status = downloader.status(); + const bool running = status.state == Downloader::State::Downloading || status.state == Downloader::State::Connecting; + if (running) { + snprintf(pausedRegion, sizeof(pausedRegion), "%s", status.region); + downloader.cancel(); + showToast("Download paused"); + } else if (status.state == Downloader::State::Updating) { + downloader.cancel(); + showToast("Update paused, it continues in idle time"); + } else if (pausedRegion[0] != '\0') { + downloader.download(pausedRegion); + pausedRegion[0] = '\0'; + showToast("Download continues"); + } + downloadCard.close(); + break; + } + case DownloadCard::Button::Cancel: { + // Cancelling a download deletes the part already fetched, after asking + const Downloader::Status status = downloader.status(); + const char* region = status.region[0] != '\0' ? status.region : pausedRegion; + downloadCard.close(); + if (region[0] == '\0' || status.state == Downloader::State::Updating) { + downloader.cancel(); + break; + } + snprintf(cancelRegion, sizeof(cancelRegion), "%s", region); + char message[96]; + snprintf(message, sizeof(message), "Stop and delete the part of %s downloaded so far?", statusTitle[0] != '\0' ? statusTitle : region); + dialogPurpose = DialogPurpose::CancelDownload; + dialog.open(message, "Delete", true); + dialog.layout(width, height, overlay.font()); + break; + } + case DownloadCard::Button::Close: + downloadCard.close(); + break; + default: + break; + } +} + +void MapView::pollDownloader(uint32_t now) { + TileKey arrived[Downloader::MAX_ARRIVED]; + int arrivedCount = 0; + if (downloader.takeTilesChanged(arrived, arrivedCount)) { + worker.tilesArrived(arrived, arrivedCount); + dirty = true; + } + if (!resumeChecked) { + // A download that was interrupted continues when the map opens again + resumeChecked = true; + int unfinished = -1; + RegionMeta meta {}; + auto find = [&] { + for (int i = 0; i < store.regionCount() && unfinished < 0; i++) { + if (strcmp(store.regionName(i), pausedRegion) == 0) { + continue; + } + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/meta.json", rootPath, store.regionName(i)); + if (readRegionMeta(path, meta) && !meta.isComplete()) { + unfinished = i; + } + } + return true; + }; + runOnCard(io, find, IoPriority::Background); + if (unfinished >= 0) { + logInfo("Continuing the download of %s at tile %d of %d", store.regionName(unfinished), static_cast(meta.done), static_cast(meta.total)); + downloader.download(store.regionName(unfinished)); + } + } + if (retryAt != 0 && static_cast(now - retryAt) >= 0) { + retryAt = 0; + resumeChecked = false; + } + if (now - lastStatusPoll < STATUS_POLL_MS) { + return; + } + lastStatusPoll = now; + const Downloader::Status status = downloader.status(); + // Tile cache slots given to a large download come back once nothing downloads + if (status.state != Downloader::State::Connecting && status.state != Downloader::State::Downloading && + status.state != Downloader::State::Updating && !downloader.isBrowsing() && worker.cacheShrunk() && !worker.isPreparing() && + navigator.state() != Navigator::State::Searching) { + worker.restoreMemory(); + } + downloader.browseProgress(browseDone, browseTotal); + if (status.region[0] != '\0' && strcmp(status.region, statusTitleOf) != 0) { + // The title users gave the region, not its short folder name + snprintf(statusTitleOf, sizeof(statusTitleOf), "%s", status.region); + snprintf(statusTitle, sizeof(statusTitle), "%s", status.region); + RegionMeta meta {}; + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/meta.json", rootPath, status.region); + auto read = [&] { return readRegionMeta(path, meta); }; + if (runOnCard(io, read, IoPriority::Background)) { + snprintf(statusTitle, sizeof(statusTitle), "%s", regionTitle(meta)); + } + } + if (status.state == Downloader::State::Downloading || status.state == Downloader::State::Updating) { + transferRate.add(now, status.bytes); + } else { + transferRate.reset(); + } + // The pill follows browsing too, and goes when it ends + const bool browsingNow = downloader.isBrowsing(); + if (browsingNow != lastBrowsing || browseDone != lastBrowseDone || browseTotal != lastBrowseTotal) { + lastBrowsing = browsingNow; + lastBrowseDone = browseDone; + lastBrowseTotal = browseTotal; + dirty = true; + } + worker.setConversionsPaused(status.state == Downloader::State::Connecting || status.state == Downloader::State::Downloading || + status.state == Downloader::State::Updating); + if (status.state != lastStatus.state || status.done != lastStatus.done || strcmp(status.message, lastStatus.message) != 0) { + if (status.state != lastStatus.state && (status.state == Downloader::State::Done || status.state == Downloader::State::Failed)) { + messageUntil = now + (status.state == Downloader::State::Failed ? FAILED_MESSAGE_SHOW_MS : MESSAGE_SHOW_MS); + } + // A download that stopped on an error (the network, or memory) continues by itself a little later + if (status.state == Downloader::State::Failed && (lastStatus.state == Downloader::State::Downloading || + lastStatus.state == Downloader::State::Connecting) && strstr(status.message, "limit") == nullptr) { + if (retries < MAX_RETRIES) { + retries++; + retryAt = now + RETRY_AFTER_MS; + logInfo("Download of %s failed, trying again in %u s (%d of %d)", status.region, RETRY_AFTER_MS / 1000, retries, MAX_RETRIES); + } + } else if (status.state == Downloader::State::Done && lastStatus.state == Downloader::State::Downloading) { + retries = 0; + } + // After a finished download the next unfinished region continues, but not one paused on purpose + if (status.state == Downloader::State::Done && lastStatus.state == Downloader::State::Downloading) { + resumeChecked = false; + } + dirty = true; + // The Regions screen shows progress as it happens + if (screen == Screen::Regions && status.state == Downloader::State::Done) { + lastStatus = status; + openRegions(); + } + } + lastStatus = status; + if (downloadCard.isOpen()) { + fillDownloadCard(); + dirty = true; + } + if (static_cast(now - messageUntil) >= 0 && messageUntil != 0) { + messageUntil = 0; + dirty = true; + } +} + +void MapView::pollGaps(uint32_t now) { + if (now - lastGapPoll < GAP_POLL_MS) { + return; + } + lastGapPoll = now; + TileKey gaps[8]; + const int count = worker.takeGaps(gaps, 8); + if (count == 0) { + return; + } + if (settings.browseDownload && Downloader::isOnline()) { + downloader.browse(gaps, count); + dirty = true; + return; + } + if (lastGapHint != 0 && now - lastGapHint < GAP_HINT_MS) { + return; + } + lastGapHint = now; + if (!settings.browseDownload) { + showToast("Less detail here. Download this area for more."); + } else { + showToast("Offline: no detail here. Download this area on Wi-Fi."); + } +} + +Rect MapView::attributionMark() const { + if (screen == Screen::Download) { + return downloadView.mark; + } + return { 0, height - MapOverlay::MARK_SIZE, MapOverlay::MARK_SIZE, MapOverlay::MARK_SIZE }; +} + +PositionButton MapView::positionButton() const { + if (!position.hasFix()) { + return PositionButton::Unavailable; + } + if (!following) { + return PositionButton::Free; + } + return headingUp ? PositionButton::FollowingHeading : PositionButton::Following; +} + +void MapView::drawMap() { + const uint32_t start = nowMs(); + probe.phase("map lock"); + worker.lock(); + const uint32_t locked = nowMs(); + probe.phase("compose"); + if (isRotated()) { + // Turned: every pixel through the transform, tiles not ready stood in for by cached ancestors or children; + // nearest pixel while it moves, bilinear once it settled + const bool bilinear = isSmoothView(); + composeAffine(frame, width, height, scaledView(centerX, centerY, zoom, 1.0f, width / 2.0f, height / 2.0f, width, height, bearingCos, bearingSin), + lookupCached, this, palette->placeholder, *scratch, bilinear); + if (bilinear && !smooth.logged) { + smooth.logged = true; + logInfo("Bilinear frame %u ms (z%d, %d degrees)", static_cast(nowMs() - locked), zoom, static_cast(bearing)); + } + } else { + composeView(frame, width, height, centerX, centerY, zoom, lookupTile, this, palette->placeholder); + } + worker.unlock(); + frameLockMs = locked - start; + frameComposeMs = nowMs() - locked; + drawRoute(); + // Labels placed for this zoom and bearing move with the pixels while panning, until they are placed again + if (labelsReady && labelZoom == zoom && labelBearing == bearing && labels.count() > 0) { + double moveX; + double moveY; + worldToScreenOffset(labelCenterX - centerX, labelCenterY - centerY, moveX, moveY); + const auto offsetX = static_cast(std::floor(moveX + 0.5)); + const auto offsetY = static_cast(std::floor(moveY + 0.5)); + probe.phase("label draw"); + labels.draw(canvas, offsetX, offsetY, true); + probe.phase("overlays"); + } + int markerX; + int markerY; + drawPlaces(); + if (markerScreenPosition(markerX, markerY)) { + if (position.state() == PositionState::Approximate) { + overlay.drawApproximateMarker(canvas, markerX, markerY, position.fix().accuracy, *palette); + } else { + const float radius = position.accuracyRadius(metresPerPixel(position.fix().latitude, zoom)); + overlay.drawAccuracyCircle(canvas, markerX, markerY, radius, *palette); + overlay.drawMarker(canvas, markerX, markerY, position.showsArrow(), position.fix().course - bearing, *palette); + } + } +} + +void MapView::applyLabelFonts() { + LabelFont* small = labelFonts[0].isOpen() ? &labelFonts[0] : (smallFont.isOpen() ? &smallFont : &normalFont); + LabelFont* normal = labelFonts[1].isOpen() ? &labelFonts[1] : static_cast(&normalFont); + LabelFont* large = labelFonts[2].isOpen() ? &labelFonts[2] : (largeFont.isOpen() ? &largeFont : normal); + labels.setFonts(small, normal, large); +} + +void MapView::pollLabelFonts() { + // Only the map loop touches the label layout: the maker's task just reports, and the files are opened here + if (labelFontsApplied || !fontMaker.isFinished()) { + return; + } + labelFontsApplied = true; + fontMaker.stop(); + const unsigned written = fontMaker.writtenMask(); + auto openWritten = [&] { + for (int i = 0; i < LabelFontMaker::COUNT; i++) { + char fontPath[200]; + LabelFontMaker::cachePath(rootPath, i, fontPath, sizeof(fontPath)); + if ((written & (1u << i)) != 0 && !labelFonts[i].openFile(fontPath)) { + logError("Label font %s does not open, the map keeps the system font", fontPath); + } + } + return true; + }; + runOnCard(io, openWritten); + applyLabelFonts(); + labelZoom = -1; +} + +void MapView::applyLabelSettings() { + labels.setDensity(static_cast(std::clamp(settings.labels, 0, 2))); + preparePoiIcons(monochrome, palette->text); + labels.setPoiLookup(lookupPoi); + labels.setPreferLatin(settings.latinNames); + const bool binary = palette->binary; + // Dark names with a light halo, water in blue, peaks in brown; black on white on monochrome displays + labels.setStyle({ monochrome ? palette->text : static_cast(0x2124), monochrome ? palette->text : static_cast(0x4208), + monochrome ? palette->text : static_cast(0x2A35), monochrome ? palette->text : static_cast(0x6A04), + monochrome ? palette->halo : static_cast(0xFFFF), binary, monochrome ? palette->text : static_cast(0x7BEF) }); + labelZoom = -1; +} + +void MapView::placeLabels() { + const uint32_t start = nowMs(); + labels.beginView(width, height, zoom); + // House numbers show from z17 (z18 with few labels): only then are they read from the label files + labelStore.setNumbersWanted(zoom >= 17 + (settings.labels == static_cast(LabelDensity::Few) ? 1 : 0)); + double left; + double top; + double right; + double bottom; + viewBounds(left, top, right, bottom); + const int tileCount = 1 << zoom; + TileKey sources[32]; + int sourceCount = 0; + for (int y = static_cast(std::floor(top / TILE_SIZE)); y <= static_cast(std::floor((bottom - 1) / TILE_SIZE)); y++) { + if (y < 0 || y >= tileCount) { + continue; + } + for (int x = static_cast(std::floor(left / TILE_SIZE)); x <= static_cast(std::floor((right - 1) / TILE_SIZE)); x++) { + const TileKey tile { zoom, wrapTileX(x, zoom), y }; + // The label section of the tile it was drawn from: its own up to z14, its z14 ancestor above, or a coarser + // one where the card has no detail + for (int sourceZoom = std::min(zoom, MAX_SOURCE_ZOOM); sourceZoom >= std::max(0, std::min(zoom, MAX_SOURCE_ZOOM) - 2); sourceZoom--) { + const TileKey source = ancestorOf(tile, sourceZoom); + bool known = false; + for (int i = 0; i < sourceCount && !known; i++) { + known = sources[i] == source; + } + if (known) { + break; + } + const double size = std::ldexp(static_cast(TILE_SIZE), zoom - sourceZoom); + // The source's corner from the screen centre, from the visible tile's own column so the antimeridian + // does not matter, then turned onto the screen + const double cornerX = x * TILE_SIZE - (tile.x - (source.x << (zoom - sourceZoom))) * TILE_SIZE - centerX; + const double cornerY = source.y * size - centerY; + double screenX; + double screenY; + worldToScreenOffset(cornerX, cornerY, screenX, screenY); + const auto scale = static_cast(size / PREPARED_EXTENT); + const LabelTransform transform { scale * bearingCos, scale * bearingSin, -scale * bearingSin, scale * bearingCos, + static_cast(width / 2.0 + screenX), static_cast(height / 2.0 + screenY) }; + const bool found = labelStore.with(source, [&](const uint8_t* bytes, size_t size, int count, const LabelCells* cells) { + labels.addSection(bytes, size, count, transform, cells); + }); + if (found) { + if (sourceCount < 32) { + sources[sourceCount++] = source; + } + break; + } + } + } + } + const uint32_t added = nowMs(); + const int placedCount = labels.place(); + labelCenterX = centerX; + labelCenterY = centerY; + labelZoom = zoom; + labelBearing = bearing; + lastLabelPlace = nowMs(); + logInfo("Labels: %d placed of %d candidates (%d records) from %d tiles in %u ms (reading %u, placing %u, of that drawing %u)", placedCount, + labels.candidateTotal(), labels.recordsRead(), sourceCount, static_cast(nowMs() - start), static_cast(added - start), + static_cast(nowMs() - added), static_cast(labels.spriteMicros() / 1000)); +} + +void MapView::updateLabels(uint32_t now) { + if (!labelsReady || settings.labels == 0 || animation.active || turn.active || pinching || isFullScreen()) { + return; + } + // Placed again once the view settled after a change, or when tiles brought new label data; upright again after a turn + const bool moved = labelZoom != zoom || labelBearing != bearing || std::fabs(labelCenterX - centerX) > 0.5 || + std::fabs(labelCenterY - centerY) > 0.5; + const bool newData = labelStore.generation() != labelGeneration; + // New data alone (often tiles drawn for the ring around the screen) places them again at most every 1.5 s + if ((!moved && !newData) || now - lastInputAt < 200 || now - lastLabelPlace < (moved ? 300u : 1500u)) { + return; + } + labelGeneration = labelStore.generation(); + placeLabels(); + dirty = true; +} + +void MapView::updateWork(uint32_t now) { + const uint32_t before = work.generation(); + auto set = [&](WorkKind kind, bool running, int32_t done, int32_t total) { + if (running && !work.isRunning(kind)) { + work.start(kind, total); + } + if (running) { + work.progress(kind, done, total); + } else if (work.isRunning(kind)) { + work.finish(kind); + } + }; + int prepared = 0; + int waiting = 0; + int splitting = 0; + worker.preparation(prepared, waiting, splitting); + // Older tiles with labels inside are split into their new files: shown as such while they are the most of the queue + const bool split = splitting > 0 && splitting * 2 >= waiting; + set(WorkKind::SplitTiles, waiting > 0 && split, prepared, prepared + waiting); + set(WorkKind::PrepareTiles, waiting > 0 && !split, prepared, prepared + waiting); + set(WorkKind::Fonts, labelsReady && missingLabelFonts != 0 && !labelFontsApplied, 0, 0); + set(WorkKind::IdleUpdate, lastStatus.state == Downloader::State::Updating, static_cast(lastStatus.done), + static_cast(lastStatus.total)); + set(WorkKind::GpxImport, gpxImport.state() == GpxImport::State::Busy, 0, 0); + const int moved = downloader.movedTiles(); + set(WorkKind::MoveBrowsed, lastStatus.state == Downloader::State::Downloading && moved > 0, moved, 0); + if (work.generation() != before) { + dirty = true; + } + // The open card follows the counts + if (workCard.isOpen() && now - lastWorkPulse >= 500) { + lastWorkPulse = now; + dirty = true; + } +} + +void MapView::drawWorkDot() { + // Amber, not red: red is track recording. Drawn only with full frames, so menus and their dimming cover it + ::maps::drawWorkDot(canvas, WORK_DOT_X, WORK_DOT_Y, monochrome ? palette->text : static_cast(0xFD20)); +} + +bool MapView::wantsTouchKeyboard() const { + if (input.hasKeyboard() || !input.hasTouch()) { + return false; + } + return prompt.isOpen() || (sheet.isOpen() && sheet.mode() == WaypointSheet::Mode::New && sheet.isEditable()); +} + +void MapView::syncTouchKeyboard() { + const bool wanted = wantsTouchKeyboard(); + if (wanted && !touchKeyboard.isOpen()) { + touchKeyboard.open(); + } else if (!wanted && touchKeyboard.isOpen()) { + touchKeyboard.close(); + } + if (!touchKeyboard.isOpen()) { + return; + } + // The text field moves up above the keyboard + const int above = height - TouchKeyboard::heightFor(width); + touchKeyboard.layout(width, height, sheetFont()); + if (prompt.isOpen()) { + prompt.layout(width, above, overlay.font()); + } + if (sheet.isOpen()) { + sheet.layout(width, above, sheetFont(), true); + } +} + +bool MapView::isSmoothView() const { + return smooth.valid && smooth.zoom == zoom && smooth.bearing == bearing && std::fabs(smooth.centerX - centerX) < 0.01 && + std::fabs(smooth.centerY - centerY) < 0.01; +} + +void MapView::updateSmooth(uint32_t now) { + if (!isRotated() || isSmoothView() || animation.active || turn.active || pinching || isFullScreen() || now - lastInputAt < 250 || + !isViewComplete()) { + return; + } + smooth = { true, centerX, centerY, zoom, bearing, false }; + dirty = true; +} + +void MapView::drawScaledMap() { + const uint32_t start = nowMs(); + AffineView view {}; + float scale = 1.0f; + double worldX = 0.0; + double worldY = 0.0; + float anchorX = 0.0f; + float anchorY = 0.0f; + if (pinching) { + // Drawn from the nearest whole zoom level, so the cached tiles are never scaled by more than 1.4 either way + const int base = snapZoom(zoom, pinchScale, MAX_VIEW_ZOOM); + const auto factor = static_cast(std::ldexp(1.0, base - zoom)); + scale = pinchScale / factor; + worldX = pinchWorldX * factor; + worldY = pinchWorldY * factor; + anchorX = static_cast(pinchX); + anchorY = static_cast(pinchY); + view = scaledView(worldX, worldY, base, scale, anchorX, anchorY, width, height, bearingCos, bearingSin); + } else if (turn.active) { + // Turning around the screen centre at the current zoom + const float t = std::min(1.0f, static_cast(start - turn.startMs) / TURN_ANIMATION_MS); + const float eased = 1.0f - (1.0f - t) * (1.0f - t); + const float radians = (turn.from + (turn.to - turn.from) * eased) * 0.017453292f; + view = scaledView(centerX, centerY, zoom, 1.0f, width / 2.0f, height / 2.0f, width, height, std::cos(radians), std::sin(radians)); + } else { + const float t = std::min(1.0f, static_cast(start - animation.startMs) / ZOOM_ANIMATION_MS); + const float eased = 1.0f - (1.0f - t) * (1.0f - t); + scale = powf(animation.fromScale, 1.0f - eased); + worldX = animation.worldX; + worldY = animation.worldY; + anchorX = animation.anchorX; + anchorY = animation.anchorY; + view = scaledView(worldX, worldY, zoom, scale, anchorX, anchorY, width, height, bearingCos, bearingSin); + } + worker.lock(); + const uint32_t locked = nowMs(); + composeAffine(frame, width, height, view, lookupCached, this, palette->placeholder, *scratch); + worker.unlock(); + const uint32_t composed = nowMs(); + animation.frameLockMs = locked - start; + animation.frameComposeMs = composed - locked; + if (turn.active) { + turn.frames++; + turn.composeMs += composed - start; + } else if (!pinching) { + animation.frames++; + animation.composeMs += composed - start; + } + int markerX; + int markerY; + if (!pinching && !turn.active && markerScreenPosition(markerX, markerY)) { + // The marker moves with the map: its offset from the anchor scales like the map does; turned offsets need no + // change, the zoom animation keeps the bearing + const float x = anchorX + (static_cast(markerX) - anchorX) * scale; + const float y = anchorY + (static_cast(markerY) - anchorY) * scale; + overlay.drawMarker(canvas, static_cast(x), static_cast(y), position.showsArrow(), position.fix().course - bearing, *palette); + } +} + +void MapView::render() { + const uint32_t start = nowMs(); + syncTouchKeyboard(); + probe.phase("render"); + if (animation.active && (pinching || isFullScreen() || start - animation.startMs >= ZOOM_ANIMATION_MS)) { + animation.active = false; + io.hold(false); + updateWanted(); + logInfo("Zoom animation: %d frames in %u ms, compose %u ms per frame, slowest frame %u ms (lock %u, compose %u, rest display)", + animation.frames, static_cast(start - animation.startMs), + animation.frames > 0 ? static_cast(animation.composeMs / animation.frames) : 0, static_cast(animation.slowestFrameMs), + static_cast(animation.slowestLockMs), static_cast(animation.slowestComposeMs)); + } + if (turn.active && (pinching || isFullScreen() || start - turn.startMs >= TURN_ANIMATION_MS)) { + turn.active = false; + io.hold(false); + logInfo("Turn to %d degrees: %d frames in %u ms, compose %u ms per frame", static_cast(turn.to), turn.frames, + static_cast(start - turn.startMs), turn.frames > 0 ? static_cast(turn.composeMs / turn.frames) : 0); + applyBearing(turn.to); + } + if (pinching || animation.active || turn.active) { + // The cached tiles scaled live, sharp tiles follow when the animation ends or the fingers lift + drawScaledMap(); + if (screen == Screen::Download) { + // The frame and its bar stay put while the map scales under them + overlay.setBottomInset(downloadView.bar.height); + drawDownloadFrame(); + } else { + overlay.setBottomInset(0); + } + overlay.drawAttributionMark(canvas, *palette); + output.present(frame, 0, height); + if (animation.active || turn.active) { + if (animation.active && nowMs() - start > animation.slowestFrameMs) { + animation.slowestFrameMs = nowMs() - start; + animation.slowestLockMs = animation.frameLockMs; + animation.slowestComposeMs = animation.frameComposeMs; + } + // The next frame comes with the next loop pass + dirty = true; + } else { + dirty = false; + } + return; + } + if (isFullScreen()) { + if (screen == Screen::About) { + drawAbout(); + } else { + activeList()->draw(canvas, overlay.font(), theme); + } + } else { + drawMap(); + overlay.setBottomInset(screen == Screen::Download ? downloadView.bar.height : 0); + if (screen == Screen::Map) { + const double lat = worldYToLat(centerY, zoom); + overlay.drawScaleBar(canvas, metresPerPixel(lat, zoom), *palette); + } + // The full attribution shows for a few seconds, then collapses into a mark that opens it again + if (screen == Screen::Download) { + drawDownloadFrame(); + } + if (attribution.isExpanded()) { + overlay.drawAttribution(canvas, *palette, attributionBar.x, attributionBar.y, attributionBar.width, attributionBar.height); + } else if (screen == Screen::Map) { + overlay.drawAttributionMark(canvas, *palette); + } + if (screen == Screen::Map) { + if (isRotated() || turn.active) { + drawCompass(); + } + controls.draw(canvas, overlay.font(), theme, positionButton()); + if (controls.isVisible() && !sheet.isOpen() && !cursorShown) { + // The crosshair Enter picks places from: dark with a light rim, like the pointer + const auto x = static_cast(width / 2); + const auto y = static_cast(height / 2); + canvas.line(x - 7.0f, y, x + 7.0f, y, 3.0f, theme.panel); + canvas.line(x, y - 7.0f, x, y + 7.0f, 3.0f, theme.panel); + canvas.line(x - 6.0f, y, x + 6.0f, y, 1.5f, theme.text); + canvas.line(x, y - 6.0f, x, y + 6.0f, 1.5f, theme.text); + } + if (sheet.isOpen()) { + sheet.draw(canvas, sheetFont(), theme); + } + if (routeCard.isOpen()) { + routeCard.draw(canvas, sheetFont(), theme); + } + if (navigating) { + navBar.draw(canvas, sheetFont(), theme, width); + } + if (pickingStart && !menuOpen) { + drawPickStart(); + } + } + if (screen == Screen::Map) { + drawStatus(); + drawRecording(); + } + workDotShown = screen == Screen::Map && work.any() && !statusShown && !controls.isVisible() && !sheet.isOpen() && !navigating; + if (workDotShown) { + drawWorkDot(); + } + } + if (menuOpen) { + menu.draw(canvas, overlay.font(), theme); + } + if (dialog.isOpen()) { + dialog.draw(canvas, overlay.font(), theme); + } + syncTouchKeyboard(); + if (prompt.isOpen()) { + prompt.draw(canvas, overlay.font(), theme); + } + if (touchKeyboard.isOpen()) { + touchKeyboard.draw(canvas, sheetFont(), theme); + } + if (downloadCard.isOpen() && !dialog.isOpen()) { + downloadCard.draw(canvas, overlay.font(), theme); + } + if (workCard.isOpen() && !dialog.isOpen()) { + workCard.set(work); + workCard.layout(width, height, overlay.font()); + workCard.draw(canvas, overlay.font(), theme); + } + if (static_cast(nowMs() - toastUntil) < 0) { + overlay.drawToast(canvas, toastText, theme); + } + if (cursorShown && input.hasPointer()) { + // An arrow pointer, dark with a light rim so it shows on any map and screen + const auto x = static_cast(cursorX); + const auto y = static_cast(cursorY); + canvas.line(x, y, x, y + 13.0f, 4.0f, theme.panel); + canvas.line(x, y, x + 9.0f, y + 9.0f, 4.0f, theme.panel); + canvas.line(x, y, x, y + 13.0f, 2.0f, theme.text); + canvas.line(x, y, x + 9.0f, y + 9.0f, 2.0f, theme.text); + } + const uint32_t presentStart = nowMs(); + probe.phase("display"); + output.present(frame, 0, height); + probe.phase("after display"); + dirty = false; + const uint32_t frameEnd = nowMs(); + if (frameEnd - start > SLOW_LOOP_MS) { + logInfo("Slow frame %u ms: lock %u, compose %u, overlays %u, display %u", frameEnd - start, frameLockMs, frameComposeMs, + presentStart - start - frameLockMs - frameComposeMs, frameEnd - presentStart); + } + frameLockMs = 0; + frameComposeMs = 0; + if (!viewTimed && !isFullScreen() && isViewComplete()) { + viewTimed = true; + // The first connection to OpenFreeMap stalls the device for about 1.5 s: after the first view, not during it + if (!browsingPrepared && settings.browseDownload) { + browsingPrepared = true; + downloader.prepareBrowsing(); + } + logInfo("View z%d complete %u ms after the change (frame %u ms)", zoom, nowMs() - viewChangedAt, nowMs() - start); + logMemory("view complete"); + // How close each task came to the end of its stack, so stack sizes can be set from measurements + logInfo("Stack left: main %u, io %u, render0 %u, render1 %u bytes", static_cast(uxTaskGetStackHighWaterMark(nullptr)), + static_cast(io.stackLeft()), static_cast(worker.stackLeft(0)), static_cast(worker.stackLeft(1))); + } +} + +bool MapView::powerAllowsUpdates() { + Device* supply = nullptr; + if (device_get_first_active_by_type(&POWER_SUPPLY_TYPE, &supply) != ERROR_NONE) { + // No battery to look at (powered by USB, or the board reports nothing): Wi-Fi alone decides + return true; + } + bool allowed = true; + PowerSupplyPropertyValue value {}; + if (power_supply_supports_property(supply, POWER_SUPPLY_PROP_IS_CHARGING) && + power_supply_get_property(supply, POWER_SUPPLY_PROP_IS_CHARGING, &value) == ERROR_NONE && value.int_value != 0) { + allowed = true; + } else if (power_supply_supports_property(supply, POWER_SUPPLY_PROP_CAPACITY) && + power_supply_get_property(supply, POWER_SUPPLY_PROP_CAPACITY, &value) == ERROR_NONE) { + allowed = value.int_value >= UPDATE_MIN_BATTERY; + } + device_put(supply); + return allowed; +} + +void MapView::showToast(const char* text) { + snprintf(toastText, sizeof(toastText), "%s", text); + toastUntil = nowMs() + TOAST_SHOW_MS; + dirty = true; +} + +void MapView::startFollowing() { + if (!position.hasFix()) { + // Location is the user's to switch on, in the menu or in Tactility's settings + following = false; + showToast(position.unavailableMessage()); + return; + } + if (following && !headingUp) { + // Second press: the direction of travel up, from the GPS course while moving + headingUp = true; + headingFilter.reset(); + showToast("Heading up"); + } else if (following) { + // Third press: free again, the map keeps its turn until N or the compass + following = false; + headingUp = false; + dirty = true; + return; + } else { + following = true; + headingUp = false; + setBearing(0.0f, true); + } + centerOnPosition(); + dirty = true; +} + +void MapView::toggleLocation() { + const bool enable = !LocationSource::isEnabled(); + if (!LocationSource::setEnabled(enable)) { + showToast("Could not change the location setting"); + return; + } + showToast(enable ? "Location on" : "Location off"); + readLocation(); +} + +void MapView::endPinch(float scale, int fromX, int fromY, int toX, int toY) { + pinching = false; + io.hold(false); + // Snaps to the nearest whole zoom level, the point under the fingers at the start stays under them + const int newZoom = snapZoom(zoom, scale, MAX_VIEW_ZOOM); + const double factor = std::ldexp(1.0, newZoom - zoom); + // pinchWorldX, pinchWorldY is the world point under (fromX, fromY) at the start; the bearing may have changed since + const double worldX = pinchWorldX; + const double worldY = pinchWorldY; + (void)fromX; + (void)fromY; + double toWorldX; + double toWorldY; + screenToWorldOffset(toX - width / 2.0, toY - height / 2.0, toWorldX, toWorldY); + const double worldSize = std::ldexp(static_cast(TILE_SIZE), newZoom); + centerX = std::fmod(worldX * factor - toWorldX + worldSize, worldSize); + centerY = std::fmin(std::fmax(worldY * factor - toWorldY, 0.0), worldSize); + pinchTurning = false; + zoom = newZoom; + dirty = true; + updateWanted(); +} + +void MapView::readLocation() { + lastLocationCheck = nowMs(); + const bool hadFix = position.hasFix(); + const PositionState before = position.state(); + PositionSource& source = replaying ? static_cast(replay) : static_cast(location); + if (!position.update(source)) { + return; + } + if (!hadFix && position.hasFix()) { + logInfo("First position, accuracy %d m", static_cast(position.fix().accuracy)); + } + if (recorder.isActive() && position.hasFix()) { + recorder.addFix(position.fix()); + } + if (following && position.hasFix()) { + // The turn first: the position's place on the screen depends on it while navigating + if (headingUp && headingFilter.update(position.fix().course, position.fix().speed, nowMs())) { + setBearing(headingFilter.heading(), false); + } + centerOnPosition(); + } + if (before != position.state() && !position.hasFix()) { + following = false; + headingUp = false; + } + followRoute(nowMs()); + dirty = true; +} + +void MapView::saveView() { + SavedView view; + view.lat = worldYToLat(centerY, zoom); + view.lon = worldXToLon(centerX, zoom); + view.zoom = zoom; + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/view.txt", rootPath); + auto save = [&] { return writeSavedView(path, view); }; + if (!runOnCard(io, save)) { + logError("View not saved to %s", path); + } +} + +void MapView::saveShot(const char* name) { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/%s.bmp", rootPath, name); + auto save = [&] { return saveBmp(path, frame, width, height); }; + logInfo("Screenshot %s: %s", path, runOnCard(io, save) ? "saved" : "failed"); +} + +void MapView::run() { + while (!exitRequested) { + const uint32_t now = nowMs(); + probe.heartbeat(now); + probe.phase("keys"); + KeyEvent events[8]; + const int count = input.readKeys(events, 8); + bool enterThisPoll = false; + if (count > 0) { + markInput(now); + } + for (int i = 0; i < count; i++) { + if (events[i].key == keys::ENTER) { + handleEnter(events[i].pressed, now); + enterThisPoll = enterThisPoll || events[i].pressed; + } else if (events[i].pressed) { + handleKey(events[i].key); + } + } + if (enterHeld && !enterThisPoll && now - enterSeenAt > ENTER_RELEASE_MS) { + handleEnter(false, now); + } + if (enterHeld && !enterLong && now - enterDownAt >= LONG_PRESS_MS) { + enterLong = true; + longClick(); + } + if (input.takeDevicesChanged()) { + // The trackball was switched between keyboard and mouse mode + controls.setInputs({ input.hasKeyboard(), input.hasTouch(), input.hasPointer() }); + cursorShown = false; + pointerAcceleration.reset(); + dirty = true; + } + const uint32_t afterKeys = nowMs(); + probe.phase("pointer"); + handleMouse(now); + handleTouch(now); + const uint32_t afterInput = nowMs(); + if (!idle && now - lastInputAt >= IDLE_AFTER_MS) { + idle = true; + worker.setIdle(true); + downloader.setIdle(true); + } + if (!updatesAllowed && now - lastInputAt >= UPDATE_IDLE_MS && now - lastPowerCheck >= POWER_CHECK_MS) { + // Outdated regions update quietly once the map has been left alone, on Wi-Fi and with enough power + lastPowerCheck = now; + if (Downloader::isOnline() && powerAllowsUpdates()) { + updatesAllowed = true; + downloader.setUpdatesAllowed(true); + } + } + if (now - lastLocationCheck >= LOCATION_CHECK_MS) { + readLocation(); + } + const uint32_t afterLocation = nowMs(); + probe.phase("downloader"); + pollDownloader(now); + pollGpxImport(); + recorder.flushIfDue(io, now); + if (recorder.state() == TrackRecorder::State::Recording && now - lastRecordingDraw >= 1000 && screen == Screen::Map) { + // The elapsed time on the indicator + lastRecordingDraw = now; + dirty = true; + } + const uint32_t afterDownloader = nowMs(); + probe.phase("gaps"); + pollGaps(now); + probe.phase("labels"); + pollLabelFonts(); + updateLabels(now); + updateSmooth(now); + if (browsedCounter.poll(now) && screen == Screen::Download) { + dirty = true; + } + updateWork(now); + updateNavigation(now); + probe.phase("loop"); + const uint32_t afterGaps = nowMs(); + + if (waitingRoute && navigator.state() != Navigator::State::Searching) { + waitingRoute = false; + } + if (!waitingIdle && !waitingRoute) { + const DebugScript::Step step = script.next(now); + switch (step.action) { + case DebugScript::Action::Key: + markInput(now); + if (step.key == keys::ENTER) { + handleEnter(true, now); + handleEnter(false, now); + } else if (step.key == 'c') { + // "key c" in a debug script is a trackball click in mouse mode + click(true); + } else { + handleKey(step.key); + } + break; + case DebugScript::Action::Shot: + render(); + saveShot(step.name); + break; + case DebugScript::Action::Goto: + centerOn(step.lat, step.lon, step.zoom); + break; + case DebugScript::Action::WaitIdle: + waitingIdle = true; + break; + case DebugScript::Action::Exit: + exitRequested = true; + break; + case DebugScript::Action::Touch: + syntheticTouch = step; + syntheticTouchStart = now; + syntheticTouchActive = true; + syntheticTouchEnded = false; + break; + case DebugScript::Action::Squeeze: + squeezeMemory(static_cast(step.zoom) * 1024); + break; + case DebugScript::Action::RouteBench: { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/%s", rootPath, step.name); + navigator.bench(path, step.zoom >= 3 ? RouteProfile::Count : static_cast(step.zoom)); + break; + } + case DebugScript::Action::Navigate: + navigateTo(step.lat, step.lon, static_cast(std::min(step.zoom, 2)), false); + break; + case DebugScript::Action::WaitRoute: + waitingRoute = true; + break; + case DebugScript::Action::Replay: + startReplay(step.name); + break; + case DebugScript::Action::TouchOnly: + input.setTouchOnly(true); + cursorShown = false; + controls.setInputs({ input.hasKeyboard(), input.hasTouch(), input.hasPointer() }); + dirty = true; + break; + default: + break; + } + } else if (viewTimed && !dirty) { + waitingIdle = false; + } + + if (subscribed) { + AppEvent event; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + exitRequested = true; + } + } + } + if (cursorShown && now - cursorMovedAt >= POINTER_HIDE_MS) { + // A pointer that is not used does not cover the map + cursorShown = false; + controls.setHover(Control::None); + dirty = true; + } + if (worker.takeChanged() && !isFullScreen()) { + dirty = true; + } + if (attribution.update(now)) { + dirty = true; + } + if (static_cast(now - toastUntil) >= 0 && toastUntil != 0) { + toastUntil = 0; + dirty = true; + } + const uint32_t beforeRender = nowMs(); + if (dirty && !exitRequested) { + render(); + } + // A pass that takes long is a freeze the user feels, the log says where the time went + const uint32_t done = nowMs(); + if (done - now > SLOW_LOOP_MS) { + logInfo("Slow loop %u ms: keys %u, mouse+touch %u, location %u, downloader %u, gaps %u, other %u, render %u", done - now, + afterKeys - now, afterInput - afterKeys, afterLocation - afterInput, afterDownloader - afterLocation, afterGaps - afterDownloader, + beforeRender - afterGaps, done - beforeRender); + } + // Sleeps until the next input poll, waking early for app events and new positions + uint32_t flags = 0; + if (animation.active || turn.active) { + // Animation frames come as fast as they can be drawn, one tick lets the render lanes on this core go on + vTaskDelay(1); + } else if (!subscribed && location.bit() == 0) { + // Without a claimed bit the wait would return at once + vTaskDelay(pdMS_TO_TICKS(LOOP_INTERVAL_MS)); + } else if (task_event_group_wait_any(&eventGroup, &flags, pdMS_TO_TICKS(LOOP_INTERVAL_MS)) == ERROR_NONE && + (flags & location.bit()) != 0) { + readLocation(); + } + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/MapView.h b/Apps/Maps/main/Source/app/MapView.h new file mode 100644 index 0000000..fd89c12 --- /dev/null +++ b/Apps/Maps/main/Source/app/MapView.h @@ -0,0 +1,704 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "BinFontText.h" +#include "Debug.h" +#include "DisplayOutput.h" +#include "Downloader.h" +#include "GpxImport.h" +#include "Input.h" +#include "IoTask.h" +#include "BrowsedCounter.h" +#include "../ui/TouchKeyboard.h" +#include "../ui/BackgroundWork.h" +#include "LabelFontMaker.h" +#include "LabelStore.h" +#include "Navigator.h" +#include "../route/RouteFollower.h" +#include "../ui/NavigationBar.h" +#include "../ui/RouteCard.h" +#include "LocationSource.h" +#include "StallProbe.h" +#include "TileWorker.h" +#include "TrackRecorder.h" + +#include "../render/LabelLayout.h" +#include "../tiles/DownloadPlan.h" +#include "../tiles/RegionCatalog.h" +#include "../tiles/RegionMeta.h" +#include "../tiles/TileStore.h" +#include "../ui/Canvas.h" +#include "../ui/Controls.h" +#include "../ui/HeadingFilter.h" +#include "../ui/DownloadProgress.h" +#include "../ui/DownloadFrame.h" +#include "../ui/Gestures.h" +#include "../ui/MapOverlay.h" +#include "../ui/PointerAcceleration.h" +#include "../ui/WaypointSheet.h" +#include "../ui/PositionTracker.h" +#include "../ui/Settings.h" +#include "../ui/GpxLayer.h" +#include "../ui/Waypoints.h" +#include "../ui/Widgets.h" + +#include +#include + +#include + +namespace maps { + +struct AffineScratch; + +#define APP_VERSION "0.4.0" +constexpr const char* APP_ID = "tactility.maps"; + +/** + * The whole app while it runs fullscreen with LVGL stopped: the map, the menu, the Regions, Settings and About + * screens, and the download frame, all drawn by the app itself. + */ +/** Positions played from a GPX track, one point a second, for testing navigation indoors ("replay" debug command). */ +class ReplaySource final : public PositionSource { +public: + static constexpr int MAX_POINTS = 2000; + static constexpr uint32_t STEP_MS = 1000; + bool load(const char* text, size_t size); + void clear() { count = 0; } + bool isActive() const { return count > 0; } + int size() const { return count; } + PositionRead read(PositionFix& fix) override; + +private: + double points[MAX_POINTS][2] {}; + int count = 0; + int next = 0; + uint32_t lastStep = 0; +}; + +class MapView { +public: + bool begin(Device* display, const char* root, const char* dataPath); + void run(); + void end(); + +private: + enum class Screen : uint8_t { + Map, + Download, + Regions, + Settings, + About, + Catalog, + Waypoints, + GpxFiles, + Tracks + }; + + enum class MenuItem : uint8_t { + Download, + Regions, + Waypoints, + AddWaypoint, + RecordTrack, + GpxFiles, + Location, + Settings, + About, + Exit, + Count + }; + + enum class Popup : uint8_t { + Main, + DownloadChoice, + Track, + RegionActions, + WaypointActions, + /** Where a route starts when there is no GPS fix (M5c). */ + RouteStart + }; + + enum class SettingItem : uint8_t { + SdCache, + PreparedTiles, + SmoothPinch, + BrowseDownload, + PointerSpeed, + Labels, + LatinNames, + RouteMode, + Count + }; + + // MapView.cpp: the map itself + void centerOn(double lat, double lon, int zoom); + /** + * "Navigate here": a route to lat, lon with the profile. With a GPS fix it starts there; without one the app asks + * where it starts (on the map, the screen centre, or an approximate position), unless ask is false (debug scripts: + * the screen centre). + */ + void navigateTo(double lat, double lon, RouteProfile profile, bool ask = true); + /** Polls the route search: a toast when it ends, the preview when a route was found. */ + void updateNavigation(uint32_t now); + void fillRouteCard(); + /** Another mode on the card: the same start and goal again; a running search is cancelled first. */ + void chooseRouteMode(RouteProfile next); + void searchRouteAgain(); + /** Starts the search from a start point; fromPosition: the start follows the position when the profile changes. */ + void requestRoute(double fromLat, double fromLon, bool fromPosition, const char* toast); + void openRouteStartChoice(); + void chooseRouteStart(int choice); + /** Picking the start on the map: a tap there, or Enter for the crosshair; Esc cancels. */ + void beginPickStart(); + void endPickStart(bool chosen, double lat, double lon); + void drawPickStart(); + void fitRoute(); + void handleRouteCardButton(RouteCard::Button button); + void startNavigation(); + /** The controls laid out for the screen, the menu button below the navigation bar while navigating. */ + void layoutControls(); + /** Centres the map on the position; while navigating the position sits low on the screen, to see ahead. */ + void centerOnPosition(); + void stopNavigation(); + void clearRoute(); + /** A new position while following: the next instruction, off route, arrival. */ + void followRoute(uint32_t now); + /** The route line, above the roads and below labels and pins. */ + void drawRoute(); + void startReplay(const char* name); + /** Remembers the centre and zoom, so the map opens there next time. */ + void saveView(); + void setZoom(int zoom, int anchorX, int anchorY); + /** + * Zooms by whole levels around a screen point: animated over about 220 ms with the cached tiles scaled, then + * sharp tiles. Straight to the new level on monochrome and e-paper displays, or with smooth zoom off. + */ + void zoomBy(int steps, int anchorX, int anchorY); + bool smoothZoom() const { return settings.smoothPinch && !monochrome; } + /** Draws the map through the affine compositor while a zoom animation or a pinch runs. */ + void drawScaledMap(); + static const uint16_t* lookupCached(const TileKey& tile, void* context); + /** Moves the map by a screen offset: on a turned map the keys and fingers move it in screen directions. */ + void pan(double dx, double dy); + void updateWanted(); + /** Turns the map so that the direction bearing (degrees clockwise from north) is up; animated when wanted. */ + void setBearing(float degrees, bool animate); + /** Sets the bearing at once; want false leaves the tile list alone (live turning with two fingers). */ + void applyBearing(float degrees, bool want = true); + static constexpr float TURN_ANIMATION_MS = 300.0f; + /** Q and E turn the map by this much. */ + static constexpr float KEY_TURN_DEGREES = 15.0f; + /** How far two fingers must twist before a turned map turns with them. */ + static constexpr float PINCH_TURN_START = 15.0f; + /** The same for a map that is north up, so a pinch rarely turns it by accident. */ + static constexpr float PINCH_TURN_START_NORTH = 45.0f; + bool isRotated() const { return bearing != 0.0f; } + /** A screen offset from the screen centre as a world offset at the current zoom, and back. */ + void screenToWorldOffset(double sx, double sy, double& wx, double& wy) const; + void worldToScreenOffset(double wx, double wy, double& sx, double& sy) const; + /** The world pixels at zoom the screen covers, turned or not. */ + void viewBounds(double& left, double& top, double& right, double& bottom) const; + /** The compass that shows north on a turned map, top right; tapping it turns the map back. */ + Rect compassRect() const; + void drawCompass(); + void render(); + void drawMap(); + /** Places the labels of the tiles on screen for the current view, when it settled or new label data came. */ + void placeLabels(); + void updateLabels(uint32_t now); + /** Uses the label fonts the maker finished: opens their cache files and places the labels again. */ + void pollLabelFonts(); + /** Gives the labels the fonts of the app where they opened, else the system's. */ + void applyLabelFonts(); + void applyLabelSettings(); + void drawStatus(); + /** Opens the details of the running download, from a tap on the progress pill or Menu, Download. */ + void openDownloadCard(); + void fillDownloadCard(); + void handleCardButton(DownloadCard::Button button); + void pollDownloader(uint32_t now); + /** Hands the tiles that lack detail to the downloader, or says why they stay coarse. */ + void pollGaps(uint32_t now); + Rect attributionMark() const; + void readLocation(); + /** Charging, or the battery above the threshold, or no battery reported. */ + static bool powerAllowsUpdates(); + void showToast(const char* text); + /** The position button and G: not following, following north up, following with the direction of travel up. */ + void startFollowing(); + /** Menu, Location: switches Tactility's location service on or off, the one setting the app changes. */ + void toggleLocation(); + bool markerScreenPosition(int& x, int& y) const; + void saveShot(const char* name); + bool isViewComplete(); + static const uint16_t* lookupTile(const TileKey& tile, void* context); + const uint16_t* findTile(const TileKey& tile); + void endPinch(float scale, int fromX, int fromY, int toX, int toY); + bool isFullScreen() const { + return screen == Screen::Regions || screen == Screen::Settings || screen == Screen::About || screen == Screen::Catalog || + screen == Screen::Waypoints || screen == Screen::GpxFiles || screen == Screen::Tracks; + } + + // MapInput.cpp: keys, trackball and touch + void handleKey(uint32_t key); + void handleMapKey(uint32_t key); + void handleEnter(bool pressed, uint32_t now); + void handleMouse(uint32_t now); + void handleTouch(uint32_t now); + void handleGesture(const Gesture& gesture); + void handleTap(int x, int y); + void handleLongPress(int x, int y); + void markInput(uint32_t now); + /** A trackball or Enter click: the same as Enter on lists, the map's own action on the map. */ + void click(bool fromPointer); + void longClick(); + /** A click of the mouse pointer: what a tap at the pointer does, on every screen. */ + void pointerClick(); + /** + * Moves the pointer, which stops at the screen edges. Returns in overflowX, overflowY how far the movement went + * past an edge: pushing against an edge moves the map or scrolls a list. + */ + void moveCursor(int dx, int dy, uint32_t now, int& overflowX, int& overflowY); + void pressControl(Control control); + /** A tap or pointer click on the map: buttons and the attribution first. Returns true when something took it. */ + bool pressAt(int x, int y); + PositionButton positionButton() const; + + // MapScreens.cpp: menu, Regions, Settings, About, download frame + void openMenu(); + void closeMenu(); + void chooseMenu(MenuItem item); + void handleListAction(UiAction action); + /** What Enter, a click, a tap or Back did in the popup that is open. */ + void handlePopupAction(UiAction action); + /** Opens a popup of kind with a title; rows are added to menu by the caller before layoutPopup(). */ + void beginPopup(Popup kind, const char* title); + void layoutPopup(); + /** Back one level from a list, the popup or About: Swipe right, < or Esc. */ + void goBack(); + void openRegions(); + void fillRegionList(); + /** Enter, a tap or a long press on a region: what can be done with it. */ + void openRegionActions(); + void chooseRegionAction(int action); + void askDeleteRegion(); + void deleteRegion(); + bool isOutdated(const RegionMeta& meta) const; + /** The dialog's main button: delete or update the selected region. */ + void confirmDialog(); + /** Rename the selected region, with a keyboard. */ + void askRenameRegion(); + /** The text prompt's OK. */ + void confirmPrompt(); + void openSettings(); + void fillSettingList(); + void toggleSetting(SettingItem item); + void openAbout(); + void scrollAbout(int lines); + void drawAbout(); + void closeScreen(); + BoundingBox frameBox() const; + void drawDownloadFrame(); + void startDownload(); + /** Creates maps// for a new region named after title; the folder name is 8.3-safe and not taken yet. */ + bool makeRegionFolder(const char* title, RegionMeta& meta); + /** Writes a new region's meta.json, registers it and starts its download. */ + bool beginRegionDownload(RegionMeta& meta); + // Region catalog: Menu, Download area, Choose region + bool loadCatalog(); + void openDownloadChoice(); + void openCatalog(int parent); + void chooseCatalog(); + void catalogBack(); + /** Works out the highest max zoom of a catalog region within the download limits, and asks to download it. */ + void planCatalogRegion(int index); + void startCatalogDownload(); + /** The smallest catalog region around a point, as a title for a new download; false without the catalog. */ + bool catalogTitle(double lat, double lon, char* out, size_t size); + + // MapPlaces.cpp: waypoints, track recording, GPX files + void loadWaypoints(); + void saveWaypoints(); + /** Opens the sheet for a new waypoint at a point: name, coordinates, distance, Save and Cancel. */ + void openNewWaypoint(double lat, double lon, const char* name = nullptr); + /** How far the placed labels moved with the map since they were placed; false when they are not for this view. */ + bool labelOffset(int& x, int& y) const; + /** The POI or house number under a screen point, -1 when none. */ + int placeAt(int x, int y) const; + /** Opens the sheet of a placed POI or house number: name, type, coordinates, distance, Save as waypoint, Close. */ + void openPlaceSheet(int index); + /** + * Enter on the map while the controls show: the POI or house number nearest the screen centre (the crosshair), + * then each next nearest. False when none is near the centre. + */ + bool selectNearestPlace(); + void selectNextPlace(); + /** The same at my position, P and holding the position button; says why when there is none. */ + void openNewWaypointAtPosition(); + /** Opens the sheet of an existing waypoint: Rename, Delete, Close. */ + void openWaypointSheet(int index); + void handleSheetButton(WaypointSheet::Button button); + /** Saves the new waypoint of the sheet and says so. */ + void saveNewWaypoint(); + void openWaypoints(); + void fillWaypointList(); + void goToWaypoint(); + /** Enter, a tap or a long press on a waypoint in the list: Go to, Rename, Delete. */ + void openWaypointActions(); + void chooseWaypointAction(int action); + void askDeleteWaypoint(int index); + void deleteWaypoint(); + void askRenameWaypoint(int index); + void renameWaypoint(const char* name); + /** The waypoint highlighted in the list, -1 when none. */ + int listedWaypoint() const; + /** The waypoint pin under a screen point, -1 when none. */ + int waypointAt(int x, int y) const; + void drawPlaces(); + void drawRecording(); + void openTrackMenu(); + void chooseTrackMenu(int row); + void openTracks(); + void openGpxFiles(); + void toggleGpxFile(); + void pollGpxImport(); + void saveShownGpx(); + void loadShownGpx(); + /** "1.2 km NE" from my position (or the map centre) to a point. */ + void describeDistance(double lat, double lon, char* out, size_t size) const; + bool worldToScreen(double lat, double lon, float& x, float& y) const; + void handleDownloadKey(uint32_t key); + void showRegion(const RegionMeta& meta); + ListView* activeList(); + /** Sets a list's hint for the inputs this board has. */ + void setListHint(ListView& list, const char* keyHint, const char* touchHint); + + DisplayOutput output; + IoTask io; + Input input; + BinFontText normalFont; + BinFontText smallFont; + BinFontText largeFont; + /** Label fonts made from the app's assets, with Latin Extended-A (the system fonts stop at Latin-1). */ + BinFontText labelFonts[3]; + /** The waypoint and place sheet shows names: in the app's label font once it is made (Tactility's UI font stops at Latin-1). */ + Font& sheetFont() { return labelFonts[1].isOpen() ? static_cast(labelFonts[1]) : overlay.font(); } + LabelFontMaker fontMaker; + /** Browsed tiles inside the download frame, for its estimate. */ + BrowsedCounter browsedCounter; + /** + * What the app does in the background, shown as a small amber dot in the top-left corner (pulsing on colour + * displays) that opens a card listing it. Hidden while the progress pill or the controls show. + */ + BackgroundWork work; + WorkCard workCard; + bool workDotShown = false; + uint32_t lastWorkPulse = 0; + /** Where the position sits while navigating, as a share of the screen height from the top: more map ahead. */ + static constexpr double NAVIGATION_POSITION_Y = 0.72; + static constexpr int WORK_DOT_X = 8; + static constexpr int WORK_DOT_Y = 8; + /** Whether drawStatus drew its pill this frame. */ + bool statusShown = false; + void updateWork(uint32_t now); + void drawWorkDot(); + /** Debug scripts: PSRAM held in 64 KB pieces until no free block is largest bytes, given back when the map closes. */ + void squeezeMemory(size_t largest); + static constexpr int MAX_SQUEEZED = 64; + void* squeezed[MAX_SQUEEZED] {}; + int squeezedCount = 0; + /** Bit i: label font i has no cache file yet, so the maker has to make it. */ + unsigned missingLabelFonts = 0; + bool labelFontsApplied = false; + LabelStore labelStore; + LabelLayout labels; + bool labelsReady = false; + /** The view the labels were placed for; they move with the map until it settles again. */ + double labelCenterX = 0.0; + double labelCenterY = 0.0; + int labelZoom = -1; + uint32_t labelGeneration = 0; + uint32_t lastLabelPlace = 0; + MapOverlay overlay; + Canvas canvas; + UiTheme theme {}; + TileStore store; + TileWorker worker; + Navigator navigator; + bool navigatorStarted = false; + /** A debug script waits for the route search ("waitroute"). */ + bool waitingRoute = false; + RouteCard routeCard; + NavigationBar navBar; + RouteFollower follower; + bool navigating = false; + bool recalculating = false; + /** A search this view asked for (not a bench run) is running. */ + bool routeRequested = false; + /** A mode was chosen while a search ran: a new search starts once it has stopped. */ + bool restartRoute = false; + uint32_t lastRecalculation = 0; + uint32_t lastFollowAt = 0; + uint32_t lastFollowSignature = 0; + uint32_t arrivedAt = 0; + double goalLat = 0.0; + double goalLon = 0.0; + RouteProfile routeProfile = RouteProfile::Walk; + /** The start of the last route asked for, and whether it was the position. */ + double startLat = 0.0; + double startLon = 0.0; + bool startFromPosition = false; + bool pickingStart = false; + NavigationBar pickBar; + /** Large: in PSRAM with the view. */ + ReplaySource replay; + bool replaying = false; + Downloader downloader; + DebugScript script; + Screen screen = Screen::Map; + /** A full screen opened from the menu goes back to the menu, one opened from the map back to the map. */ + bool returnToMenu = false; + /** The popup over the screen: the menu, its sub-menus, or the actions of a row in a list. */ + ListView menu; + bool menuOpen = false; + Popup popup = Popup::Main; + static constexpr int MAX_POPUP_ACTIONS = 16; + int popupActions[MAX_POPUP_ACTIONS] {}; + int popupActionCount = 0; + HeaderLayout aboutHeader {}; + ListView regionList; + RegionMeta regions[TileStore::MAX_REGIONS] {}; + int regionCount = 0; + ListView settingList; + ListView catalogList; + char catalogPath[PATH_MAX_LENGTH] = ""; + FileBuffer catalogFile; + RegionCatalog catalog; + /** The region whose children the list shows, -1 for the continents; the row of "All of ..." when it has one. */ + int catalogParent = -1; + int catalogRows[ListView::MAX_ITEMS] {}; + struct CatalogPlan { + int region; + int maxZoom; + int64_t tiles; + uint64_t bytes; + }; + CatalogPlan catalogPlan {}; + + Waypoints waypoints; + /** The waypoint whose sheet is open, -1 for none or a new one. */ + int selectedWaypoint = -1; + /** The waypoint a rename or delete is asked for, from the sheet or the list. */ + int editingWaypoint = -1; + WaypointSheet sheet; + /** The places Enter walks through, nearest the centre first, and the one shown. */ + static constexpr int MAX_PLACE_ORDER = 16; + int placeOrder[MAX_PLACE_ORDER] {}; + int placeOrderCount = 0; + int placeOrderIndex = 0; + double sheetLat = 0.0; + double sheetLon = 0.0; + ListView waypointList; + int waypointRows[ListView::MAX_ITEMS] {}; + TrackRecorder recorder; + ListView trackList; + struct GpxFile { + char name[48]; + bool shown; + bool failed; + GpxLayer* layer; + }; + static constexpr int MAX_GPX_FILES = 16; + GpxFile gpxFiles[MAX_GPX_FILES] {}; + int gpxFileCount = 0; + ListView gpxList; + GpxImport gpxImport; + int gpxImporting = -1; + char gpxFolder[PATH_MAX_LENGTH] = ""; + Dialog dialog; + enum class DialogPurpose : uint8_t { + Delete, + Update, + CatalogDownload, + DeleteWaypoint, + CancelDownload + }; + DialogPurpose dialogPurpose = DialogPurpose::Delete; + TextPrompt prompt; + enum class PromptPurpose : uint8_t { + RenameRegion, + RenameWaypoint + }; + PromptPurpose promptPurpose = PromptPurpose::RenameRegion; + int aboutScroll = 0; + int aboutLines = 0; + char* aboutBuffer = nullptr; + DownloadLayout downloadView {}; + Downloader::Status lastStatus {}; + /** The display title of the region in lastStatus, read from its meta.json when the region changes. */ + char statusTitle[48] = ""; + char statusTitleOf[REGION_NAME_MAX + 1] = ""; + /** The region of the download that was paused from the details card, so it can be resumed there. */ + char pausedRegion[REGION_NAME_MAX + 1] = ""; + char cancelRegion[REGION_NAME_MAX + 1] = ""; + TransferRate transferRate; + DownloadCard downloadCard; + Rect pillRect {}; + int browseDone = 0; + int browseTotal = 0; + bool lastBrowsing = false; + int lastBrowseDone = 0; + int lastBrowseTotal = 0; + uint32_t lastStatusPoll = 0; + uint32_t lastGapPoll = 0; + uint32_t lastGapHint = 0; + uint32_t messageUntil = 0; + bool resumeChecked = false; + /** A download that failed tries again at this time (0: none planned); retries counts the tries since the last success. */ + uint32_t retryAt = 0; + int retries = 0; + const Palette* palette = nullptr; + bool monochrome = false; + uint16_t* frame = nullptr; + uint16_t* preview = nullptr; + AffineScratch* scratch = nullptr; + int width = 0; + int height = 0; + double centerX = 0.0; + double centerY = 0.0; + int zoom = 14; + bool dirty = true; + bool exitRequested = false; + char rootPath[PATH_MAX_LENGTH] = ""; + TaskEventGroup eventGroup {}; + bool eventGroupReady = false; + AppEventSubscription appEvents {}; + bool subscribed = false; + Settings settings; + StallProbe probe; + SavedView savedView; + bool hasSavedView = false; + bool viewReady = false; + LocationSource location; + PositionTracker position; + uint32_t lastLocationCheck = 0; + char toastText[96] = ""; + uint32_t toastUntil = 0; + bool following = false; + /** While following: the map turns to the GPS course (smoothed, held while slow). */ + bool headingUp = false; + HeadingFilter headingFilter; + /** Degrees clockwise from north of the direction at the top of the screen, with its cosine and sine. */ + float bearing = 0.0f; + float bearingCos = 1.0f; + float bearingSin = 0.0f; + /** Turning to a new bearing over TURN_ANIMATION_MS, drawn from cached tiles like the zoom animation. */ + struct TurnAnimation { + bool active; + float from; + float to; + uint32_t startMs; + int frames; + uint32_t composeMs; + }; + TurnAnimation turn {}; + /** + * A turned view is drawn nearest pixel while it moves; once it settles with all tiles ready, it is drawn again with + * bilinear sampling, and stays so while this view (centre, zoom, bearing) is unchanged. + */ + struct SmoothView { + bool valid; + double centerX; + double centerY; + int zoom; + float bearing; + bool logged; + }; + SmoothView smooth {}; + bool isSmoothView() const; + /** Text can be typed: on a keyboard, or on the on-screen keyboard of a touch screen. */ + bool canType() const { return input.hasKeyboard() || input.hasTouch(); } + /** The on-screen keyboard shows while a text field is open on a board with a touch screen and no keyboard. */ + bool wantsTouchKeyboard() const; + void syncTouchKeyboard(); + TouchKeyboard touchKeyboard; + /** A touch played by a debug script (tap, hold, drag, pinch, twist), read instead of the touch screen. */ + DebugScript::Step syntheticTouch {}; + uint32_t syntheticTouchStart = 0; + bool syntheticTouchActive = false; + bool syntheticTouchEnded = false; + void syntheticTouchState(uint32_t now, TouchState& state); + void updateSmooth(uint32_t now); + /** The bearing the labels were placed for. */ + float labelBearing = 0.0f; + Attribution attribution; + Rect attributionBar {}; + Controls controls; + /** The pointer of a trackball in mouse mode, shown while the controls are. */ + bool cursorShown = false; + /** When the pointer last moved; it hides after POINTER_HIDE_MS without movement. */ + uint32_t cursorMovedAt = 0; + int cursorX = 0; + int cursorY = 0; + bool enterHeld = false; + bool enterLong = false; + uint32_t enterDownAt = 0; + uint32_t enterSeenAt = 0; + bool mouseHeld = false; + uint32_t mouseDownAt = 0; + bool mouseLong = false; + /** The pointer moved while its button was held: the map was dragged, the release is no click. */ + bool mouseDragged = false; + int mouseDragDistance = 0; + int mouseRowAccumulator = 0; + PointerAcceleration pointerAcceleration; + Gestures gestures; + int dragRowAccumulator = 0; + bool pinching = false; + /** Two-finger turning: starts only past PINCH_TURN_START degrees of twist, so a pinch does not turn the map. */ + bool pinchTurning = false; + float pinchStartBearing = 0.0f; + float pinchTwistStart = 0.0f; + /** The world point at zoom under the fingers when the pinch started. */ + double pinchWorldX = 0.0; + double pinchWorldY = 0.0; + /** A zoom animation: the map at zoom (the target) magnified by a scale that eases from fromScale to 1. */ + struct ZoomAnimation { + bool active; + uint32_t startMs; + float fromScale; + float anchorX; + float anchorY; + double worldX; + double worldY; + int frames; + uint32_t composeMs; + uint32_t slowestFrameMs; + /** Where the slowest frame spent its time. */ + uint32_t slowestLockMs; + uint32_t slowestComposeMs; + uint32_t frameLockMs; + uint32_t frameComposeMs; + }; + ZoomAnimation animation {}; + bool pinchStepped = false; + float pinchScale = 1.0f; + int pinchStartX = 0; + int pinchStartY = 0; + int pinchX = 0; + int pinchY = 0; + uint32_t lastInputAt = 0; + uint32_t lastRecordingDraw = 0; + bool updatesAllowed = false; + uint32_t lastPowerCheck = 0; + bool idle = false; + uint32_t viewChangedAt = 0; + bool browsingPrepared = false; + uint32_t frameLockMs = 0; + uint32_t frameComposeMs = 0; + bool viewTimed = true; + bool waitingIdle = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/Navigator.cpp b/Apps/Maps/main/Source/app/Navigator.cpp new file mode 100644 index 0000000..ab0c839 --- /dev/null +++ b/Apps/Maps/main/Source/app/Navigator.cpp @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Navigator.h" + +#include "TileWorker.h" + +#include "../platform/Platform.h" +#include "../tiles/TileStore.h" + +#include +#include + +#include +#include +#include +#include + +namespace maps { + +namespace { + +constexpr uint32_t TASK_STACK = 12 * 1024; +constexpr UBaseType_t TASK_PRIORITY = 3; +constexpr char ROUTE_MAGIC[4] = { 'T', 'M', 'R', 'T' }; +constexpr uint16_t ROUTE_FILE_VERSION = 1; +/** Left free for the rest of the app while a search runs. */ +constexpr size_t KEEP_FREE_BYTES = 1024 * 1024; + +struct GraphReadJob { + TileStore* store; + TileKey tile; + FileBuffer* buffer; + bool found; + bool yielded; + /** The region the graph was found in last time, tried first. */ + int* lastRegion; + uint32_t findMs; + uint32_t readMs; +}; + +bool graphReadJob(IoTask& io, void* context) { + auto* job = static_cast(context); + TileFile file {}; + const uint32_t started = nowMs(); + job->found = job->store->find(job->tile, file) && file.tile == job->tile; + job->findMs = nowMs() - started; + if (!job->found) { + return false; + } + // Overlapping regions can hold the same tile: the first one whose graph is made already, the region that had the + // last tile's graph first (a route stays mostly in one region) + char path[PATH_MAX_LENGTH]; + const int count = job->store->regionCount(); + for (int n = -1; n < count; n++) { + const int i = n < 0 ? *job->lastRegion : n; + // Only regions whose area has the tile: a file lookup in a large region folder costs hundreds of ms + if (i < 0 || i >= count || (n >= 0 && i == *job->lastRegion) || !job->store->mayHave(i, job->tile)) { + continue; + } + if (preparedFilePath(path, sizeof(path), job->store->root(), job->store->regionName(i), job->tile, PreparedFile::Graph) && fileExists(path)) { + const uint32_t reading = nowMs(); + const bool ok = io.readFile(path, *job->buffer); + job->readMs = nowMs() - reading; + job->yielded = io.yielded(); + if (ok) { + *job->lastRegion = i; + } + return ok; + } + } + return false; +} + +void putU16(GrowArray& out, uint16_t value) { + out.push(static_cast(value)); + out.push(static_cast(value >> 8)); +} + +void putU32(GrowArray& out, uint32_t value) { + for (int i = 0; i < 4; i++) { + out.push(static_cast(value >> (8 * i))); + } +} + +uint32_t readU32(const uint8_t* bytes) { + return static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); +} + +} // namespace + +/** Reads graph files through the I/O task from the region that holds the tile. */ +class Navigator::CardSource final : public GraphSource { +public: + CardSource(IoTask& io, TileStore& store) : io(io), store(store) {} + ~CardSource() override = default; + bool readGraph(const TileKey& tile, GrowArray& file) override { + GraphReadJob job { &store, tile, &buffer, false, false, &lastRegion, 0, 0 }; + const uint32_t started = nowMs(); + bool ok = io.run(IoPriority::Visible, graphReadJob, &job); + if (nowMs() - started > 500) { + logInfo("Route: graph of %d/%d/%d read in %u ms (find %u, read %u, region %d, %u bytes)", tile.z, tile.x, tile.y, + static_cast(nowMs() - started), static_cast(job.findMs), static_cast(job.readMs), lastRegion, + static_cast(buffer.size())); + } + // A read that gave way to tiles on screen is read again + for (int attempt = 0; !ok && job.yielded && attempt < 20; attempt++) { + job.yielded = false; + ok = io.run(IoPriority::Visible, graphReadJob, &job); + } + if (!ok || !file.resize(buffer.size())) { + readFailures++; + logInfo("Route: no graph for %d/%d/%d (%s, yielded %d, %u bytes short, largest free block %u KB)", tile.z, tile.x, tile.y, + job.found ? "file not read" : "no tile", job.yielded ? 1 : 0, static_cast(buffer.lastShortfall()), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024)); + return false; + } + // The buffer stays for the next tile: allocating it again for each tile left PSRAM in pieces + memcpy(file.begin(), buffer.bytes(), buffer.size()); + return true; + } + bool memoryShort() override { + // The map draws tiles and places labels meanwhile: it keeps at least this much + return heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) < 256 * 1024 || heap_caps_get_free_size(MALLOC_CAP_SPIRAM) < 768 * 1024; + } + int readFailures = 0; + +private: + IoTask& io; + TileStore& store; + FileBuffer buffer; + int lastRegion = -1; +}; + +bool Navigator::start(IoTask& ioTask, TileStore& tileStore, TileWorker& tileWorker, const char* dataPath) { + io = &ioTask; + store = &tileStore; + worker = &tileWorker; + snprintf(routePath, sizeof(routePath), "%s/route.bin", dataPath); + wake = xSemaphoreCreateBinary(); + mutex = xSemaphoreCreateMutex(); + stopped = xSemaphoreCreateBinary(); + if (wake == nullptr || mutex == nullptr || stopped == nullptr) { + return false; + } + // The route of the last session + if (loadRoute()) { + logInfo("Route loaded: %s, %u points, %.0f m", routeProfileName(routes[current].profile), static_cast(routes[current].points.size()), + routes[current].lengthM); + } + if (xTaskCreatePinnedToCoreWithCaps(taskMain, "maps-route", TASK_STACK, this, TASK_PRIORITY, &task, tskNO_AFFINITY, MALLOC_CAP_SPIRAM) != pdPASS) { + task = nullptr; + return false; + } + return true; +} + +void Navigator::stop() { + if (task != nullptr) { + search.cancel(); + job = Job::Quit; + xSemaphoreGive(wake); + xSemaphoreTake(stopped, portMAX_DELAY); + vTaskDeleteWithCaps(task); + task = nullptr; + } + for (SemaphoreHandle_t* handle : { &wake, &mutex, &stopped }) { + if (*handle != nullptr) { + vSemaphoreDelete(*handle); + *handle = nullptr; + } + } +} + +void Navigator::lock() { + if (mutex != nullptr) { + xSemaphoreTake(mutex, portMAX_DELAY); + } +} + +void Navigator::unlock() { + if (mutex != nullptr) { + xSemaphoreGive(mutex); + } +} + +bool Navigator::request(double fromLat, double fromLon, double toLat, double toLon, RouteProfile profile) { + if (task == nullptr || currentState == State::Searching) { + return false; + } + requestFrom[0] = fromLat; + requestFrom[1] = fromLon; + requestTo[0] = toLat; + requestTo[1] = toLon; + requestProfile = profile; + currentState = State::Searching; + job = Job::Route; + xSemaphoreGive(wake); + return true; +} + +bool Navigator::bench(const char* pairsPath, RouteProfile profile) { + if (task == nullptr || currentState == State::Searching) { + return false; + } + snprintf(benchPath, sizeof(benchPath), "%s", pairsPath); + requestProfile = profile; + currentState = State::Searching; + job = Job::Bench; + xSemaphoreGive(wake); + return true; +} + +RouteResult Navigator::takeResult() { + const RouteResult value = result; + if (currentState == State::Done || currentState == State::Failed) { + currentState = State::Idle; + } + return value; +} + +void Navigator::clearRoute() { + lock(); + routes[current].clear(); + routeReady = false; + changes++; + unlock(); + auto remove = [&]() { + removeFile(routePath); + return true; + }; + runOnCard(*io, remove); +} + +void Navigator::taskMain(void* context) { + static_cast(context)->loop(); +} + +void Navigator::loop() { + while (true) { + xSemaphoreTake(wake, portMAX_DELAY); + const Job next = job.exchange(Job::None); + if (next == Job::Quit) { + break; + } + if (next == Job::Route) { + runRoute(); + } else if (next == Job::Bench) { + runBench(); + } + } + xSemaphoreGive(stopped); + vTaskSuspend(nullptr); +} + +bool Navigator::takeMemory() { + worker->setRouting(true); + // The node table is one block of up to 1 MB: the tile cache makes room for it, then the search takes what is free + // Down to 6 slots: enough for the tiles on screen while the user waits for the route + worker->relieveMemory(1100 * 1024, SEARCH_BYTES + KEEP_FREE_BYTES, 6); + const size_t free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + const size_t budget = std::min(SEARCH_BYTES, free > KEEP_FREE_BYTES ? free - KEEP_FREE_BYTES : 0); + if (budget < 512 * 1024 || !search.begin(budget)) { + logError("Route: no memory for a search (%u KB free, largest block %u KB)", static_cast(free / 1024), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024)); + search.end(); + worker->restoreMemory(); + worker->setRouting(false); + return false; + } + logInfo("Route: search memory %u KB, %u node states", static_cast(budget / 1024), static_cast(search.nodeCapacity())); + return true; +} + +void Navigator::giveMemory() { + search.end(); + worker->restoreMemory(); + worker->setRouting(false); +} + +void Navigator::runRoute() { + CardSource source(*io, *store); + // Memory may be short during a download: waits up to 10 s for it + bool memory = false; + for (int attempt = 0; attempt < 10 && !(memory = takeMemory()); attempt++) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + if (!memory) { + result = RouteResult::NoMemory; + currentState = State::Failed; + return; + } + Route& next = routes[1 - current]; + const RouteResult found = search.find(source, requestFrom[0], requestFrom[1], requestTo[0], requestTo[1], requestProfile, next, &stats); + giveMemory(); + logInfo("Route %s: %s, %.0f m, %.0f s, %u points, %u steps; %u ms (reading %u ms), settled %u, tiles %u (%u missing), peak %u KB%s", + routeProfileName(requestProfile), routeResultText(found), next.lengthM, next.seconds, static_cast(next.points.size()), + static_cast(next.steps.size()), stats.totalMs, stats.loadMs, stats.settled, stats.tilesRead, stats.tilesMissing, stats.peakBytes / 1024, + stats.passes > 1 ? ", second pass toward the goal" : ""); + result = found; + if (found == RouteResult::Found) { + lock(); + current = 1 - current; + routeReady = true; + changes++; + unlock(); + saveRoute(routes[current]); + routes[1 - current].clear(); + currentState = State::Done; + } else { + next.clear(); + currentState = State::Failed; + } +} + +void Navigator::runBench() { + FileBuffer file; + auto read = [&]() { return io->readFile(benchPath, file); }; + if (!runOnCard(*io, read)) { + logError("Route bench: %s does not read", benchPath); + result = RouteResult::NoPath; + currentState = State::Failed; + return; + } + if (!takeMemory()) { + result = RouteResult::NoMemory; + currentState = State::Failed; + return; + } + CardSource source(*io, *store); + Route& scratch = routes[1 - current]; + const int firstProfile = requestProfile == RouteProfile::Count ? 0 : static_cast(requestProfile); + const int lastProfile = requestProfile == RouteProfile::Count ? 2 : firstProfile; + for (int p = firstProfile; p <= lastProfile; p++) { + const auto profile = static_cast(p); + uint32_t found = 0; + uint32_t tooLong = 0; + uint32_t count = 0; + uint64_t ms = 0; + uint32_t maxMs = 0; + uint64_t settled = 0; + uint32_t maxSettled = 0; + uint64_t tiles = 0; + uint32_t maxTiles = 0; + uint32_t peak = 0; + const char* cursor = reinterpret_cast(file.bytes()); + const char* end = cursor + file.size(); + while (cursor < end) { + double a; + double b; + double c; + double d; + double cost; + int studySettled; + char line[160]; + const char* newline = static_cast(memchr(cursor, '\n', static_cast(end - cursor))); + const size_t length = std::min((newline != nullptr ? newline : end) - cursor, sizeof(line) - 1); + memcpy(line, cursor, length); + line[length] = '\0'; + cursor = newline != nullptr ? newline + 1 : end; + if (sscanf(line, "%lf %lf %lf %lf %lf %d", &a, &b, &c, &d, &cost, &studySettled) != 6) { + continue; + } + const RouteResult outcome = search.find(source, a, b, c, d, profile, scratch, &stats); + logInfo("Bench %s %u: %s, %.0f m (study %.0f), settled %u (study %d), tiles %u, %u ms (reading %u), peak %u KB, pass %u", routeProfileName(profile), + static_cast(count), routeResultText(outcome), scratch.lengthM, cost, stats.settled, studySettled, stats.tilesRead, stats.totalMs, + stats.loadMs, stats.peakBytes / 1024, static_cast(stats.passes)); + count++; + found += outcome == RouteResult::Found ? 1 : 0; + tooLong += outcome == RouteResult::TooLong ? 1 : 0; + ms += stats.totalMs; + maxMs = std::max(maxMs, stats.totalMs); + settled += stats.settled; + maxSettled = std::max(maxSettled, stats.settled); + tiles += stats.tilesRead; + maxTiles = std::max(maxTiles, stats.tilesRead); + peak = std::max(peak, stats.peakBytes); + scratch.clear(); + } + if (count > 0) { + logInfo("Bench %s: %u of %u found (%u too long); %u ms mean, %u max; settled %u mean, %u max; tiles %.1f mean, %u max; peak %u KB", + routeProfileName(profile), found, count, tooLong, static_cast(ms / count), maxMs, static_cast(settled / count), maxSettled, + static_cast(tiles) / count, maxTiles, peak / 1024); + } + } + giveMemory(); + result = RouteResult::Found; + currentState = State::Done; +} + +bool Navigator::saveRoute(const Route& route) { + GrowArray out; + for (char c : ROUTE_MAGIC) { + out.push(static_cast(c)); + } + putU16(out, ROUTE_FILE_VERSION); + putU16(out, static_cast(route.profile)); + putU32(out, static_cast(route.from.x)); + putU32(out, static_cast(route.from.y)); + putU32(out, static_cast(route.to.x)); + putU32(out, static_cast(route.to.y)); + putU32(out, static_cast(route.seconds)); + putU32(out, static_cast(route.points.size())); + putU32(out, static_cast(route.steps.size())); + putU32(out, static_cast(route.names.size())); + for (const RoutePoint& point : route.points) { + putU32(out, static_cast(point.x)); + putU32(out, static_cast(point.y)); + } + for (const RouteStep& step : route.steps) { + putU32(out, step.point); + putU16(out, step.nameOffset); + out.push(step.nameLength); + out.push(step.roadClass); + out.push(step.flags); + } + for (char c : route.names) { + out.push(static_cast(c)); + } + if (out.failed()) { + return false; + } + char temporary[PATH_MAX_LENGTH + 8]; + snprintf(temporary, sizeof(temporary), "%s.tmp", routePath); + auto write = [&]() { return io->writeFile(routePath, temporary, out.begin(), out.size(), false); }; + return runOnCard(*io, write); +} + +bool Navigator::loadRoute() { + FileBuffer file; + auto read = [&]() { return io->readFile(routePath, file); }; + if (!runOnCard(*io, read) || file.size() < 44 || memcmp(file.bytes(), ROUTE_MAGIC, 4) != 0) { + return false; + } + const uint8_t* data = file.bytes(); + if ((data[4] | (data[5] << 8)) != ROUTE_FILE_VERSION) { + return false; + } + Route& route = routes[current]; + route.clear(); + route.profile = static_cast(std::min(data[6], static_cast(RouteProfile::Car))); + route.from = { static_cast(readU32(data + 8)), static_cast(readU32(data + 12)) }; + route.to = { static_cast(readU32(data + 16)), static_cast(readU32(data + 20)) }; + route.seconds = static_cast(readU32(data + 24)); + const uint32_t points = readU32(data + 28); + const uint32_t steps = readU32(data + 32); + const uint32_t names = readU32(data + 36); + const size_t expected = 40 + static_cast(points) * 8 + static_cast(steps) * 9 + names; + if (expected != file.size() || points < 2) { + return false; + } + const uint8_t* cursor = data + 40; + for (uint32_t i = 0; i < points; i++, cursor += 8) { + route.points.push({ static_cast(readU32(cursor)), static_cast(readU32(cursor + 4)) }); + } + for (uint32_t i = 0; i < steps; i++, cursor += 9) { + route.steps.push({ readU32(cursor), static_cast(cursor[4] | (cursor[5] << 8)), cursor[6], cursor[7], cursor[8] }); + } + for (uint32_t i = 0; i < names; i++) { + route.names.push(static_cast(cursor[i])); + } + if (!route.distances.resize(points)) { + route.clear(); + return false; + } + float distance = 0.0f; + for (uint32_t i = 1; i < points; i++) { + const RoutePoint& a = route.points[i - 1]; + const RoutePoint& b = route.points[i]; + const float dx = static_cast(b.x - a.x); + const float dy = static_cast(b.y - a.y); + distance += std::sqrt(dx * dx + dy * dy) * static_cast(routeMetresPerUnit((a.y + b.y) / 2)); + route.distances[i] = distance; + } + route.lengthM = distance; + routeReady = !route.points.failed(); + changes++; + return routeReady; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/Navigator.h b/Apps/Maps/main/Source/app/Navigator.h new file mode 100644 index 0000000..3debbf8 --- /dev/null +++ b/Apps/Maps/main/Source/app/Navigator.h @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IoTask.h" + +#include "../route/RouteSearch.h" +#include "../tiles/TilePaths.h" + +#include +#include +#include + +#include +#include + +namespace maps { + +class TileStore; +class TileWorker; + +/** + * Finds routes on its own task (M5c-2) and keeps the current route, saved in route.bin so it survives a restart. The + * search borrows memory from the tile cache while it runs (TileWorker::relieveMemory) and gives it back afterwards. + * The map loop asks for a route with request(), then polls state(); the route is read between lock() and unlock(). + */ +class Navigator { +public: + enum class State : uint8_t { Idle, Searching, Done, Failed }; + + /** The memory a search may use: its node states (65,536), queue and the tiles' graphs. */ + static constexpr size_t SEARCH_BYTES = 2560 * 1024; + + bool start(IoTask& io, TileStore& store, TileWorker& worker, const char* dataPath); + void stop(); + + /** Starts a search; false while one runs. The result replaces the current route only when one is found. */ + bool request(double fromLat, double fromLon, double toLat, double toLon, RouteProfile profile); + /** Runs the pairs of a file (host/routing/export_pairs.py) with one profile or all (Count), logging each, for measurements. */ + bool bench(const char* pairsPath, RouteProfile profile); + void cancel() { search.cancel(); } + + State state() const { return currentState.load(); } + /** The last search's outcome and its message, for the toast. Reading it sets the state back to Idle. */ + RouteResult takeResult(); + const RouteStats& lastStats() const { return stats; } + /** Changes whenever the route changed (found, cleared or loaded). */ + uint32_t generation() const { return changes.load(); } + + bool hasRoute() const { return routeReady.load(); } + void lock(); + void unlock(); + /** The current route: only between lock() and unlock(). */ + const Route& route() const { return routes[current]; } + /** Drops the route and deletes route.bin. */ + void clearRoute(); + +private: + enum class Job : uint8_t { None, Route, Bench, Quit }; + + static void taskMain(void* context); + void loop(); + void runRoute(); + void runBench(); + bool takeMemory(); + void giveMemory(); + bool saveRoute(const Route& route); + bool loadRoute(); + + class CardSource; + + IoTask* io = nullptr; + TileStore* store = nullptr; + TileWorker* worker = nullptr; + char routePath[PATH_MAX_LENGTH] = ""; + char benchPath[PATH_MAX_LENGTH] = ""; + TaskHandle_t task = nullptr; + SemaphoreHandle_t wake = nullptr; + SemaphoreHandle_t mutex = nullptr; + SemaphoreHandle_t stopped = nullptr; + std::atomic job { Job::None }; + std::atomic currentState { State::Idle }; + std::atomic routeReady { false }; + std::atomic changes { 0 }; + RouteSearch search; + /** Two routes: the search fills the other one, which then becomes the current one. */ + Route routes[2]; + int current = 0; + RouteResult result = RouteResult::Found; + RouteStats stats {}; + double requestFrom[2] {}; + double requestTo[2] {}; + RouteProfile requestProfile = RouteProfile::Walk; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/PlatformTactility.cpp b/Apps/Maps/main/Source/app/PlatformTactility.cpp new file mode 100644 index 0000000..47014fb --- /dev/null +++ b/Apps/Maps/main/Source/app/PlatformTactility.cpp @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "../platform/Platform.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +constexpr auto* TAG = "Maps"; + +namespace maps { + +namespace { +/** What the app's own large blocks hold, so the memory log tells the app's share from the system's. */ +std::atomic largeBytes { 0 }; +std::atomic largeBlocks { 0 }; +} // namespace + +void* largeAlloc(size_t size) { + const MemoryPolicy policy = { + .required = 0, + .desired = MEMORY_CAPABILITY_EXTERNAL, + .alignment = 0, + }; + void* pointer = memory_alloc_with_policy(size, &policy); + if (pointer != nullptr) { + largeBytes += static_cast(heap_caps_get_allocated_size(pointer)); + largeBlocks++; + } + return pointer; +} + +void* fastAlloc(size_t size) { + const MemoryPolicy policy = { + .required = 0, + .desired = MEMORY_CAPABILITY_INTERNAL, + .alignment = 0, + }; + void* pointer = memory_alloc_with_policy(size, &policy); + if (pointer != nullptr) { + largeBytes += static_cast(heap_caps_get_allocated_size(pointer)); + largeBlocks++; + } + return pointer; +} + +void largeFree(void* pointer) { + if (pointer != nullptr) { + largeBytes -= static_cast(heap_caps_get_allocated_size(pointer)); + largeBlocks--; + memory_free(pointer); + } +} + +uint64_t nowUs() { + return static_cast(esp_timer_get_time()); +} + +uint32_t nowMs() { + return static_cast(esp_timer_get_time() / 1000); +} + +void logInfo(const char* format, ...) { + char message[160]; + va_list args; + va_start(args, format); + vsnprintf(message, sizeof(message), format, args); + va_end(args); + LOG_I(TAG, "%s", message); +} + +void logError(const char* format, ...) { + char message[160]; + va_list args; + va_start(args, format); + vsnprintf(message, sizeof(message), format, args); + va_end(args); + LOG_E(TAG, "%s", message); +} + +void logMemory(const char* label) { + LOG_I(TAG, "Memory %s: internal %u free (largest %u), PSRAM %u free (largest %u), app large blocks %u in %u", + label, + static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)), + static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)), + static_cast(largeBytes.load()), + static_cast(largeBlocks.load())); +} + +bool fileExists(const char* path) { + struct stat info {}; + return stat(path, &info) == 0 && S_ISREG(info.st_mode); +} + +bool fileSize(const char* path, uint32_t& size) { + struct stat info {}; + if (stat(path, &info) != 0 || !S_ISREG(info.st_mode)) { + return false; + } + size = static_cast(info.st_size); + return true; +} + +bool removeFile(const char* path) { + return remove(path) == 0; +} + +bool removeFolder(const char* path) { + return rmdir(path) == 0; +} + +bool listDirectory(const char* path, DirectoryCallback callback, void* context) { + DIR* dir = opendir(path); + if (dir == nullptr) { + return false; + } + while (const dirent* entry = readdir(dir)) { + if (entry->d_name[0] == '.') { + continue; + } + if (!callback(entry->d_name, entry->d_type == DT_DIR, context)) { + break; + } + } + closedir(dir); + return true; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/RootCertificates.h b/Apps/Maps/main/Source/app/RootCertificates.h new file mode 100644 index 0000000..0359935 --- /dev/null +++ b/Apps/Maps/main/Source/app/RootCertificates.h @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +namespace maps { + +/** + * Root certificates for the HTTPS connections to OpenFreeMap, which is served through Cloudflare with + * certificates from Google Trust Services. Let's Encrypt is included in case the tile host changes CA. + * Tactility's certificate bundle is not used, because OpenFreeMap sends GTS Root R4 cross-signed by an + * older GlobalSign root, and the bundle only checks the top of the chain it receives. + */ +constexpr const char ROOT_CERTIFICATES[] = + "-----BEGIN CERTIFICATE-----\n" + "MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw\n" + "CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n" + "MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n" + "MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n" + "Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA\n" + "A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo\n" + "27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w\n" + "Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw\n" + "TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl\n" + "qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH\n" + "szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8\n" + "Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk\n" + "MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92\n" + "wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p\n" + "aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN\n" + "VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID\n" + "AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E\n" + "FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb\n" + "C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe\n" + "QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy\n" + "h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4\n" + "7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J\n" + "ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef\n" + "MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/\n" + "Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT\n" + "6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ\n" + "0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm\n" + "2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb\n" + "bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c\n" + "-----END CERTIFICATE-----\n" + "-----BEGIN CERTIFICATE-----\n" + "MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n" + "VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n" + "A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n" + "WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n" + "IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n" + "AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi\n" + "QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR\n" + "HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n" + "BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D\n" + "9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n" + "p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n" + "-----END CERTIFICATE-----\n" + "-----BEGIN CERTIFICATE-----\n" + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n" + "-----BEGIN CERTIFICATE-----\n" + "MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw\n" + "CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg\n" + "R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00\n" + "MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT\n" + "ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw\n" + "EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW\n" + "+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9\n" + "ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T\n" + "AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI\n" + "zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW\n" + "tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1\n" + "/q4AaOeMSQ+2b1tbFfLn\n" + "-----END CERTIFICATE-----\n"; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/StallProbe.cpp b/Apps/Maps/main/Source/app/StallProbe.cpp new file mode 100644 index 0000000..bcf17e4 --- /dev/null +++ b/Apps/Maps/main/Source/app/StallProbe.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "StallProbe.h" + +#include "../platform/Platform.h" + +#include + +namespace maps { + +namespace { + +/** Above the app (5), the Wi-Fi driver's helpers and lwIP (18), below the Wi-Fi task and esp_timer. */ +constexpr UBaseType_t PROBE_PRIORITY = 19; +constexpr uint32_t PROBE_STACK = 3072; + +} // namespace + +bool StallProbe::startWatchdog() { + stopRequested = false; + watchdogOnly = true; + if (xTaskCreatePinnedToCore(probeMain, "maps-watchdog", PROBE_STACK, this, PROBE_PRIORITY, &tasks[0], tskNO_AFFINITY) != pdPASS) { + return false; + } + running++; + return true; +} + +bool StallProbe::start() { + stopRequested = false; + watchdogOnly = false; + for (int core = 0; core < 2; core++) { + if (xTaskCreatePinnedToCore(probeMain, core == 0 ? "maps-probe0" : "maps-probe1", PROBE_STACK, this, PROBE_PRIORITY, + &tasks[core], core) != pdPASS) { + stop(); + return false; + } + running++; + } + logInfo("Stall probes running on both cores, priority %u, gaps over %u ms are logged", static_cast(PROBE_PRIORITY), + static_cast(GAP_MS)); + return true; +} + +void StallProbe::stop() { + stopRequested = true; + while (running > 0) { + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +void StallProbe::watch(TaskHandle_t task, const char* name) { + if (task != nullptr && watchedCount < MAX_WATCHED) { + watched[watchedCount++] = { task, name }; + } +} + +void StallProbe::logTaskStates(uint32_t stalledMs) { + static const char* const STATES[] = { "running", "ready", "blocked", "suspended", "deleted", "invalid" }; + char line[200]; + int used = snprintf(line, sizeof(line), "Loop stalled %u ms in %s:", static_cast(stalledMs), currentPhase.load()); + for (int i = 0; i < watchedCount && used < static_cast(sizeof(line)); i++) { + TaskStatus_t status {}; + vTaskGetInfo(watched[i].task, &status, pdFALSE, eInvalid); + const int state = status.eCurrentState <= eInvalid ? static_cast(status.eCurrentState) : 5; + // The current priority above the base one means the task holds a lock a higher task waits for + used += snprintf(line + used, sizeof(line) - used, " %s %s %u/%u", watched[i].name, STATES[state], + static_cast(status.uxCurrentPriority), static_cast(status.uxBasePriority)); + } + logInfo("%s", line); +} + +void StallProbe::probeMain(void* context) { + auto* probe = static_cast(context); + const int core = xPortGetCoreID(); + const int64_t tickUs = static_cast(portTICK_PERIOD_MS) * 1000; + int64_t last = esp_timer_get_time(); + int64_t nextMemory = last; + while (probe->watchdogOnly && !probe->stopRequested) { + vTaskDelay(pdMS_TO_TICKS(250)); + const uint32_t beat = probe->lastBeat; + const auto nowMs = static_cast(esp_timer_get_time() / 1000); + if (beat != 0 && nowMs - beat > WATCHDOG_MS && probe->reportedBeat != beat) { + probe->reportedBeat = beat; + probe->logTaskStates(nowMs - beat); + logMemory("stall"); + } + } + while (!probe->watchdogOnly && !probe->stopRequested) { + vTaskDelay(1); + const int64_t now = esp_timer_get_time(); + const int64_t lateUs = now - last - tickUs; + if (lateUs > static_cast(GAP_MS) * 1000) { + logInfo("Stall on core %d: %u ms, from t=%u ms", core, static_cast(lateUs / 1000), static_cast(last / 1000)); + } + last = now; + // Core 1 looks at the map loop: when it stops coming round, what every app task is doing + const uint32_t beat = probe->lastBeat; + const uint32_t nowMs = static_cast(now / 1000); + if (core == 1 && beat != 0 && nowMs - beat > LOOP_STALL_MS && probe->reportedBeat != beat) { + probe->reportedBeat = beat; + probe->logTaskStates(nowMs - beat); + } + // Core 0 also samples memory once a second + if (core == 0 && now >= nextMemory) { + nextMemory = now + 1000000; + logMemory("sample"); + } + } + probe->running--; + vTaskDelete(nullptr); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/StallProbe.h b/Apps/Maps/main/Source/app/StallProbe.h new file mode 100644 index 0000000..1e317c7 --- /dev/null +++ b/Apps/Maps/main/Source/app/StallProbe.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +#include +#include + +namespace maps { + +/** + * A developer tool for finding stalls (setting dev_probe=1 in settings.txt). One small task per core, at a + * priority above every app task and the network stack, wakes every tick and logs when it was kept from running + * for longer than GAP_MS. If the probes stall together with the app, the core was held below the scheduler + * (interrupts or the flash cache off, as during a flash write); if only the app stalls, a task of higher priority + * than the app ran or the app waited for a lock. The probe on core 0 also logs free memory once a second. + */ +class StallProbe { +public: + static constexpr uint32_t GAP_MS = 40; + /** The map loop counts as stalled when it has not come round for this long. */ + static constexpr uint32_t LOOP_STALL_MS = 300; + static constexpr int MAX_WATCHED = 8; + static constexpr uint32_t WATCHDOG_MS = 3000; + + bool start(); + /** + * The quiet mode used when dev_probe is off: one task that only logs, once per stall, the step the map loop is in + * and the states of the watched tasks when the loop has not come round for WATCHDOG_MS. + */ + bool startWatchdog(); + /** The step of the map loop, logged with a stall; a string literal. */ + void phase(const char* name) { currentPhase = name; } + void stop(); + /** A task whose state is logged when the map loop stalls. Call before start(). */ + void watch(TaskHandle_t task, const char* name); + /** Called by the map loop on every round. */ + void heartbeat(uint32_t nowMs) { lastBeat = nowMs; } + +private: + static void probeMain(void* context); + void logTaskStates(uint32_t stalledMs); + + struct Watched { + TaskHandle_t task; + const char* name; + }; + Watched watched[MAX_WATCHED] {}; + int watchedCount = 0; + std::atomic lastBeat = 0; + uint32_t reportedBeat = 0; + std::atomic currentPhase = "start"; + bool watchdogOnly = false; + + TaskHandle_t tasks[2] {}; + std::atomic stopRequested = false; + std::atomic running = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/TileWorker.cpp b/Apps/Maps/main/Source/app/TileWorker.cpp new file mode 100644 index 0000000..15b7a03 --- /dev/null +++ b/Apps/Maps/main/Source/app/TileWorker.cpp @@ -0,0 +1,1379 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "TileWorker.h" +#include "Downloader.h" + +#include "../platform/Platform.h" +#include "../render/TileCodec.h" + +#include +#include + +#include +#include +#include + +namespace maps { + +namespace { + +/** Rendering, and conversions, where ROM tdefl alone needs about 3-4 KB of stack. */ +/** tdefl, which deflates prepared tiles, keeps about 6 KB of Huffman tables on the stack: 8 KB left 600 bytes. */ +constexpr uint32_t TASK_STACK = 10240; +/** Cache slots kept free of wanted tiles, for the ancestors magnified while a tile renders. */ +constexpr int CACHE_SPARE_SLOTS = 6; +/** Lanes keep a file buffer this large between tiles, larger ones (dense z14 or low-zoom tiles) are freed after use. */ +constexpr size_t KEEP_FILE_BUFFER = 256 * 1024; +constexpr UBaseType_t TASK_PRIORITY = 4; +constexpr uint32_t IDLE_WAIT_MS = 30; +/** A conversion starts only with a PSRAM block this large free (the compressor needs 320 KB in one block), and this + * much PSRAM free in all for the original, the features and the labels. */ +constexpr size_t CONVERT_MIN_BLOCK = 336 * 1024; +constexpr size_t CONVERT_MIN_FREE = 900 * 1024; + +bool memoryForConversion() { + return heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) >= CONVERT_MIN_BLOCK && + heap_caps_get_free_size(MALLOC_CAP_SPIRAM) >= CONVERT_MIN_FREE; +} +constexpr char CACHE_MAGIC[4] = { 'T', 'M', 'C', '2' }; +constexpr size_t CACHE_HEADER_BYTES = 12; +constexpr size_t TILE_PIXELS = static_cast(TILE_SIZE) * TILE_SIZE; +constexpr size_t ENCODE_CAPACITY = TILE_PIXELS + 256; +/** Enough entries for the 200 MB cap: a dense rendered tile codes to about 96 KB. */ +constexpr int CACHE_INDEX_ENTRIES = 6000; +constexpr int SAVE_INDEX_EVERY_WRITES = 25; + +/** Checks a rendered tile file from the SD card cache. Returns the number of coded words, 0 when unusable. */ +uint32_t checkCacheFile(const uint8_t* bytes, size_t size, int& sourceZoom) { + uint32_t version = 0; + uint32_t words = 0; + if (size < CACHE_HEADER_BYTES || memcmp(bytes, CACHE_MAGIC, 4) != 0) { + return 0; + } + memcpy(&version, bytes + 4, 4); + memcpy(&words, bytes + 8, 4); + // The top byte of the version holds the zoom level of the vector tile it was drawn from + sourceZoom = static_cast(version >> 24); + if ((version & 0xFFFFFF) != STYLE_VERSION || size != CACHE_HEADER_BYTES + static_cast(words) * 2) { + return 0; + } + return words; +} + +struct WriteCacheJob { + const char* root; + const char* style; + TileKey tile; + const uint8_t* bytes; + size_t size; +}; + +bool writeCacheJob(IoTask& io, void* context) { + auto* job = static_cast(context); + char path[PATH_MAX_LENGTH]; + char folder[PATH_MAX_LENGTH]; + char temporary[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/cache", job->root); + mkdir(folder, 0777); + snprintf(folder, sizeof(folder), "%s/cache/%s", job->root, job->style); + mkdir(folder, 0777); + snprintf(folder, sizeof(folder), "%s/cache/%s/%d", job->root, job->style, job->tile.z); + mkdir(folder, 0777); + if (!cacheFolderPath(folder, sizeof(folder), job->root, job->style, job->tile) || + !cachePath(path, sizeof(path), job->root, job->style, job->tile)) { + return false; + } + mkdir(folder, 0777); + snprintf(temporary, sizeof(temporary), "%s.tmp", path); + return io.writeFile(path, temporary, job->bytes, job->size, true); +} + +struct IndexFileJob { + const char* root; + const char* style; + FileBuffer* buffer; + const ByteWriter* bytes; +}; + +bool readIndexJob(IoTask& io, void* context) { + auto* job = static_cast(context); + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/cache/%s/index.bin", job->root, job->style); + return io.readFile(path, *job->buffer); +} + +bool writeIndexJob(IoTask& io, void* context) { + auto* job = static_cast(context); + char path[PATH_MAX_LENGTH]; + char temporary[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/cache", job->root); + mkdir(path, 0777); + snprintf(path, sizeof(path), "%s/cache/%s", job->root, job->style); + mkdir(path, 0777); + snprintf(path, sizeof(path), "%s/cache/%s/index.bin", job->root, job->style); + snprintf(temporary, sizeof(temporary), "%s/cache/%s/index.tmp", job->root, job->style); + return io.writeFile(path, temporary, job->bytes->bytes(), job->bytes->size(), false); +} + +struct EvictJob { + const char* root; + const char* style; + TileKey tile; +}; + +bool evictJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + char path[PATH_MAX_LENGTH]; + // Only ever a file of the cache folder, never anything under maps/ + if (!cachePath(path, sizeof(path), job->root, job->style, job->tile) || !isInsideCache(path, job->root)) { + return false; + } + removeFile(path); + return true; +} + +struct WalkJob { + CacheWalker* walker; + CacheIndex* index; + const CacheWalker::IndexLock* lock; + bool more; +}; + +bool walkJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + job->more = job->walker->step(*job->index, job->lock); + return true; +} + +struct ConvertReadJob { + const char* root; + const char* region; + TileKey tile; + FileBuffer* buffer; + uint32_t size; + bool current; + /** The files the tile needs (PART_* of PreparedTile.h). */ + uint8_t parts; + bool yielded; + /** The original is not there (any more): moved into another region or deleted. */ + bool missing; +}; + +bool convertReadJob(IoTask& io, void* context) { + auto* job = static_cast(context); + char path[PATH_MAX_LENGTH]; + char prepared[PATH_MAX_LENGTH]; + if (!tilePath(path, sizeof(path), job->root, job->region, job->tile) || + !preparedPath(prepared, sizeof(prepared), job->root, job->region, job->tile)) { + return false; + } + if (!fileSize(path, job->size)) { + job->missing = true; + return false; + } + // Queued twice, or converted by the other lane meanwhile + job->parts = PreparedScanner::partsNeeded(prepared, job->tile, job->size); + job->current = job->parts == 0; + const bool ok = job->current || io.readFile(path, *job->buffer); + job->yielded = io.yielded(); + return ok; +} + +struct ChunkReadJob { + const char* path; + uint32_t offset; + uint8_t* buffer; + size_t size; + size_t count; +}; + +bool chunkReadJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + FILE* file = fopen(job->path, "rb"); + if (file == nullptr) { + return false; + } + const bool ok = fseek(file, static_cast(job->offset), SEEK_SET) == 0; + job->count = ok ? fread(job->buffer, 1, job->size, file) : 0; + const bool failed = !ok || (job->count == 0 && ferror(file)); + fclose(file); + return !failed; +} + +/** + * Reads a file a chunk at a time through the I/O task, for originals too large for one block of PSRAM (low-zoom tiles + * of 1 MB and more). Each chunk is its own job, so tiles on screen are read in between. + */ +class CardInput final : public ByteInput { + IoTask* io; + const char* path; + IoPriority priority; + uint8_t* chunk = nullptr; + uint32_t chunkOffset = 0; + size_t chunkLength = 0; + size_t chunkPosition = 0; + bool failed = false; + +public: + CardInput(IoTask* io, const char* path, IoPriority priority = IoPriority::Background) : io(io), path(path), priority(priority) { + chunk = static_cast(largeAlloc(IoTask::CHUNK_BYTES)); + } + ~CardInput() override { largeFree(chunk); } + + bool isReady() const { return chunk != nullptr; } + + int read(uint8_t* buffer, size_t size) override { + size_t done = 0; + while (done < size && !failed) { + if (chunkPosition == chunkLength) { + ChunkReadJob job { path, chunkOffset + static_cast(chunkLength), chunk, IoTask::CHUNK_BYTES, 0 }; + if (!io->run(priority, chunkReadJob, &job)) { + failed = true; + break; + } + chunkOffset = job.offset; + chunkLength = job.count; + chunkPosition = 0; + if (chunkLength == 0) { + break; + } + } + const size_t n = std::min(size - done, chunkLength - chunkPosition); + memcpy(buffer + done, chunk + chunkPosition, n); + chunkPosition += n; + done += n; + } + return failed && done == 0 ? -1 : static_cast(done); + } + + bool rewind() override { + chunkOffset = 0; + chunkLength = 0; + chunkPosition = 0; + failed = false; + return true; + } +}; + +struct ConvertWriteJob { + const char* root; + const char* region; + TileKey tile; + /** nullptr for a file that is current. */ + const ByteWriter* bytes; + const ByteWriter* labels; + const ByteWriter* graph; + bool yielded; +}; + +/** Writes one prepared file through its temporary name. */ +bool writePreparedFile(IoTask& io, const ConvertWriteJob& job, PreparedFile file, const ByteWriter& bytes) { + char path[PATH_MAX_LENGTH]; + char temporary[PATH_MAX_LENGTH]; + return preparedFilePath(path, sizeof(path), job.root, job.region, job.tile, file) && + preparedFileTempPath(temporary, sizeof(temporary), job.root, job.region, job.tile, file) && + io.writeFile(path, temporary, bytes.bytes(), bytes.size(), true); +} + +/** + * Records in the .prt header that its companion files are current now: byte 9 is the label file version, byte 7 the + * graph file version. Two bytes in one sector, written in place, for a companion made again without the geometry. + */ +bool markCompanions(const char* path) { + FILE* file = fopen(path, "r+b"); + if (file == nullptr) { + return false; + } + const uint8_t graph = PREPARED_GRAPH_FORMAT_VERSION; + const uint8_t label = PREPARED_LABEL_FORMAT_VERSION; + const bool ok = fseek(file, 7, SEEK_SET) == 0 && fwrite(&graph, 1, 1, file) == 1 && fseek(file, 9, SEEK_SET) == 0 && + fwrite(&label, 1, 1, file) == 1; + return fclose(file) == 0 && ok; +} + +bool convertWriteJob(IoTask& io, void* context) { + auto* job = static_cast(context); + // The companions first: a .prt of the new version says they are there + bool ok = job->labels == nullptr || writePreparedFile(io, *job, PreparedFile::Labels, *job->labels); + ok = ok && (job->graph == nullptr || writePreparedFile(io, *job, PreparedFile::Graph, *job->graph)); + if (ok && job->bytes != nullptr) { + ok = writePreparedFile(io, *job, PreparedFile::Geometry, *job->bytes); + } else if (ok) { + char path[PATH_MAX_LENGTH]; + ok = preparedFilePath(path, sizeof(path), job->root, job->region, job->tile, PreparedFile::Geometry) && markCompanions(path); + } + job->yielded = io.yielded(); + return ok; +} + +struct LabelReadJob { + const char* root; + const char* region; + TileKey tile; + bool numbers; + FileBuffer* buffer; + PreparedLabelHeader header; + bool yielded; +}; + +/** Reads the header of a .prl file and its labels, with the house numbers only when they are wanted. */ +bool labelReadJob(IoTask& io, void* context) { + auto* job = static_cast(context); + char path[PATH_MAX_LENGTH]; + if (!preparedFilePath(path, sizeof(path), job->root, job->region, job->tile, PreparedFile::Labels)) { + return false; + } + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return false; + } + uint8_t head[PREPARED_LABEL_HEADER_SIZE]; + const bool read = fread(head, 1, sizeof(head), file) == sizeof(head); + fclose(file); + if (!read || !readPreparedLabelHeader(head, sizeof(head), job->header) || !(job->header.tile == job->tile) || + job->header.version != PREPARED_LABEL_FORMAT_VERSION) { + return false; + } + const size_t wanted = PREPARED_LABEL_HEADER_SIZE + LABEL_INDEX_SIZE + job->header.mainStored + (job->numbers ? job->header.numberStored : 0); + const bool ok = io.readFile(path, *job->buffer, wanted); + job->yielded = io.yielded(); + return ok; +} + +struct ScanJob { + PreparedScanner* scanner; + const TileStore* store; + PreparedScanner::StaleCallback callback; + void* result; + bool more; +}; + +bool scanJob(IoTask& io, void* context) { + (void)io; + auto* job = static_cast(context); + job->more = job->scanner->step(*job->store, job->callback, job->result); + return true; +} + +} // namespace + +bool TileWorker::relieveMemory(size_t bytes, size_t totalBytes, int minSlots) { + lock(); + const int before = tileCache.slotCount(); + // Never fewer than the tiles on screen (9 in a turned view), or they are drawn again and again + const int keep = std::max(minSlots > 0 ? minSlots : MIN_CACHE_SLOTS, visibleWanted); + while ((heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) < bytes || heap_caps_get_free_size(MALLOC_CAP_SPIRAM) < totalBytes) && + tileCache.slotCount() > keep && tileCache.freeSlot()) { + } + const int after = tileCache.slotCount(); + unlock(); + const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM); + logInfo("Memory for a %u KB block: %d of %d cache slots freed, largest block %u KB", static_cast(bytes / 1024), before - after, + before, static_cast(largest / 1024)); + return largest >= bytes; +} + +bool TileWorker::restoreMemory() { + lock(); + const int missing = tileCache.regrow(); + const int slots = tileCache.slotCount(); + unlock(); + if (missing == 0) { + logInfo("Tile cache back to %d slots", slots); + } + return missing == 0; +} + +bool TileWorker::start(TileStore* tileStore, IoTask* ioTask, const Palette* tilePalette, int cacheSlots, const char* cacheRoot) { + store = tileStore; + io = ioTask; + palette = tilePalette; + snprintf(root, sizeof(root), "%s", cacheRoot); + mutex = xSemaphoreCreateMutex(); + stopped = xSemaphoreCreateCounting(LANES, 0); + if (mutex == nullptr || stopped == nullptr || tileCache.init(cacheSlots) < 4) { + logError("Tile worker: out of memory"); + return false; + } + logInfo("Tile cache: %d slots of %d KB", tileCache.slotCount(), TileCache::TILE_BYTES / 1024); + styleCacheId(*palette, styleId, sizeof(styleId)); + if (!cacheIndex.init(CACHE_INDEX_ENTRIES)) { + logError("Tile worker: no memory for the cache index"); + return false; + } + // The index of the SD card cache, rebuilt from the files in idle time when it is missing + FileBuffer indexFile; + IndexFileJob read { root, styleId, &indexFile, nullptr }; + if (io->run(IoPriority::Visible, readIndexJob, &read) && cacheIndex.load(indexFile.bytes(), indexFile.size())) { + logInfo("SD cache %s (%s): %d tiles, %u KB", styleId, sdCache ? "on" : "off", cacheIndex.size(), + static_cast(cacheIndex.totalBytes() / 1024)); + } else { + logInfo("SD cache %s (%s): no index yet", styleId, sdCache ? "on" : "off"); + } + // With the SD card cache off its files go too, they only take space + cacheWalker.begin(root, styleId, sdCache); + if (!sdCache) { + cacheIndex.clear(); + } + stopRequested = false; + laneCount = 0; + for (int i = 0; i < LANES; i++) { + Lane& lane = lanes[i]; + lane.worker = this; + lane.index = i; + lane.active = false; + lane.converting = false; + lane.prefetching = false; + lane.cancel = false; + lane.renderer.setCancelFlag(&lane.cancel); + if (labelStore != nullptr) { + lane.renderer.setLabelHook([](const TileKey& source, const uint8_t* section, size_t size, int count, void* context) { + static_cast(context)->put(source, section, size, count); + }, labelStore); + } + lane.writeBuffer = static_cast(largeAlloc(TileCache::TILE_BYTES)); + lane.encodeBuffer = static_cast(largeAlloc(ENCODE_CAPACITY * sizeof(uint16_t) + CACHE_HEADER_BYTES)); + if (lane.writeBuffer == nullptr || lane.encodeBuffer == nullptr || !lane.renderer.init()) { + logError("Tile worker: no memory for render lane %d", i); + break; + } + char name[16]; + snprintf(name, sizeof(name), "maps-render%d", i); + if (xTaskCreatePinnedToCore(taskMain, name, TASK_STACK, &lane, TASK_PRIORITY, &lane.task, i) != pdPASS) { + logError("Tile worker: task %d not started", i); + break; + } + laneCount++; + } + logMemory("render lanes started"); + return laneCount > 0; +} + +void TileWorker::stop() { + stopRequested = true; + for (int i = 0; i < laneCount; i++) { + xSemaphoreTake(stopped, portMAX_DELAY); + } + if (laneCount > 0) { + saveCacheIndex(); + } + for (int i = 0; i < LANES; i++) { + largeFree(lanes[i].writeBuffer); + lanes[i].writeBuffer = nullptr; + largeFree(lanes[i].encodeBuffer); + lanes[i].encodeBuffer = nullptr; + } + laneCount = 0; + if (stopped != nullptr) { + vSemaphoreDelete(stopped); + stopped = nullptr; + } + if (mutex != nullptr) { + vSemaphoreDelete(mutex); + mutex = nullptr; + } +} + +void TileWorker::taskMain(void* context) { + auto* lane = static_cast(context); + TileWorker* worker = lane->worker; + worker->loop(*lane); + xSemaphoreGive(worker->stopped); + vTaskDelete(nullptr); +} + +void TileWorker::want(const TileKey* tiles, int count, int visibleCount) { + lock(); + // Fewer than the cache holds, leaving room for the magnified stand-ins: wanting more made the ring around the + // screen evict the visible tiles, which were then rendered again, several times over + const int room = std::max(1, tileCache.slotCount() - CACHE_SPARE_SLOTS); + wantedCount = std::min({ count, static_cast(MAX_WANTED), room }); + visibleWanted = visibleCount < wantedCount ? visibleCount : wantedCount; + memcpy(wanted, tiles, sizeof(TileKey) * wantedCount); + // The visible tiles count as just used, so tiles rendered for the ring evict others first. One that was drawn + // from coarser data while it was still in the ring is a gap now that it is on screen. + for (int i = 0; i < visibleWanted; i++) { + tileCache.find(wanted[i]); + const int sourceZoom = tileCache.sourceZoomOf(wanted[i]); + const int bestZoom = wanted[i].z < MAX_SOURCE_ZOOM ? wanted[i].z : MAX_SOURCE_ZOOM; + if (sourceZoom >= 0 && sourceZoom < bestZoom) { + addGap(ancestorOf(wanted[i], bestZoom)); + } + } + unlock(); +} + +bool TileWorker::isSettled(const TileKey& tile) const { + if (tileCache.contains(tile)) { + return true; + } + for (int i = 0; i < missingCount; i++) { + if (missing[i] == tile) { + return true; + } + } + return false; +} + +void TileWorker::tilesArrived(const TileKey* tiles, int count) { + lock(); + missingCount = 0; + missingNext = 0; + if (count >= 0) { + tileCache.dropMagnified(tiles, count); + } else if (conversionsPaused) { + magnifiedOutdated = true; + } else { + tileCache.dropMagnified(); + } + unlock(); + // New originals get their prepared copies in idle time + rescanRequested = true; + changed = true; +} + +void TileWorker::setConversionsPaused(bool paused) { + if (paused == conversionsPaused) { + return; + } + lock(); + conversionsPaused = paused; + if (!paused) { + // The tiles of the download are prepared once the map was left alone for a while from now on + idleSince = xTaskGetTickCount(); + } + if (!paused && magnifiedOutdated) { + magnifiedOutdated = false; + tileCache.dropMagnified(); + changed = true; + } + unlock(); +} + +bool TileWorker::queueConversion(const TileKey& tile, int region, bool urgent, bool split) { + for (int i = 0; i < failedConversionCount; i++) { + if (failedConversions[i] == tile) { + return true; + } + } + for (int i = 0; i < conversionCount; i++) { + if (conversions[i].tile == tile) { + conversions[i].split = conversions[i].split || split; + return true; + } + } + for (int i = 0; i < laneCount; i++) { + if (lanes[i].converting && lanes[i].convertingTile == tile) { + return true; + } + } + if (urgent) { + const int kept = conversionCount < MAX_CONVERSIONS ? conversionCount : MAX_CONVERSIONS - 1; + for (int i = kept; i > 0; i--) { + conversions[i] = conversions[i - 1]; + } + conversions[0] = { tile, region, true, split }; + conversionCount = kept + 1; + return true; + } + if (conversionCount >= MAX_CONVERSIONS) { + return false; + } + conversions[conversionCount++] = { tile, region, false, split }; + return true; +} + +bool TileWorker::collectFromScan(const TileKey& tile, int region, bool split, void* context) { + auto* found = static_cast(context); + if (found->count >= found->room) { + return false; + } + found->items[found->count++] = { tile, region, false, split }; + return true; +} + +bool TileWorker::loadLabels(Lane& lane) { + LabelStore::Request request {}; + if (labelStore == nullptr || !labelStore->takeRequest(request)) { + return false; + } + char region[REGION_NAME_MAX + 1] = ""; + lock(); + if (request.region >= 0 && request.region < store->regionCount()) { + snprintf(region, sizeof(region), "%s", store->regionName(request.region)); + } + unlock(); + if (region[0] == '\0') { + labelStore->done(request.tile); + return true; + } + // However it ends, the request is no longer being read + struct Done { + LabelStore* store; + TileKey tile; + ~Done() { store->done(tile); } + } done { labelStore, request.tile }; + const uint32_t start = nowMs(); + // The lane's file buffer: the file it held for its last tile is read again when needed + lane.heldKind = static_cast(Found::Nothing); + LabelReadJob read { root, region, request.tile, request.numbers, &lane.fileBuffer, {}, false }; + const bool ok = io->run(IoPriority::Visible, labelReadJob, &read); + if (!ok) { + if (read.yielded) { + labelStore->done(request.tile); + labelStore->request(request.tile, request.region); + } + return true; + } + const uint32_t loaded = nowMs(); + const PreparedLabelHeader& header = read.header; + const size_t size = header.mainSize + (request.numbers ? header.numberSize : 0); + const int count = static_cast(header.mainCount + (request.numbers ? header.numberCount : 0)); + // The cells first, then the records; a tile without labels keeps an empty entry, so it is not asked for again + auto* block = static_cast(largeAlloc(sizeof(LabelCells) + size)); + bool inflated = block != nullptr && + readLabelCells(lane.fileBuffer.bytes() + PREPARED_LABEL_HEADER_SIZE, header.mainSize, header.numberSize, request.numbers, + *reinterpret_cast(block)); + uint8_t* bytes = block != nullptr ? block + sizeof(LabelCells) : nullptr; + const uint8_t* stored = lane.fileBuffer.bytes() + PREPARED_LABEL_HEADER_SIZE + LABEL_INDEX_SIZE; + const bool deflated = (header.flags & PREPARED_FLAG_DEFLATED) != 0; + // Part A, then part B right after it, so the records read as one section + const uint32_t partSize[2] = { header.mainSize, request.numbers ? header.numberSize : 0 }; + const uint32_t partStored[2] = { header.mainStored, request.numbers ? header.numberStored : 0 }; + size_t offset = 0; + for (int part = 0; part < 2 && inflated; part++) { + if (partSize[part] == 0) { + stored += partStored[part]; + continue; + } + if (deflated) { + MemoryInput input(stored, partStored[part]); + inflated = lane.renderer.tileDecoder().inflate(&input, bytes + offset, partSize[part]); + } else { + inflated = partStored[part] == partSize[part]; + if (inflated) { + memcpy(bytes + offset, stored, partSize[part]); + } + } + stored += partStored[part]; + offset += partSize[part]; + } + lane.fileBuffer.trim(KEEP_FILE_BUFFER); + if (!inflated) { + largeFree(block); + logError("[%d] Labels of %d/%d/%d do not read", lane.index, request.tile.z, request.tile.x, request.tile.y); + return true; + } + labelStore->adopt(request.tile, block, size, count, request.numbers, request.region); + logInfo("[%d] Labels %d/%d/%d%s: %u bytes, read %u ms, inflate %u ms", lane.index, request.tile.z, request.tile.x, request.tile.y, + request.numbers ? " with house numbers" : "", static_cast(size), static_cast(loaded - start), + static_cast(nowMs() - loaded)); + return true; +} + +bool TileWorker::convertOne(Lane& lane) { + // The compressor needs one 320 KB block of PSRAM, which a download leaves too fragmented to find + if (conversionsPaused || routing) { + return false; + } + if (lane.trimBuffers.exchange(false)) { + lane.heldKind = static_cast(Found::Nothing); + lane.fileBuffer.trim(0); + lane.converter.trim(0); + lane.converted.release(); + } + const TickType_t now = xTaskGetTickCount(); + if (static_cast(now - conversionsWaitUntil.load()) < 0) { + return false; + } + // Tiles not on screen wait until the map was left alone for a while: they are queued now, prepared later + const bool settled = now - idleSince.load() >= pdMS_TO_TICKS(BACKGROUND_PREPARE_MS); + lock(); + Conversion job {}; + bool found = false; + bool otherConverting = false; + for (int i = 0; i < laneCount; i++) { + otherConverting = otherConverting || lanes[i].converting; + } + if (conversionCount > 0 && !otherConverting && (conversions[0].urgent || settled)) { + if (!memoryForConversion()) { + // The tile cache gives up its least recently used slots for it (they come back when nothing is left to + // prepare). Without enough memory even then the tile stays queued, and the next try is after a pause + const int queued = conversionCount; + const bool firstTry = !lane.triedTrim; + lane.triedTrim = true; + for (int i = 0; i < laneCount && firstTry; i++) { + lanes[i].trimBuffers = true; + } + unlock(); + if (firstTry) { + // The lanes free their kept buffers first, then this is tried again + return true; + } + if (relieveMemory(CONVERT_MIN_BLOCK) && memoryForConversion()) { + return true; + } + conversionsWaitUntil = now + pdMS_TO_TICKS(MEMORY_BACKOFF_MS); + lane.triedTrim = false; + logInfo("Preparing tiles waits for memory (largest free PSRAM block %u KB, %u KB free, %d queued)", + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024), + static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024), queued); + return false; + } + lane.triedTrim = false; + // One conversion at a time: each needs about 0.4 MB of PSRAM, which a download needs as well. + // The tiles on screen first, then the oldest, so the tiles just drawn from originals come before the scanned ones + job = conversions[0]; + for (int i = 1; i < conversionCount; i++) { + conversions[i - 1] = conversions[i]; + } + conversionCount--; + found = true; + lane.converting = true; + lane.convertingTile = job.tile; + } else if (settled && (rescanRequested || !scanner.isComplete()) && !scanning) { + // Only one lane walks the folders at a time, and only that lane touches the scanner + scanning = true; + } else { + unlock(); + return false; + } + char region[REGION_NAME_MAX + 1] = ""; + char version[48] = ""; + if (found && job.region < store->regionCount()) { + snprintf(region, sizeof(region), "%s", store->regionName(job.region)); + snprintf(version, sizeof(version), "%s", store->regionVersion(job.region)); + } + unlock(); + + if (!found) { + if (rescanRequested.exchange(false)) { + scanner.restart(); + } + // Kept in the worker, not on the lane's small stack, only the scanning lane uses it + ScanResult& result = scanResult; + result.count = 0; + lock(); + result.room = MAX_CONVERSIONS - conversionCount; + unlock(); + ScanJob scan { &scanner, store, collectFromScan, &result, false }; + io->run(IoPriority::Background, scanJob, &scan); + lock(); + for (int i = 0; i < result.count; i++) { + queueConversion(result.items[i].tile, result.items[i].region, false, result.items[i].split); + } + scanning = false; + unlock(); + return scan.more || result.count > 0; + } + + const uint32_t start = nowMs(); + // The conversion reuses the lane's buffer + lane.heldKind = static_cast(Found::Nothing); + ConvertReadJob read { root, region, job.tile, &lane.fileBuffer, 0, false, 0, false, region[0] == '\0' }; + bool ok = region[0] != '\0' && io->run(IoPriority::Background, convertReadJob, &read); + if (read.missing) { + // The scan finds it again where it is now + logInfo("[%d] %d/%d/%d is no longer in %s, not prepared there", lane.index, job.tile.z, job.tile.x, job.tile.y, + region[0] != '\0' ? region : "its region"); + lane.fileBuffer.trim(KEEP_FILE_BUFFER); + lock(); + lane.converting = false; + unlock(); + return true; + } + const uint32_t loaded = nowMs(); + bool requeue = read.yielded; + const char* reason = "reading the card"; + // No block of PSRAM was large enough for the whole original: it is read a chunk at a time instead + const bool streamed = !ok && !requeue && read.size > 0; + if (streamed) { + lane.fileBuffer.trim(0); + } + if ((ok && !read.current) || streamed) { + ConvertStats stats {}; + const ByteWriter* writeGeometry = nullptr; + const ByteWriter* writeLabels = nullptr; + const ByteWriter* writeGraph = nullptr; + GraphStats graphStats {}; + char path[PATH_MAX_LENGTH]; + CardInput cardInput(io, path); + MemoryInput memoryInput(lane.fileBuffer.bytes(), lane.fileBuffer.size()); + ByteInput* input = &memoryInput; + if (streamed) { + ok = tilePath(path, sizeof(path), root, region, job.tile) && cardInput.isReady(); + input = &cardInput; + } + ByteWriter* geometry = (read.parts & PART_GEOMETRY) != 0 ? &lane.converted : nullptr; + ByteWriter* labels = (read.parts & PART_LABELS) != 0 ? &lane.convertedLabels : nullptr; + if (ok && (geometry != nullptr || labels != nullptr)) { + ok = lane.converter.convert(lane.renderer.tileDecoder(), input, job.tile, read.size, version, deflatePrepared, geometry, labels, &stats); + reason = ok ? "writing the card" : lane.converter.failure(); + writeGeometry = geometry; + writeLabels = labels; + } + if (ok && (read.parts & PART_GRAPH) != 0) { + // The routing graph of a z14 tile, from the original read again from the start + CardInput graphCard(io, path); + MemoryInput graphMemory(lane.fileBuffer.bytes(), lane.fileBuffer.size()); + ByteInput* graphInput = streamed ? static_cast(&graphCard) : &graphMemory; + ok = lane.graphBuilder.build(lane.renderer.tileDecoder(), graphInput, job.tile, read.size, version, true, lane.convertedGraph, &graphStats); + reason = ok ? "writing the card" : lane.graphBuilder.failure(); + writeGraph = &lane.convertedGraph; + } + const uint32_t convertedAt = nowMs(); + ConvertWriteJob write { root, region, job.tile, writeGeometry, writeLabels, writeGraph, false }; + ok = ok && io->run(IoPriority::Background, convertWriteJob, &write); + requeue = write.yielded; + if (ok) { + logInfo("[%d] %s %d/%d/%d%s: %u -> %u + %u + %u bytes (.prt + .prl + .prg), read %u ms, convert %u ms (graph %u ms, %u nodes, " + "%u edges), write %u ms", + lane.index, job.split ? "Split" : "Prepared", job.tile.z, job.tile.x, job.tile.y, streamed ? " in chunks" : "", + static_cast(read.size), writeGeometry != nullptr ? static_cast(writeGeometry->size()) : 0, + writeLabels != nullptr ? static_cast(writeLabels->size()) : 0, + writeGraph != nullptr ? static_cast(writeGraph->size()) : 0, loaded - start, convertedAt - loaded, + static_cast(graphStats.totalMs), static_cast(graphStats.nodes), static_cast(graphStats.edges), + nowMs() - convertedAt); + // Tiles drawn from the original or an older prepared copy are drawn again from the new one, which has labels + preparedCount++; + if (writeGeometry != nullptr || writeLabels != nullptr) { + preparedGeneration++; + lock(); + tileCache.dropMagnified(&job.tile, 1); + changed = true; + unlock(); + } + } + } + // A read or write that gave way to tiles on screen is tried again later + lane.fileBuffer.trim(KEEP_FILE_BUFFER); + lane.converter.trim(KEEP_FILE_BUFFER / 4); + if (lane.convertedLabels.allocated() > KEEP_FILE_BUFFER / 4) { + lane.convertedLabels.release(); + } + // The graph builder's working memory goes back after each tile: graphs are made once per tile + lane.graphBuilder.trim(); + lane.convertedGraph.release(); + bool retry = false; + if (!ok && !requeue && !memoryForConversion()) { + // Out of memory, not a bad tile: it stays queued and conversions pause, it does not count as a failure + conversionsWaitUntil = xTaskGetTickCount() + pdMS_TO_TICKS(MEMORY_BACKOFF_MS); + logInfo("[%d] Preparing %d/%d/%d waits for memory (%s, largest free PSRAM block %u KB)", lane.index, job.tile.z, job.tile.x, + job.tile.y, reason, static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024)); + requeue = true; + } else if (!ok && !requeue) { + const auto largest = static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)); + lock(); + // A tile that failed once, usually for want of memory, is tried once more at the end of the queue + retry = std::find(retriedConversions, retriedConversions + retriedConversionCount, job.tile) == + retriedConversions + retriedConversionCount; + if (retry) { + retriedConversions[retriedConversionNext] = job.tile; + retriedConversionNext = (retriedConversionNext + 1) % MAX_FAILED_CONVERSIONS; + retriedConversionCount = std::min(retriedConversionCount + 1, MAX_FAILED_CONVERSIONS); + } + unlock(); + logError("[%d] Could not prepare %d/%d/%d (%s, largest free PSRAM block %u bytes), %s", lane.index, job.tile.z, job.tile.x, + job.tile.y, reason, largest, retry ? "tried again later" : "not tried again until the map reopens"); + requeue = retry; + } + lock(); + if (!ok && !requeue) { + failedConversions[failedConversionNext] = job.tile; + failedConversionNext = (failedConversionNext + 1) % MAX_FAILED_CONVERSIONS; + failedConversionCount = std::min(failedConversionCount + 1, MAX_FAILED_CONVERSIONS); + } + lane.converting = false; + if (requeue) { + queueConversion(job.tile, job.region, job.urgent, job.split); + } + unlock(); + // Something was done, or the card is busy with more urgent work + return true; +} + +void TileWorker::preparation(int& done, int& waiting, int& splitting) { + lock(); + waiting = conversionCount; + splitting = 0; + for (int i = 0; i < conversionCount; i++) { + splitting += conversions[i].split ? 1 : 0; + } + for (int i = 0; i < laneCount; i++) { + waiting += lanes[i].converting ? 1 : 0; + } + unlock(); + done = preparedCount.load(); +} + +void TileWorker::addGap(const TileKey& tile) { + for (int i = 0; i < gapCount; i++) { + if (gaps[i] == tile) { + return; + } + } + if (gapCount < MAX_GAPS) { + gaps[gapCount++] = tile; + } +} + +int TileWorker::takeGaps(TileKey* out, int max) { + lock(); + const int count = gapCount < max ? gapCount : max; + for (int i = 0; i < count; i++) { + out[i] = gaps[i]; + } + // Whatever did not fit stays for the next call + for (int i = count; i < gapCount; i++) { + gaps[i - count] = gaps[i]; + } + gapCount -= count; + unlock(); + return count; +} + +void TileWorker::cancelPrefetch() { + idle = false; + lock(); + for (int i = 0; i < laneCount; i++) { + if (lanes[i].active && lanes[i].prefetching) { + lanes[i].cancel = true; + } + } + unlock(); + io->cancelPrefetch(); +} + +void TileWorker::lockIndex(void* context) { + static_cast(context)->lock(); +} + +void TileWorker::unlockIndex(void* context) { + static_cast(context)->unlock(); +} + +void TileWorker::saveCacheIndex() { + lock(); + const bool dirty = cacheIndex.isDirty(); + if (dirty) { + cacheIndex.save(indexBytes); + cacheIndex.setClean(); + } + writesSinceSave = 0; + unlock(); + if (dirty) { + IndexFileJob job { root, styleId, nullptr, &indexBytes }; + io->run(IoPriority::Background, writeIndexJob, &job); + } +} + +bool TileWorker::tidyCache() { + lock(); + TileKey tile {}; + const bool evict = sdCache && cacheIndex.nextEviction(tile); + if (evict) { + cacheIndex.erase(tile); + } + const bool walk = !evict && !walking && !cacheWalker.isComplete(); + if (walk) { + walking = true; + } + unlock(); + if (evict) { + EvictJob job { root, styleId, tile }; + io->run(IoPriority::Background, evictJob, &job); + return true; + } + if (walk) { + const CacheWalker::IndexLock indexLock { lockIndex, unlockIndex, this }; + WalkJob job { &cacheWalker, &cacheIndex, &indexLock, false }; + io->run(IoPriority::Background, walkJob, &job); + lock(); + walking = false; + unlock(); + return true; + } + return false; +} + +bool TileWorker::claimNext(Lane& lane, bool visible, TileKey& tile, uint16_t*& pixels) { + lock(); + if (missingRetryAt != 0 && static_cast(xTaskGetTickCount() - missingRetryAt) >= 0) { + // Tiles that failed for want of memory get another try + missingRetryAt = 0; + missingCount = 0; + missingNext = 0; + } + bool found = false; + const int first = visible ? 0 : visibleWanted; + // The ring around the screen only fits in what the cache holds now: while a route search has borrowed slots, it + // evicted the visible tiles, which were drawn again (the map blinked) + const int last = visible ? visibleWanted : std::min(wantedCount, std::max(visibleWanted, tileCache.slotCount() - CACHE_SPARE_SLOTS)); + for (int i = first; i < last && !found; i++) { + const TileKey& candidate = wanted[i]; + if (tileCache.contains(candidate)) { + continue; + } + bool skip = false; + for (int j = 0; j < missingCount && !skip; j++) { + skip = missing[j] == candidate; + } + for (int j = 0; j < laneCount && !skip; j++) { + skip = j != lane.index && lanes[j].active && lanes[j].current == candidate; + } + if (skip) { + continue; + } + pixels = tileCache.reserve(candidate); + if (pixels != nullptr) { + tile = candidate; + lane.current = candidate; + lane.active = true; + found = true; + } + } + unlock(); + return found; +} + +bool TileWorker::writeOne(Lane& lane) { + if (!sdCache) { + return false; + } + lock(); + TileKey tile {}; + int sourceZoom = 0; + const uint16_t* pixels = nullptr; + while (pendingWriteCount > 0 && pixels == nullptr) { + pendingWriteCount--; + tile = pendingWrites[pendingWriteCount]; + sourceZoom = pendingSourceZoom[pendingWriteCount]; + pixels = tileCache.peek(tile); + } + if (pixels != nullptr) { + memcpy(lane.writeBuffer, pixels, TileCache::TILE_BYTES); + } + unlock(); + if (pixels == nullptr) { + return false; + } + const uint32_t start = nowMs(); + // Header: magic, style version with the source zoom level on top, and the number of coded 16-bit words + auto* bytes = reinterpret_cast(lane.encodeBuffer); + const size_t words = encodeTile(lane.writeBuffer, TILE_PIXELS, reinterpret_cast(bytes + CACHE_HEADER_BYTES), ENCODE_CAPACITY); + if (words == 0) { + return true; + } + const uint32_t version = STYLE_VERSION | (static_cast(sourceZoom) << 24); + const auto wordCount = static_cast(words); + memcpy(bytes, CACHE_MAGIC, 4); + memcpy(bytes + 4, &version, 4); + memcpy(bytes + 8, &wordCount, 4); + WriteCacheJob job { root, styleId, tile, bytes, CACHE_HEADER_BYTES + words * 2 }; + if (io->run(IoPriority::Background, writeCacheJob, &job)) { + lock(); + cacheIndex.put(tile, static_cast(job.size)); + const bool save = ++writesSinceSave >= SAVE_INDEX_EVERY_WRITES; + unlock(); + logInfo("[%d] Tile %d/%d/%d written to the SD cache in %u ms", lane.index, tile.z, tile.x, tile.y, nowMs() - start); + if (save) { + saveCacheIndex(); + } + } else { + // It gave way to more urgent card work, or the card failed: it is written later or never + lock(); + if (pendingWriteCount < MAX_PENDING_WRITES && tileCache.peek(tile) != nullptr) { + pendingSourceZoom[pendingWriteCount] = sourceZoom; + pendingWrites[pendingWriteCount++] = tile; + } + unlock(); + } + return true; +} + +void TileWorker::finish(Lane& lane, const TileKey& tile, bool success, int sourceZoom) { + lock(); + if (success) { + tileCache.commit(tile, sourceZoom); + // Drawn while it was in the ring, then the view settled with it on screen: want() looked before it was + // finished, so the gap is noted here, or it stayed drawn from a coarser zoom level and was never fetched + const int bestZoom = tile.z < MAX_SOURCE_ZOOM ? tile.z : MAX_SOURCE_ZOOM; + for (int i = 0; i < visibleWanted && sourceZoom < bestZoom; i++) { + if (wanted[i] == tile) { + addGap(ancestorOf(tile, bestZoom)); + break; + } + } + } else { + tileCache.release(tile); + missing[missingNext] = tile; + missingNext = (missingNext + 1) % 64; + if (missingCount < 64) { + missingCount++; + } + } + lane.active = false; + unlock(); + changed = true; +} + +bool TileWorker::loadJob(IoTask& io, void* context) { + auto* job = static_cast(context); + TileWorker* worker = job->worker; + Lane& lane = *job->lane; + FileBuffer& buffer = lane.fileBuffer; + job->found = Found::Nothing; + job->yielded = false; + const bool hasSource = worker->store->find(job->tile, job->source); + char path[PATH_MAX_LENGTH]; + // Only tiles the index knows are read, a missing file costs a folder lookup on the card + worker->lock(); + const bool indexed = job->tryCache && worker->sdCache && (worker->cacheIndex.contains(job->tile) || !worker->cacheIndex.isComplete()); + worker->unlock(); + const auto held = static_cast(lane.heldKind); + if (indexed) { + lane.heldKind = static_cast(Found::Nothing); + } + if (indexed && cachePath(path, sizeof(path), worker->root, worker->styleId, job->tile) && io.readFile(path, buffer)) { + // A tile drawn magnified from a lower zoom level is drawn again once its own data is on the card + int sourceZoom = 0; + if (checkCacheFile(buffer.bytes(), buffer.size(), sourceZoom) > 0 && (!hasSource || job->source.tile.z == sourceZoom)) { + job->cachedSourceZoom = sourceZoom; + job->found = Found::Cached; + worker->lock(); + worker->cacheIndex.touch(job->tile); + worker->unlock(); + return true; + } + } + if (io.yielded()) { + job->yielded = true; + return false; + } + if (!hasSource) { + return false; + } + // The file the lane read for the last tile, when it is this one's source too + if (!indexed && buffer.size() > 0 && lane.heldTile == job->source.tile && lane.heldRegion == job->source.region && + ((held == Found::Prepared && !job->skipPrepared) || + (held == Found::Original && lane.heldGeneration == worker->preparedGeneration.load()))) { + job->found = held; + job->olderPrepared = lane.heldOlder; + job->splitPrepared = lane.heldSplit; + return true; + } + lane.heldKind = static_cast(Found::Nothing); + lane.heldTile = job->source.tile; + lane.heldRegion = job->source.region; + lane.heldGeneration = worker->preparedGeneration.load(); + PreparedHeader header {}; + if (!job->skipPrepared && preparedPath(path, sizeof(path), worker->root, worker->store->regionName(job->source.region), job->source.tile) && + io.readFile(path, buffer) && readPreparedHeader(buffer.bytes(), buffer.size(), header) && + isPreparedReadable(header, job->source.tile, job->source.size)) { + job->found = Found::Prepared; + job->olderPrepared = preparedPartsNeeded(&header, job->source.tile, job->source.size) != 0; + job->splitPrepared = header.version >= PREPARED_SPLIT_VERSION; + lane.heldKind = static_cast(Found::Prepared); + lane.heldOlder = job->olderPrepared; + lane.heldSplit = job->splitPrepared; + return true; + } + if (!io.yielded() && io.readFile(job->source.path, buffer)) { + job->found = Found::Original; + lane.heldKind = static_cast(Found::Original); + lane.heldOlder = false; + return true; + } + job->yielded = io.yielded(); + job->noMemoryBytes = buffer.lastShortfall(); + return false; +} + +bool TileWorker::produce(Lane& lane, const TileKey& tile, uint16_t* pixels, bool visible) { + const uint32_t start = nowMs(); + const IoPriority priority = visible ? IoPriority::Visible : IoPriority::Prefetch; + LoadJob job {}; + job.worker = this; + job.lane = &lane; + job.tile = tile; + job.tryCache = true; + io->run(priority, loadJob, &job); + if (job.yielded) { + return false; + } + if (job.found == Found::Nothing && job.noMemoryBytes > 0 && visible) { + // A large low-zoom tile on screen and no PSRAM block for its file: preparing gives way, the least recently + // used cache slots make room, and it is read again. Without this it stayed drawn from a coarser zoom level + logInfo("[%d] Tile %d/%d/%d: no %u KB block of PSRAM for its data, making room", lane.index, tile.z, tile.x, tile.y, + static_cast(job.noMemoryBytes / 1024)); + conversionsWaitUntil = xTaskGetTickCount() + pdMS_TO_TICKS(MEMORY_BACKOFF_MS); + for (int i = 0; i < laneCount; i++) { + lanes[i].trimBuffers = true; + } + lane.fileBuffer.trim(0); + relieveMemory(job.noMemoryBytes + 0x10000); + job.noMemoryBytes = 0; + io->run(priority, loadJob, &job); + if (job.yielded) { + return false; + } + } + // Still no block that large: the original is drawn reading it from the card a chunk at a time, as preparing does + bool streamedOk = false; + RenderStats streamStats {}; + if (job.found == Found::Nothing && job.noMemoryBytes > 0 && job.source.path[0] != '\0') { + CardInput input(io, job.source.path, priority); + streamedOk = input.isReady() && lane.renderer.render(&input, job.source.tile, tile, *palette, pixels, &streamStats); + if (lane.renderer.wasCancelled()) { + return false; + } + } + if (job.found == Found::Nothing && job.noMemoryBytes > 0 && !streamedOk) { + // Not even that: tried again in a few seconds, not left drawn from a coarser zoom level + logError("[%d] Tile %d/%d/%d: no %u KB block of PSRAM for its data (largest %u KB), tried again in %u s", lane.index, tile.z, + tile.x, tile.y, static_cast(job.noMemoryBytes / 1024), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024), static_cast(MEMORY_RETRY_MS / 1000)); + lock(); + if (missingRetryAt == 0) { + missingRetryAt = xTaskGetTickCount() + pdMS_TO_TICKS(MEMORY_RETRY_MS); + } + unlock(); + } + const uint32_t loaded = nowMs(); + // A tile on screen without data of its own zoom level is a gap that browsing can fill + if (visible) { + const int bestZoom = tile.z < MAX_SOURCE_ZOOM ? tile.z : MAX_SOURCE_ZOOM; + int haveZoom = -1; + if (job.found == Found::Cached) { + haveZoom = job.cachedSourceZoom; + } else if (job.found != Found::Nothing || streamedOk) { + haveZoom = job.source.tile.z; + } + if (haveZoom < bestZoom) { + lock(); + addGap(ancestorOf(tile, bestZoom)); + unlock(); + } + } + if (job.found == Found::Cached) { + int sourceZoom = 0; + const uint32_t words = checkCacheFile(lane.fileBuffer.bytes(), lane.fileBuffer.size(), sourceZoom); + const bool decoded = decodeTile(reinterpret_cast(lane.fileBuffer.bytes() + CACHE_HEADER_BYTES), words, pixels, TILE_PIXELS); + finish(lane, tile, decoded, sourceZoom); + if (decoded) { + logInfo("[%d] Tile %d/%d/%d from SD cache: read %u ms, decode %u ms", lane.index, tile.z, tile.x, tile.y, loaded - start, nowMs() - loaded); + } + return true; + } + RenderStats stats {}; + bool rendered = false; + if (streamedOk) { + rendered = true; + stats = streamStats; + logInfo("[%d] Tile %d/%d/%d drawn from its original read in chunks (no %u KB block of PSRAM)", lane.index, tile.z, tile.x, + tile.y, static_cast(job.noMemoryBytes / 1024)); + // Its prepared copy is small and makes the next drawing cheap + lock(); + queueConversion(job.source.tile, job.source.region, true); + unlock(); + } + if (job.found == Found::Prepared) { + rendered = lane.renderer.renderPrepared(lane.fileBuffer.bytes(), lane.fileBuffer.size(), tile, *palette, pixels, &stats); + if (lane.renderer.wasCancelled()) { + return false; + } + if (rendered && job.olderPrepared) { + // A tile on screen from an older format is made again (split) before the idle scan gets to it + lock(); + queueConversion(job.source.tile, job.source.region, true, !job.splitPrepared); + unlock(); + } + if (rendered && job.splitPrepared && labelStore != nullptr) { + // Its labels are in the .prl file: a lane reads them once no tile on screen waits + labelStore->request(job.source.tile, job.source.region); + } + if (!rendered) { + // A damaged prepared tile: the original is drawn and the copy made again + job.tryCache = false; + job.skipPrepared = true; + io->run(priority, loadJob, &job); + if (job.yielded) { + return false; + } + } + } + if (job.found == Found::Original) { + MemoryInput input(lane.fileBuffer.bytes(), lane.fileBuffer.size()); + rendered = lane.renderer.render(&input, job.source.tile, tile, *palette, pixels, &stats); + if (lane.renderer.wasCancelled()) { + return false; + } + // No current prepared copy: converted in idle time, before the tiles the scan found + lock(); + queueConversion(job.source.tile, job.source.region, true); + unlock(); + } + lane.fileBuffer.trim(KEEP_FILE_BUFFER); + if (rendered) { + lock(); + if (pendingWriteCount < MAX_PENDING_WRITES) { + pendingSourceZoom[pendingWriteCount] = job.source.tile.z; + pendingWrites[pendingWriteCount++] = tile; + } + unlock(); + } + finish(lane, tile, rendered, job.source.tile.z); + if (rendered) { + logInfo("[%d] Tile %d/%d/%d from z%d %s: read %u ms, render %u ms (%s %u, geometry %u, fill %u, %u features)", lane.index, tile.z, + tile.x, tile.y, job.source.tile.z, job.found == Found::Prepared ? ".prt" : ".pbf", loaded - start, stats.totalMs, + job.found == Found::Prepared ? "inflate" : "scan", stats.scanMs, stats.geometryMs, stats.fillMs, stats.features); + } + return true; +} + +void TileWorker::loop(Lane& lane) { + while (!stopRequested) { + // A TLS handshake is 2-3.6 s of CPU at a lower priority: rendering beside it on the same core starved it and + // the core's idle task, which the task watchdog reported. It gets the core, at most 5 s. + for (int wait = 0; lane.index == Downloader::NETWORK_CORE && Downloader::isHandshaking() && wait < 250 && !stopRequested; wait++) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + TileKey tile {}; + uint16_t* pixels = nullptr; + // Tiles on screen first, then the SD card cache, then the tiles around the screen, then conversions + bool visible = true; + if (!claimNext(lane, true, tile, pixels)) { + if (loadLabels(lane) || writeOne(lane)) { + continue; + } + visible = false; + // Around the screen and other idle work only after a while without input + // Not during a route search: the user waits for it, and the cache has lent it most of its slots + if (!idle || routing || !claimNext(lane, false, tile, pixels)) { + if (!idle || (!convertOne(lane) && !tidyCache())) { + vTaskDelay(pdMS_TO_TICKS(IDLE_WAIT_MS)); + } + continue; + } + } + lock(); + lane.prefetching = !visible; + lane.cancel = false; + unlock(); + busyLanes++; + if (!produce(lane, tile, pixels, visible)) { + // Gave way to more urgent card work: the slot is freed and the tile claimed again when it is still wanted + lock(); + tileCache.release(tile); + lane.active = false; + unlock(); + } + busyLanes--; + // A run of low-zoom tiles of about 1 s each kept the core's idle task from running for the 5 s the task watchdog allows + vTaskDelay(1); + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/TileWorker.h b/Apps/Maps/main/Source/app/TileWorker.h new file mode 100644 index 0000000..5f98eb6 --- /dev/null +++ b/Apps/Maps/main/Source/app/TileWorker.h @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IoTask.h" +#include "LabelStore.h" + +#include "../mvt/ByteInput.h" +#include "../render/CacheIndex.h" +#include "../render/CacheWalker.h" +#include "../render/PreparedScanner.h" +#include "../render/PreparedTile.h" +#include "../route/RouteGraph.h" +#include "../render/Style.h" +#include "../render/TileCache.h" +#include "../render/TileRenderer.h" +#include "../tiles/TileStore.h" + +#include +#include +#include + +#include +#include + +namespace maps { + +/** + * Fills the tile cache with one render task per core. The lanes never touch the SD card: they hand jobs to the + * I/O task, which reads whole files into the lane's buffer, so one lane renders while the card serves the other. + * The view hands over the tiles it wants, nearest first. A tile comes from the rendered tile cache on the SD + * card when it is there and that cache is switched on, otherwise it is rendered from its prepared copy (.prt), + * or from the original vector tile while there is no current prepared copy, and written to the SD card cache. + * The SD card cache is kept per style and palette under cache/, below a size cap, deleting the least + * recently used tiles first. It never touches maps/. + * Tiles around the screen are only rendered after a second without input (idle), and any input cancels them, + * so they never delay a tile on screen. In idle time the lanes also convert originals into prepared tiles: the ones + * just drawn on screen from an original at once, every other tile on the card (found by a PreparedScanner, such as + * the tiles of a new download) only once the map was left alone for BACKGROUND_PREPARE_MS. When PSRAM has no block + * large enough, conversions wait MEMORY_BACKOFF_MS and the tile stays queued instead of counting as failed. + */ +class TileWorker { +public: + static constexpr int MAX_WANTED = 32; + static constexpr int LANES = 2; + + bool start(TileStore* store, IoTask* io, const Palette* palette, int cacheSlots, const char* cacheRoot); + /** Where the lanes put the label sections of the prepared tiles they draw. Set before start(). */ + void setLabelStore(LabelStore* store) { labelStore = store; } + void stop(); + + /** Replaces the wanted tiles. The first visibleCount are on screen, the rest are fetched ahead. */ + void want(const TileKey* tiles, int count, int visibleCount); + + /** The cache is shared with the render tasks, so every access happens between lock() and unlock(). */ + void lock() { xSemaphoreTake(mutex, portMAX_DELAY); } + void unlock() { xSemaphoreGive(mutex); } + TileCache& cache() { return tileCache; } + /** + * Frees tile cache slots, least recently used first, until PSRAM has a free block of bytes, keeping at least + * MIN_CACHE_SLOTS. For downloads of large low-zoom tiles late in a session. Returns whether the block is there. + */ + /** Frees cache slots until PSRAM has a block of bytes and, when given, totalBytes free in all. */ + bool relieveMemory(size_t bytes, size_t totalBytes = 0, int minSlots = 0); + /** Takes the freed slots back when memory allows; true when none is missing any more. */ + bool restoreMemory(); + /** Whether tiles are queued for preparation or being prepared; the cache stays small for them meanwhile. */ + bool isPreparing() { + lock(); + bool preparing = conversionCount > 0; + for (int i = 0; i < laneCount; i++) { + preparing = preparing || lanes[i].converting; + } + unlock(); + return preparing; + } + bool cacheShrunk() { + lock(); + const bool shrunk = tileCache.freedSlots() > 0; + unlock(); + return shrunk; + } + /** relieveMemory() leaves the cache at least this many slots. */ + static constexpr int MIN_CACHE_SLOTS = 12; + + /** True once since the last call when a tile was added to the cache. */ + bool takeChanged() { return changed.exchange(false); } + bool isBusy() const { return busyLanes.load() > 0; } + /** + * Background preparation (new originals, or older prepared tiles made again): tiles done since start, tiles + * waiting, and of those the older tiles with labels inside that are split into their new files. + */ + void preparation(int& done, int& waiting, int& splitting); + TaskHandle_t laneTask(int lane) const { return lane < laneCount ? lanes[lane].task : nullptr; } + /** The fewest bytes of stack a render lane had left so far. */ + uint32_t stackLeft(int lane) const { + return lane < laneCount && lanes[lane].task != nullptr ? static_cast(uxTaskGetStackHighWaterMark(lanes[lane].task)) : 0; + } + /** Whether a tile is cached or known to have no data. Call between lock() and unlock(). */ + bool isSettled(const TileKey& tile) const; + /** + * New vector tiles are on the card: tiles without data and tiles drawn from lower zoom levels are drawn again, + * only those the given tiles cover, or all of them when count is -1. + */ + void tilesArrived(const TileKey* tiles, int count); + /** Whether new prepared tiles are written deflated. */ + void setDeflatePrepared(bool deflate) { deflatePrepared = deflate; } + /** + * No tiles are prepared while paused (during a download), they are prepared afterwards. Tiles on screen are drawn + * again from the downloaded tiles once, when the pause ends, not after every batch. + */ + void setConversionsPaused(bool paused); + /** While a route search runs no tiles are prepared and none around the screen drawn: it needs the card, the memory and the cores. */ + void setRouting(bool value) { routing = value; } + /** Whether rendered tiles are read from and written to the SD card cache. */ + void setSdCache(bool enabled) { sdCache = enabled; } + /** Idle: no input for a while, so prefetch, conversions and cache tidying may run. */ + void setIdle(bool value) { + if (value && !idle) { + idleSince = xTaskGetTickCount(); + } + idle = value; + } + /** Tiles not on screen are prepared only after this long without input and without a download. */ + static constexpr uint32_t BACKGROUND_PREPARE_MS = 10000; + /** How long conversions wait after PSRAM had no block large enough for one. */ + static constexpr uint32_t MEMORY_BACKOFF_MS = 30000; + /** Input arrived: prefetch reads and renders stop at once, and idle work waits. */ + void cancelPrefetch(); + /** + * Tiles on screen that were drawn from a lower zoom level or not at all, as the tile of the zoom level that + * would draw them in full detail (at most z14). Returns how many were written to out, each one once. + */ + int takeGaps(TileKey* out, int max); + +private: + struct Lane { + TileWorker* worker; + int index; + TileRenderer renderer; + FileBuffer fileBuffer; + /** + * The source file fileBuffer still holds from the last tile, so the tiles drawn magnified from one large + * low-zoom file read it from the card once instead of once each. Only the lane's own jobs use it, one at a time. + */ + uint8_t heldKind; + TileKey heldTile; + int heldRegion; + bool heldOlder; + bool heldSplit; + uint32_t heldGeneration; + TaskHandle_t task; + TileKey current; + bool active; + uint16_t* writeBuffer; + uint16_t* encodeBuffer; + TileConverter converter; + ByteWriter converted; + ByteWriter convertedLabels; + GraphBuilder graphBuilder; + ByteWriter convertedGraph; + /** Inflated labels of a .prl file, handed to the label store. */ + FileBuffer labelFile; + bool converting; + TileKey convertingTile; + bool prefetching; + std::atomic cancel; + /** Preparing waits for memory: the lane frees the buffers it keeps between tiles, so PSRAM has larger blocks. */ + std::atomic trimBuffers; + /** Whether the buffers were freed for the conversion now waiting; reset once a conversion starts. */ + bool triedTrim; + }; + + struct Conversion { + TileKey tile; + int region; + /** Drawn on screen from its original: prepared without waiting for BACKGROUND_PREPARE_MS. */ + bool urgent; + /** An older prepared tile with labels inside, split into its new files. */ + bool split; + }; + + static constexpr int MAX_CONVERSIONS = 32; + + struct ScanResult { + Conversion items[MAX_CONVERSIONS]; + int count; + int room; + }; + + /** What the I/O task found for a tile. */ + enum class Found : uint8_t { + Nothing, + Cached, + Prepared, + Original + }; + + /** Counts finished conversions: an original held in a lane's buffer is not reused once a prepared copy exists. */ + std::atomic preparedGeneration = 0; + std::atomic preparedCount = 0; + std::atomic splitWaiting = 0; + + struct LoadJob { + TileWorker* worker; + Lane* lane; + TileKey tile; + bool tryCache; + bool skipPrepared; + /** The prepared copy is of an older format: drawn as it is, made again soon. */ + bool olderPrepared; + /** The prepared copy has no labels inside (version 5 on): they are read from its .prl file. */ + bool splitPrepared; + Found found; + TileFile source; + int cachedSourceZoom; + bool yielded; + /** The tile's data is on the card but PSRAM had no block this large for it, or 0. */ + size_t noMemoryBytes; + }; + + static void taskMain(void* context); + void loop(Lane& lane); + /** Claims the next wanted tile that is on screen, or around the screen when visible is false. */ + bool claimNext(Lane& lane, bool visible, TileKey& tile, uint16_t*& pixels); + /** Reads what a tile is drawn from: its SD cache file, its prepared copy or its original. */ + static bool loadJob(IoTask& io, void* context); + /** Produces a claimed tile. Returns false when it yielded to more urgent card work and should be tried again. */ + bool produce(Lane& lane, const TileKey& tile, uint16_t* pixels, bool visible); + void finish(Lane& lane, const TileKey& tile, bool success, int sourceZoom); + /** Writes one rendered tile to the SD card cache, only while no tile is waiting to be drawn. */ + bool writeOne(Lane& lane); + /** Call between lock() and unlock(). */ + void addGap(const TileKey& tile); + /** Adds a tile to the conversion queue unless it is there already. Call between lock() and unlock(). */ + /** Queues a tile for conversion; urgent ones (on screen) go first and push the last queued one out when full. */ + bool queueConversion(const TileKey& tile, int region, bool urgent = false, bool split = false); + static bool collectFromScan(const TileKey& tile, int region, bool split, void* context); + /** Reads the labels of one split tile the label store asked for. Returns false when none were wanted. */ + bool loadLabels(Lane& lane); + /** Converts one queued tile, or looks at one more folder for tiles to convert. Returns false when there is nothing to do. */ + bool convertOne(Lane& lane); + /** Deletes the least recently used SD cache tile while the cache is over its cap, or tidies one cache folder. */ + bool tidyCache(); + void saveCacheIndex(); + static void lockIndex(void* context); + static void unlockIndex(void* context); + + TileStore* store = nullptr; + LabelStore* labelStore = nullptr; + IoTask* io = nullptr; + const Palette* palette = nullptr; + Lane lanes[LANES] {}; + int laneCount = 0; + TileCache tileCache; + char root[PATH_MAX_LENGTH] = ""; + SemaphoreHandle_t mutex = nullptr; + SemaphoreHandle_t stopped = nullptr; + std::atomic stopRequested = false; + std::atomic changed = false; + std::atomic busyLanes = 0; + TileKey wanted[MAX_WANTED] {}; + int wantedCount = 0; + int visibleWanted = 0; + TileKey missing[64] {}; + int missingCount = 0; + int missingNext = 0; + /** Ticks when tiles that failed for want of memory are tried again (the missing list is cleared), or 0. */ + TickType_t missingRetryAt = 0; + static constexpr uint32_t MEMORY_RETRY_MS = 5000; + static constexpr int MAX_PENDING_WRITES = 32; + TileKey pendingWrites[MAX_PENDING_WRITES] {}; + int pendingSourceZoom[MAX_PENDING_WRITES] {}; + int pendingWriteCount = 0; + static constexpr int MAX_GAPS = 16; + TileKey gaps[MAX_GAPS] {}; + int gapCount = 0; + Conversion conversions[MAX_CONVERSIONS] {}; + static constexpr int MAX_FAILED_CONVERSIONS = 16; + TileKey failedConversions[MAX_FAILED_CONVERSIONS] = {}; + int failedConversionNext = 0; + int failedConversionCount = 0; + /** Tiles whose preparation failed once and went back into the queue. */ + TileKey retriedConversions[MAX_FAILED_CONVERSIONS] = {}; + int retriedConversionNext = 0; + int retriedConversionCount = 0; + int conversionCount = 0; + PreparedScanner scanner; + ScanResult scanResult {}; + bool scanning = false; + std::atomic rescanRequested = false; + std::atomic deflatePrepared = true; + std::atomic conversionsPaused = false; + std::atomic routing = false; + bool magnifiedOutdated = false; + std::atomic sdCache = true; + std::atomic idle = false; + /** Ticks: when idle began (or a download ended), and until when conversions wait for memory. */ + std::atomic idleSince = 0; + std::atomic conversionsWaitUntil = 0; + char styleId[8] = ""; + CacheIndex cacheIndex; + CacheWalker cacheWalker; + bool walking = false; + int writesSinceSave = 0; + ByteWriter indexBytes; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/TrackRecorder.cpp b/Apps/Maps/main/Source/app/TrackRecorder.cpp new file mode 100644 index 0000000..5fe7cdb --- /dev/null +++ b/Apps/Maps/main/Source/app/TrackRecorder.cpp @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "TrackRecorder.h" + +#include "MapView.h" + +#include "../gpx/GpxWriter.h" +#include "../platform/Platform.h" + +#include + +#include +#include +#include +#include + +namespace maps { + +namespace { + +constexpr size_t PENDING_BYTES = 16 * 1024; +constexpr const char* TRACK_END = "\n\n"; + +/** Whether a track file ends with , read from its last bytes. */ +bool isClosed(FILE* file) { + char tail[16] = ""; + if (fseek(file, -static_cast(sizeof(tail) - 1), SEEK_END) != 0) { + return false; + } + const size_t count = fread(tail, 1, sizeof(tail) - 1, file); + tail[count] = '\0'; + return strstr(tail, "") != nullptr; +} + +} // namespace + +TrackRecorder::~TrackRecorder() { + largeFree(pending); + largeFree(drawn); +} + +void TrackRecorder::closeUnfinished(IoTask& io, const char* root) { + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/tracks", root); + auto close = [&] { + struct Context { + const char* folder; + int closed; + } context { folder, 0 }; + listDirectory(folder, [](const char* fileName, bool isDirectory, void* data) { + auto* context = static_cast(data); + const size_t length = strlen(fileName); + if (isDirectory || length < 5 || strcmp(fileName + length - 4, ".gpx") != 0) { + return true; + } + char file[PATH_MAX_LENGTH]; + snprintf(file, sizeof(file), "%s/%s", context->folder, fileName); + FILE* track = fopen(file, "rb"); + if (track == nullptr) { + return true; + } + const bool closed = isClosed(track); + // A crash can only have cut a line in the middle: the last whole point is kept, then the end tags + long keep = 0; + if (!closed && fseek(track, 0, SEEK_END) == 0) { + const long size = ftell(track); + const long window = size < 320 ? size : 320; + char last[321]; + if (fseek(track, size - window, SEEK_SET) == 0 && fread(last, 1, static_cast(window), track) == static_cast(window)) { + last[window] = '\0'; + const char* end = nullptr; + for (const char* found = strstr(last, "\n"); found != nullptr; found = strstr(found + 1, "\n")) { + end = found; + } + const char* segment = nullptr; + for (const char* found = strstr(last, "\n"); found != nullptr; found = strstr(found + 1, "\n")) { + segment = found; + } + if (end != nullptr && (segment == nullptr || end > segment)) { + keep = size - window + static_cast(end - last) + 9; + } else if (segment != nullptr) { + keep = size - window + static_cast(segment - last) + 9; + } + } + } + fclose(track); + if (closed || keep <= 0) { + return true; + } + // Copied up to the last whole point through a .tmp file, then the end tags + char temporary[PATH_MAX_LENGTH + 8]; + snprintf(temporary, sizeof(temporary), "%s.tmp", file); + FILE* source = fopen(file, "rb"); + FILE* target = fopen(temporary, "wb"); + bool ok = source != nullptr && target != nullptr; + char buffer[256]; + long left = keep; + while (ok && left > 0) { + const size_t wanted = left < static_cast(sizeof(buffer)) ? static_cast(left) : sizeof(buffer); + ok = fread(buffer, 1, wanted, source) == wanted && fwrite(buffer, 1, wanted, target) == wanted; + left -= static_cast(wanted); + } + ok = ok && fputs(TRACK_END, target) >= 0; + if (source != nullptr) { + fclose(source); + } + if (target != nullptr) { + fclose(target); + } + if (ok && remove(file) == 0 && rename(temporary, file) == 0) { + context->closed++; + } else { + remove(temporary); + } + return true; + }, &context); + if (context.closed > 0) { + logInfo("Closed %d unfinished track file(s)", context.closed); + } + return true; + }; + runOnCard(io, close, IoPriority::Background); +} + +bool TrackRecorder::ensureRoom() { + if (drawnCount < drawnCapacity) { + return true; + } + if (drawnCapacity >= MAX_DRAWN_POINTS) { + return false; + } + const int capacity = drawnCapacity == 0 ? 1024 : (drawnCapacity * 2 < MAX_DRAWN_POINTS ? drawnCapacity * 2 : MAX_DRAWN_POINTS); + auto* bigger = static_cast(largeAlloc(sizeof(Point) * static_cast(capacity))); + if (bigger == nullptr) { + return false; + } + if (drawn != nullptr) { + memcpy(bigger, drawn, sizeof(Point) * static_cast(drawnCount)); + largeFree(drawn); + } + drawn = bigger; + drawnCapacity = capacity; + return true; +} + +bool TrackRecorder::start(IoTask& io, const char* root, uint32_t nowMs) { + if (currentState != State::Off) { + return true; + } + if (pending == nullptr) { + pending = static_cast(largeAlloc(PENDING_BYTES)); + pendingCapacity = pending != nullptr ? PENDING_BYTES : 0; + } + if (pending == nullptr) { + return false; + } + // Named by the local start time; without a set clock by a number + const time_t now = time(nullptr); + struct tm local {}; + localtime_r(&now, &local); + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/tracks", root); + bool made = false; + auto create = [&] { + mkdir(folder, 0777); + for (int number = 0; number < 100 && !made; number++) { + if (local.tm_year + 1900 >= 2024) { + strftime(name, sizeof(name), "%Y%m%d-%H%M", &local); + if (number > 0) { + const size_t length = strlen(name); + snprintf(name + length, sizeof(name) - length, "-%d", number); + } + } else { + snprintf(name, sizeof(name), "track%03d", number + 1); + } + snprintf(path, sizeof(path), "%s/%s.gpx", folder, name); + struct stat info {}; + if (stat(path, &info) == 0) { + continue; + } + FILE* file = fopen(path, "wb"); + if (file == nullptr) { + return false; + } + char text[512]; + const int header = gpxHeader(text, sizeof(text), APP_VERSION); + const int track = header >= 0 ? gpxTrackStart(text + header, sizeof(text) - static_cast(header), name) : -1; + made = track >= 0 && fwrite(text, 1, static_cast(header + track), file) == static_cast(header + track); + fclose(file); + } + return made; + }; + if (!runOnCard(io, create, IoPriority::Background)) { + return false; + } + filter.reset(); + drawnCount = 0; + segmentCount = 0; + pendingLength = 0; + newSegment = false; + startedMs = nowMs; + pausedMs = 0; + lastFlushMs = nowMs; + currentState = State::Recording; + logInfo("Recording track %s", path); + return true; +} + +void TrackRecorder::pause(uint32_t nowMs) { + if (currentState == State::Recording) { + currentState = State::Paused; + pauseStartMs = nowMs; + } +} + +void TrackRecorder::resume(uint32_t nowMs) { + if (currentState == State::Paused) { + currentState = State::Recording; + pausedMs += nowMs - pauseStartMs; + // A new segment, so the line does not jump across what was not recorded + newSegment = true; + filter.reset(); + } +} + +uint32_t TrackRecorder::elapsedMs(uint32_t nowMs) const { + if (currentState == State::Off) { + return 0; + } + const uint32_t end = currentState == State::Paused ? pauseStartMs : nowMs; + return end - startedMs - pausedMs; +} + +bool TrackRecorder::segmentStartsAt(int index) const { + for (int i = 0; i < segmentCount; i++) { + if (segmentStarts[i] == index) { + return true; + } + } + return false; +} + +void TrackRecorder::addFix(const PositionFix& fix) { + if (currentState != State::Recording || !filter.accept(fix)) { + return; + } + char line[256]; + int used = 0; + if (newSegment) { + used = snprintf(line, sizeof(line), "\n"); + if (segmentCount < MAX_SEGMENT_STARTS) { + segmentStarts[segmentCount++] = drawnCount; + } + newSegment = false; + } + GpxPoint point {}; + point.kind = GpxKind::TrackPoint; + point.lat = fix.latitude; + point.lon = fix.longitude; + point.elevation = NAN; + point.time = fix.timeMs > 0 ? fix.timeMs / 1000 : static_cast(time(nullptr)); + const int written = gpxTrackPoint(line + used, sizeof(line) - static_cast(used), point); + if (written > 0 && pendingLength + static_cast(used + written) <= pendingCapacity) { + memcpy(pending + pendingLength, line, static_cast(used + written)); + pendingLength += static_cast(used + written); + } + if (ensureRoom()) { + drawn[drawnCount++] = { static_cast(fix.latitude), static_cast(fix.longitude) }; + } +} + +bool TrackRecorder::flush(IoTask& io, const char* closing) { + if (pendingLength == 0 && closing == nullptr) { + return true; + } + auto append = [&] { + FILE* file = fopen(path, "ab"); + if (file == nullptr) { + return false; + } + bool ok = pendingLength == 0 || fwrite(pending, 1, pendingLength, file) == pendingLength; + if (ok && closing != nullptr) { + ok = fputs(closing, file) >= 0; + } + fclose(file); + return ok; + }; + const bool ok = runOnCard(io, append, IoPriority::Background); + if (ok) { + pendingLength = 0; + } else { + logError("Could not write to %s", path); + } + return ok; +} + +void TrackRecorder::flushIfDue(IoTask& io, uint32_t nowMs) { + if (currentState != State::Off && nowMs - lastFlushMs >= FLUSH_INTERVAL_MS) { + lastFlushMs = nowMs; + flush(io, nullptr); + } +} + +void TrackRecorder::stop(IoTask& io, uint32_t nowMs) { + if (currentState == State::Off) { + return; + } + (void)nowMs; + flush(io, TRACK_END); + logInfo("Track %s stopped: %d points, %.0f m", path, drawnCount, filter.distance()); + currentState = State::Off; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/app/TrackRecorder.h b/Apps/Maps/main/Source/app/TrackRecorder.h new file mode 100644 index 0000000..2265b7d --- /dev/null +++ b/Apps/Maps/main/Source/app/TrackRecorder.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IoTask.h" + +#include "../gpx/Track.h" +#include "../platform/Position.h" +#include "../tiles/TilePaths.h" + +#include + +namespace maps { + +/** + * Records the user's track into tracks/YYYYMMDD-HHMM.gpx in the app's folder, only while the app is open. Points come + * from the position updates through TrackFilter, are buffered, and appended to the file every 30 s, so a crash loses + * at most 30 s; a file left open by a crash is closed the next time the app starts. + */ +class TrackRecorder { +public: + enum class State : uint8_t { + Off, + Recording, + Paused + }; + + /** A recorded point for drawing, in degrees; floats are good to about half a metre. */ + struct Point { + float lat; + float lon; + }; + + static constexpr int MAX_DRAWN_POINTS = 20000; + static constexpr uint32_t FLUSH_INTERVAL_MS = 30000; + + ~TrackRecorder(); + /** Closes track files a crash left without their end tags. Call once at start, on the I/O task's side. */ + static void closeUnfinished(IoTask& io, const char* root); + + bool start(IoTask& io, const char* root, uint32_t nowMs); + void pause(uint32_t nowMs); + void resume(uint32_t nowMs); + void stop(IoTask& io, uint32_t nowMs); + /** A new position; stored when it moved far enough (TrackFilter). */ + void addFix(const PositionFix& fix); + /** Appends the buffered points once 30 s have passed since the last write. */ + void flushIfDue(IoTask& io, uint32_t nowMs); + + State state() const { return currentState; } + bool isActive() const { return currentState != State::Off; } + /** Time spent recording, pauses left out. */ + uint32_t elapsedMs(uint32_t nowMs) const; + double distanceMetres() const { return filter.distance(); } + const Point* points() const { return drawn; } + int pointCount() const { return drawnCount; } + /** Where a new segment starts in points(), after a pause. */ + bool segmentStartsAt(int index) const; + const char* fileName() const { return name; } + +private: + bool flush(IoTask& io, const char* closing); + bool ensureRoom(); + + State currentState = State::Off; + TrackFilter filter; + char path[PATH_MAX_LENGTH] = ""; + char name[24] = ""; + uint32_t startedMs = 0; + uint32_t pausedMs = 0; + uint32_t pauseStartMs = 0; + uint32_t lastFlushMs = 0; + /** GPX text not yet on the card. */ + char* pending = nullptr; + size_t pendingLength = 0; + size_t pendingCapacity = 0; + Point* drawn = nullptr; + int drawnCount = 0; + int drawnCapacity = 0; + static constexpr int MAX_SEGMENT_STARTS = 32; + int segmentStarts[MAX_SEGMENT_STARTS] {}; + int segmentCount = 0; + bool newSegment = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/gpx/GpxReader.cpp b/Apps/Maps/main/Source/gpx/GpxReader.cpp new file mode 100644 index 0000000..3863bcb --- /dev/null +++ b/Apps/Maps/main/Source/gpx/GpxReader.cpp @@ -0,0 +1,779 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "GpxReader.h" + +#include +#include +#include + +namespace maps { + +namespace { + +bool isSpace(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +const char* localName(const char* name) { + const char* local = name; + for (const char* p = name; *p != '\0'; p++) { + if (*p == ':') { + local = p + 1; + } + } + return local; +} + +bool parseDouble(const char* text, double& out) { + char* end = nullptr; + const double value = strtod(text, &end); + if (end == text) { + return false; + } + while (isSpace(*end)) { + end++; + } + if (*end != '\0') { + return false; + } + out = value; + return true; +} + +int64_t daysFromCivil(int year, unsigned month, unsigned day) { + year -= month <= 2; + const int64_t era = (year >= 0 ? year : year - 399) / 400; + const unsigned yearOfEra = static_cast(year - era * 400); + const int adjustedMonth = static_cast(month) + (month > 2 ? -3 : 9); + const unsigned dayOfYear = static_cast((153 * adjustedMonth + 2) / 5 + static_cast(day) - 1); + const unsigned dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear; + return era * 146097 + static_cast(dayOfEra) - 719468; +} + +bool readDigits(const char*& text, int count, int& value) { + value = 0; + for (int i = 0; i < count; i++) { + if (text[i] < '0' || text[i] > '9') { + return false; + } + value = value * 10 + (text[i] - '0'); + } + text += count; + return true; +} + +bool parseTimeText(const char* text, int64_t& out) { + const char* p = text; + while (isSpace(*p)) { + p++; + } + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int minute = 0; + int second = 0; + if (!readDigits(p, 4, year) || *p++ != '-') { + return false; + } + if (!readDigits(p, 2, month) || *p++ != '-') { + return false; + } + if (!readDigits(p, 2, day) || (*p != 'T' && *p != 't')) { + return false; + } + p++; + if (!readDigits(p, 2, hour) || *p++ != ':') { + return false; + } + if (!readDigits(p, 2, minute) || *p++ != ':') { + return false; + } + if (!readDigits(p, 2, second)) { + return false; + } + if (*p == '.') { + p++; + if (*p < '0' || *p > '9') { + return false; + } + while (*p >= '0' && *p <= '9') { + p++; + } + } + int offset = 0; + if (*p == 'Z' || *p == 'z') { + p++; + } else if (*p == '+' || *p == '-') { + const int sign = *p == '+' ? 1 : -1; + p++; + int offsetHour = 0; + int offsetMinute = 0; + if (!readDigits(p, 2, offsetHour) || *p++ != ':') { + return false; + } + if (!readDigits(p, 2, offsetMinute)) { + return false; + } + offset = sign * (offsetHour * 3600 + offsetMinute * 60); + } else { + return false; + } + while (isSpace(*p)) { + p++; + } + if (*p != '\0') { + return false; + } + if (month < 1 || month > 12 || day < 1 || day > 31) { + return false; + } + if (hour > 23 || minute > 59 || second > 60) { + return false; + } + const int64_t days = daysFromCivil(year, static_cast(month), static_cast(day)); + out = days * 86400 + hour * 3600 + minute * 60 + second - offset; + return true; +} + +void appendCodePoint(char* out, size_t size, size_t& used, uint32_t code) { + if (code < 0x80) { + if (used + 1 < size) { + out[used++] = static_cast(code); + } + } else if (code < 0x800) { + if (used + 2 < size) { + out[used++] = static_cast(0xC0 | (code >> 6)); + out[used++] = static_cast(0x80 | (code & 0x3F)); + } + } else if (code < 0x10000) { + if (used + 3 < size) { + out[used++] = static_cast(0xE0 | (code >> 12)); + out[used++] = static_cast(0x80 | ((code >> 6) & 0x3F)); + out[used++] = static_cast(0x80 | (code & 0x3F)); + } + } else if (code <= 0x10FFFF) { + if (used + 4 < size) { + out[used++] = static_cast(0xF0 | (code >> 18)); + out[used++] = static_cast(0x80 | ((code >> 12) & 0x3F)); + out[used++] = static_cast(0x80 | ((code >> 6) & 0x3F)); + out[used++] = static_cast(0x80 | (code & 0x3F)); + } + } +} + +void decodeEntities(const char* in, char* out, size_t size) { + if (size == 0) { + return; + } + size_t used = 0; + for (size_t index = 0; in[index] != '\0';) { + if (in[index] == '&') { + size_t semi = index + 1; + while (semi < index + 12 && in[semi] != '\0' && in[semi] != ';') { + semi++; + } + if (in[semi] == ';') { + const char* name = in + index + 1; + const size_t length = semi - index - 1; + char decoded = '\0'; + if (length == 3 && strncmp(name, "amp", 3) == 0) { + decoded = '&'; + } else if (length == 2 && strncmp(name, "lt", 2) == 0) { + decoded = '<'; + } else if (length == 2 && strncmp(name, "gt", 2) == 0) { + decoded = '>'; + } else if (length == 4 && strncmp(name, "quot", 4) == 0) { + decoded = '"'; + } else if (length == 4 && strncmp(name, "apos", 4) == 0) { + decoded = '\''; + } + if (decoded != '\0') { + if (used + 1 < size) { + out[used++] = decoded; + } + index = semi + 1; + continue; + } + if (length > 1 && name[0] == '#') { + bool hex = false; + size_t digit = 1; + if (name[1] == 'x' || name[1] == 'X') { + hex = true; + digit = 2; + } + uint32_t code = 0; + bool valid = digit < length; + for (; digit < length; digit++) { + const char c = name[digit]; + int value = -1; + if (c >= '0' && c <= '9') { + value = c - '0'; + } else if (hex && c >= 'a' && c <= 'f') { + value = c - 'a' + 10; + } else if (hex && c >= 'A' && c <= 'F') { + value = c - 'A' + 10; + } + if (value < 0) { + valid = false; + break; + } + code = code * (hex ? 16u : 10u) + static_cast(value); + if (code > 0x10FFFF) { + valid = false; + break; + } + } + if (valid && code != 0) { + appendCodePoint(out, size, used, code); + } + index = semi + 1; + continue; + } + } + } + if (used + 1 < size) { + out[used++] = in[index]; + } + index++; + } + out[used] = '\0'; +} + +} // namespace + +GpxReader::GpxReader(GpxListener& listener) : listener_(listener) {} + +bool GpxReader::feed(const char* data, size_t size) { + if (failed_) { + return false; + } + for (size_t i = 0; i < size; i++) { + handleByte(data[i]); + if (failed_) { + return false; + } + } + return true; +} + +bool GpxReader::finish() { + if (failed_) { + return false; + } + if (!rootSeen_) { + fail("Not a GPX file"); + return false; + } + if (!rootClosed_ || state_ != State::Content) { + fail("The file is incomplete"); + return false; + } + return true; +} + +int GpxReader::pointCount() const { + return pointCount_; +} + +int GpxReader::skippedPoints() const { + return skippedPoints_; +} + +const char* GpxReader::error() const { + return error_; +} + +void GpxReader::fail(const char* message) { + if (!failed_) { + failed_ = true; + error_ = message; + } +} + +void GpxReader::resetTag() { + tagName_[0] = '\0'; + tagLen_ = 0; + attrName_[0] = '\0'; + attrLen_ = 0; + attrValue_[0] = '\0'; + attrValueLen_ = 0; + tagLat_ = 0.0; + tagLon_ = 0.0; + tagHasLat_ = false; + tagHasLon_ = false; +} + +void GpxReader::appendText(char c) { + if (depth_ <= 0) { + return; + } + const Role role = stack_[depth_ - 1].role; + if (role == Role::LineName || role == Role::PointName || role == Role::Ele || role == Role::Time) { + if (textLen_ + 1 < sizeof(text_)) { + text_[textLen_++] = c; + text_[textLen_] = '\0'; + } + } +} + +void GpxReader::appendTagName(char c) { + if (tagLen_ + 1 < sizeof(tagName_)) { + tagName_[tagLen_++] = c; + tagName_[tagLen_] = '\0'; + } +} + +void GpxReader::appendAttrName(char c) { + if (attrLen_ + 1 < sizeof(attrName_)) { + attrName_[attrLen_++] = c; + attrName_[attrLen_] = '\0'; + } +} + +void GpxReader::appendAttrValue(char c) { + if (attrValueLen_ + 1 < sizeof(attrValue_)) { + attrValue_[attrValueLen_++] = c; + attrValue_[attrValueLen_] = '\0'; + } +} + +void GpxReader::applyAttribute(bool hasValue) { + if (!hasValue) { + return; + } + attrName_[attrLen_] = '\0'; + attrValue_[attrValueLen_] = '\0'; + const char* name = localName(attrName_); + double value = 0.0; + if (strcmp(name, "lat") == 0) { + if (parseDouble(attrValue_, value)) { + tagLat_ = value; + tagHasLat_ = true; + } + } else if (strcmp(name, "lon") == 0) { + if (parseDouble(attrValue_, value)) { + tagLon_ = value; + tagHasLon_ = true; + } + } +} + +void GpxReader::handleByte(char c) { + switch (state_) { + case State::Content: + if (c == '<') { + sawTag_ = true; + resetTag(); + state_ = State::TagStart; + } else { + if (!sawTag_ && !isSpace(c)) { + nonSpace_++; + if (nonSpace_ >= 256) { + fail("Not a GPX file"); + return; + } + } + appendText(c); + } + break; + case State::TagStart: + if (c == '/') { + tagName_[0] = '\0'; + tagLen_ = 0; + state_ = State::EndTagName; + } else if (c == '!') { + state_ = State::Bang; + commentDashes_ = 0; + } else if (c == '?') { + state_ = State::Pi; + piQuestion_ = false; + } else if (isSpace(c)) { + break; + } else { + appendTagName(c); + state_ = State::TagName; + } + break; + case State::Bang: + if (c == '-') { + state_ = State::BangDash; + } else if (c == '[') { + cdataMatch_ = 0; + state_ = State::CDataOpen; + } else if (c == '>') { + state_ = State::Content; + } else { + state_ = State::Doctype; + } + break; + case State::BangDash: + if (c == '-') { + commentDashes_ = 0; + state_ = State::Comment; + } else { + state_ = State::Doctype; + } + break; + case State::CDataOpen: + if (c == "CDATA["[cdataMatch_]) { + cdataMatch_++; + if (cdataMatch_ == 6) { + cdataBrackets_ = 0; + state_ = State::CData; + } + } else { + state_ = State::Doctype; + } + break; + case State::Comment: + if (c == '-') { + commentDashes_++; + } else if (c == '>' && commentDashes_ >= 2) { + commentDashes_ = 0; + state_ = State::Content; + } else { + commentDashes_ = 0; + } + break; + case State::CData: + if (c == ']') { + cdataBrackets_++; + } else if (c == '>' && cdataBrackets_ >= 2) { + cdataBrackets_ = 0; + state_ = State::Content; + } else { + cdataBrackets_ = 0; + } + break; + case State::Pi: + if (c == '>' && piQuestion_) { + piQuestion_ = false; + state_ = State::Content; + } else { + piQuestion_ = c == '?'; + } + break; + case State::Doctype: + if (c == '>') { + state_ = State::Content; + } + break; + case State::TagName: + if (c == '>') { + finishStartTag(false); + } else if (c == '/') { + state_ = State::SelfClose; + } else if (isSpace(c)) { + state_ = State::BeforeAttr; + } else { + appendTagName(c); + } + break; + case State::EndTagName: + if (c == '>') { + finishEndTag(); + state_ = State::Content; + } else if (!isSpace(c)) { + appendTagName(c); + } + break; + case State::BeforeAttr: + if (isSpace(c)) { + break; + } + if (c == '>') { + finishStartTag(false); + } else if (c == '/') { + state_ = State::SelfClose; + } else { + attrName_[0] = '\0'; + attrLen_ = 0; + appendAttrName(c); + state_ = State::AttrName; + } + break; + case State::AttrName: + if (c == '=') { + state_ = State::BeforeValue; + } else if (isSpace(c)) { + state_ = State::AfterAttrName; + } else if (c == '>') { + applyAttribute(false); + finishStartTag(false); + } else if (c == '/') { + applyAttribute(false); + state_ = State::SelfClose; + } else { + appendAttrName(c); + } + break; + case State::AfterAttrName: + if (isSpace(c)) { + break; + } + if (c == '=') { + state_ = State::BeforeValue; + } else if (c == '>') { + applyAttribute(false); + finishStartTag(false); + } else if (c == '/') { + applyAttribute(false); + state_ = State::SelfClose; + } else { + applyAttribute(false); + attrName_[0] = '\0'; + attrLen_ = 0; + appendAttrName(c); + state_ = State::AttrName; + } + break; + case State::BeforeValue: + if (isSpace(c)) { + break; + } + if (c == '"' || c == '\'') { + quote_ = c; + attrValue_[0] = '\0'; + attrValueLen_ = 0; + state_ = State::Value; + } else if (c == '>') { + applyAttribute(false); + finishStartTag(false); + } else if (c == '/') { + applyAttribute(false); + state_ = State::SelfClose; + } else { + quote_ = '\0'; + attrValue_[0] = '\0'; + attrValueLen_ = 0; + appendAttrValue(c); + state_ = State::UnquotedValue; + } + break; + case State::Value: + if (c == quote_) { + applyAttribute(true); + state_ = State::BeforeAttr; + } else { + appendAttrValue(c); + } + break; + case State::UnquotedValue: + if (isSpace(c)) { + applyAttribute(true); + state_ = State::BeforeAttr; + } else if (c == '>') { + applyAttribute(true); + finishStartTag(false); + } else if (c == '/') { + applyAttribute(true); + state_ = State::SelfClose; + } else { + appendAttrValue(c); + } + break; + case State::SelfClose: + if (c == '>') { + finishStartTag(true); + } + break; + } +} + +void GpxReader::finishStartTag(bool selfClosing) { + tagName_[tagLen_] = '\0'; + if (depth_ == 0) { + if (strcmp(localName(tagName_), "gpx") != 0) { + fail("Not a GPX file"); + return; + } + rootSeen_ = true; + } + if (depth_ >= kMaxDepth) { + fail("The file is incomplete"); + return; + } + const Role parent = depth_ > 0 ? stack_[depth_ - 1].role : Role::None; + const char* name = localName(tagName_); + Role role = Role::Other; + if (parent == Role::None) { + role = strcmp(name, "gpx") == 0 ? Role::Gpx : Role::Other; + } else if (parent == Role::Gpx) { + if (strcmp(name, "wpt") == 0) { + role = Role::Wpt; + } else if (strcmp(name, "rte") == 0) { + role = Role::Rte; + } else if (strcmp(name, "trk") == 0) { + role = Role::Trk; + } else if (strcmp(name, "metadata") == 0) { + role = Role::Metadata; + } + } else if (parent == Role::Trk) { + if (strcmp(name, "name") == 0) { + role = Role::LineName; + } else if (strcmp(name, "trkseg") == 0) { + role = Role::Trkseg; + } + } else if (parent == Role::Rte) { + if (strcmp(name, "name") == 0) { + role = Role::LineName; + } else if (strcmp(name, "rtept") == 0) { + role = Role::Rtept; + } + } else if (parent == Role::Trkseg) { + if (strcmp(name, "trkpt") == 0) { + role = Role::Trkpt; + } + } else if (parent == Role::Wpt || parent == Role::Rtept || parent == Role::Trkpt) { + if (strcmp(name, "name") == 0) { + role = Role::PointName; + } else if (strcmp(name, "ele") == 0) { + role = Role::Ele; + } else if (strcmp(name, "time") == 0) { + role = Role::Time; + } + } + Element& element = stack_[depth_]; + size_t length = strlen(name); + if (length >= sizeof(element.name)) { + length = sizeof(element.name) - 1; + } + memcpy(element.name, name, length); + element.name[length] = '\0'; + element.role = role; + depth_++; + startRole(role); + if (selfClosing) { + finishEndTag(); + } + state_ = State::Content; +} + +void GpxReader::finishEndTag() { + if (depth_ == 0) { + fail("Not a GPX file"); + return; + } + depth_--; + const Role role = stack_[depth_].role; + switch (role) { + case Role::Gpx: + rootClosed_ = true; + break; + case Role::Trk: + case Role::Rte: + ensureLineStarted(); + listener_.endLine(); + lineActive_ = false; + lineStarted_ = false; + break; + case Role::LineName: + decodeText(lineName_, sizeof(lineName_)); + break; + case Role::PointName: + decodeText(currentPoint_.name, sizeof(currentPoint_.name)); + break; + case Role::Ele: { + double value = 0.0; + currentPoint_.elevation = parseDouble(text_, value) ? static_cast(value) : NAN; + break; + } + case Role::Time: { + int64_t value = -1; + if (parseTimeText(text_, value)) { + currentPoint_.time = value; + } + break; + } + case Role::Wpt: + case Role::Rtept: + case Role::Trkpt: + emitPoint(); + break; + default: + break; + } +} + +void GpxReader::startRole(Role role) { + switch (role) { + case Role::Trk: + lineActive_ = true; + lineStarted_ = false; + lineKind_ = GpxKind::TrackPoint; + lineName_[0] = '\0'; + break; + case Role::Rte: + lineActive_ = true; + lineStarted_ = false; + lineKind_ = GpxKind::RoutePoint; + lineName_[0] = '\0'; + break; + case Role::Trkseg: + ensureLineStarted(); + listener_.beginSegment(); + break; + case Role::Wpt: + case Role::Rtept: + case Role::Trkpt: + ensureLineStarted(); + currentPoint_ = {}; + currentPoint_.kind = role == Role::Wpt ? GpxKind::Waypoint + : (role == Role::Rtept ? GpxKind::RoutePoint : GpxKind::TrackPoint); + currentPoint_.elevation = NAN; + currentPoint_.time = -1; + currentPoint_.lat = tagLat_; + currentPoint_.lon = tagLon_; + pointHasLat_ = tagHasLat_; + pointHasLon_ = tagHasLon_; + break; + case Role::LineName: + case Role::PointName: + case Role::Ele: + case Role::Time: + textLen_ = 0; + text_[0] = '\0'; + break; + default: + break; + } +} + +void GpxReader::ensureLineStarted() { + if (lineActive_ && !lineStarted_) { + listener_.beginLine(lineKind_, lineName_); + lineStarted_ = true; + } +} + +void GpxReader::emitPoint() { + if (!pointHasLat_ || !pointHasLon_) { + skippedPoints_++; + return; + } + if (!(currentPoint_.lat >= -90.0 && currentPoint_.lat <= 90.0)) { + skippedPoints_++; + return; + } + if (!(currentPoint_.lon >= -180.0 && currentPoint_.lon <= 180.0)) { + skippedPoints_++; + return; + } + pointCount_++; + listener_.point(currentPoint_); +} + +void GpxReader::decodeText(char* out, size_t size) { + size_t begin = 0; + size_t end = textLen_; + while (begin < end && isSpace(text_[begin])) { + begin++; + } + while (end > begin && isSpace(text_[end - 1])) { + end--; + } + text_[end] = '\0'; + decodeEntities(text_ + begin, out, size); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/gpx/GpxReader.h b/Apps/Maps/main/Source/gpx/GpxReader.h new file mode 100644 index 0000000..4dfb01f --- /dev/null +++ b/Apps/Maps/main/Source/gpx/GpxReader.h @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace maps { + +enum class GpxKind : uint8_t { Waypoint, RoutePoint, TrackPoint }; + +struct GpxPoint { + GpxKind kind; + double lat; + double lon; + float elevation; + int64_t time; + char name[48]; +}; + +class GpxListener { +public: + virtual ~GpxListener() = default; + /** + * A or starts; name may be empty (it is reported when known: the name comes after the start, so + * call this at the first point or at the end of the element, whichever is first, with the name read so far). + */ + virtual void beginLine(GpxKind kind, const char* name) { + (void)kind; + (void)name; + } + /** A new inside a track. */ + virtual void beginSegment() {} + /** A point; for waypoints and route points name/elevation/time are complete, for track points too. */ + virtual void point(const GpxPoint& point) { + (void)point; + } + virtual void endLine() {} +}; + +class GpxReader { +public: + explicit GpxReader(GpxListener& listener); + /** Feeds the next bytes of the file, any chunk size from 1 byte up. Returns false once the file is known to be bad. */ + bool feed(const char* data, size_t size); + /** Call after the last chunk. True when the document was a complete GPX file (root , closed). */ + bool finish(); + /** Counts, and why a file was refused (a short English sentence, "" when fine). */ + int pointCount() const; + int skippedPoints() const; + const char* error() const; + +private: + enum class State : uint8_t { + Content, + TagStart, + Bang, + BangDash, + CDataOpen, + Comment, + CData, + Pi, + Doctype, + TagName, + EndTagName, + BeforeAttr, + AttrName, + AfterAttrName, + BeforeValue, + Value, + UnquotedValue, + SelfClose, + }; + + enum class Role : uint8_t { + None, + Gpx, + Metadata, + Trk, + Trkseg, + Rte, + Wpt, + Rtept, + Trkpt, + LineName, + PointName, + Ele, + Time, + Other, + }; + + struct Element { + char name[32]; + Role role; + }; + + static constexpr int kMaxDepth = 32; + + GpxListener& listener_; + bool failed_ = false; + const char* error_ = ""; + int pointCount_ = 0; + int skippedPoints_ = 0; + bool rootSeen_ = false; + bool rootClosed_ = false; + bool sawTag_ = false; + int nonSpace_ = 0; + + State state_ = State::Content; + char tagName_[32] = {}; + size_t tagLen_ = 0; + char attrName_[32] = {}; + size_t attrLen_ = 0; + char attrValue_[64] = {}; + size_t attrValueLen_ = 0; + char quote_ = '"'; + char text_[64] = {}; + size_t textLen_ = 0; + int commentDashes_ = 0; + int cdataMatch_ = 0; + int cdataBrackets_ = 0; + bool piQuestion_ = false; + + double tagLat_ = 0.0; + double tagLon_ = 0.0; + bool tagHasLat_ = false; + bool tagHasLon_ = false; + + Element stack_[kMaxDepth] = {}; + int depth_ = 0; + + bool lineActive_ = false; + bool lineStarted_ = false; + GpxKind lineKind_ = GpxKind::TrackPoint; + char lineName_[64] = {}; + + GpxPoint currentPoint_ = {}; + bool pointHasLat_ = false; + bool pointHasLon_ = false; + + void fail(const char* message); + void handleByte(char c); + void resetTag(); + void appendText(char c); + void appendTagName(char c); + void appendAttrName(char c); + void appendAttrValue(char c); + void applyAttribute(bool hasValue); + void finishStartTag(bool selfClosing); + void finishEndTag(); + void startRole(Role role); + void ensureLineStarted(); + void emitPoint(); + void decodeText(char* out, size_t size); +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/gpx/GpxWriter.cpp b/Apps/Maps/main/Source/gpx/GpxWriter.cpp new file mode 100644 index 0000000..007302e --- /dev/null +++ b/Apps/Maps/main/Source/gpx/GpxWriter.cpp @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "GpxWriter.h" + +#include +#include +#include + +namespace maps { + +namespace { + +void civilFromDays(int64_t days, int& year, unsigned& month, unsigned& day) { + const int64_t shifted = days + 719468; + const int64_t era = (shifted >= 0 ? shifted : shifted - 146096) / 146097; + const unsigned dayOfEra = static_cast(shifted - era * 146097); + const unsigned yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365; + year = static_cast(yearOfEra) + static_cast(era) * 400; + const unsigned dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100); + const unsigned monthIndex = (5 * dayOfYear + 2) / 153; + day = dayOfYear - (153 * monthIndex + 2) / 5 + 1; + month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9; + year += month <= 2; +} + +struct TextBuilder { + char* out; + size_t size; + int used = 0; + bool overflow = false; + + TextBuilder(char* buffer, size_t capacity) : out(buffer), size(capacity) {} + + void add(const char* text) { + if (overflow) { + return; + } + if (size == 0 || out == nullptr) { + overflow = true; + return; + } + const size_t length = strlen(text); + if (static_cast(used) + length >= size) { + overflow = true; + out[size - 1] = '\0'; + return; + } + memcpy(out + used, text, length); + used += static_cast(length); + out[used] = '\0'; + } + + void addf(const char* format, ...) { + if (overflow) { + return; + } + if (size == 0 || out == nullptr || static_cast(used) >= size) { + overflow = true; + if (out != nullptr && size > 0) { + out[size - 1] = '\0'; + } + return; + } + va_list args; + va_start(args, format); + const int written = vsnprintf(out + used, size - static_cast(used), format, args); + va_end(args); + if (written < 0 || static_cast(written) >= size - static_cast(used)) { + overflow = true; + out[size - 1] = '\0'; + return; + } + used += written; + } + + int result() const { + return overflow ? -1 : used; + } +}; + +void addPointBody(TextBuilder& builder, const GpxPoint& point, const char* tag) { + builder.addf("<%s lat=\"%.7f\" lon=\"%.7f\">", tag, point.lat, point.lon); + if (point.elevation == point.elevation) { + builder.addf("%.1f", static_cast(point.elevation)); + } + if (point.time >= 0) { + char time[32]; + formatGpxTime(point.time, time, sizeof(time)); + builder.addf("", time); + } + char name[256]; + escapeXml(point.name, name, sizeof(name)); + builder.addf("%s", name); +} + +} // namespace + +void formatGpxTime(int64_t seconds, char* out, size_t size) { + if (out == nullptr || size == 0) { + return; + } + int64_t days = seconds / 86400; + int64_t remainder = seconds % 86400; + if (remainder < 0) { + remainder += 86400; + days--; + } + int year = 1970; + unsigned month = 1; + unsigned day = 1; + civilFromDays(days, year, month, day); + const unsigned hour = static_cast(remainder / 3600); + const unsigned minute = static_cast((remainder % 3600) / 60); + const unsigned second = static_cast(remainder % 60); + snprintf(out, size, "%04d-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, minute, second); +} + +void escapeXml(const char* text, char* out, size_t size) { + if (out == nullptr || size == 0) { + return; + } + size_t used = 0; + for (const char* p = text; *p != '\0'; p++) { + const char* replacement = nullptr; + switch (*p) { + case '&': + replacement = "&"; + break; + case '<': + replacement = "<"; + break; + case '>': + replacement = ">"; + break; + case '"': + replacement = """; + break; + case '\'': + replacement = "'"; + break; + default: + break; + } + if (replacement == nullptr) { + if (used + 1 >= size) { + break; + } + out[used++] = *p; + } else { + const size_t length = strlen(replacement); + if (used + length >= size) { + break; + } + memcpy(out + used, replacement, length); + used += length; + } + } + out[used] = '\0'; +} + +int gpxHeader(char* out, size_t size, const char* version) { + TextBuilder builder(out, size); + char escaped[128]; + escapeXml(version, escaped, sizeof(escaped)); + builder.add("\n"); + builder.addf( + "\n", + escaped); + return builder.result(); +} + +int gpxFooter(char* out, size_t size) { + TextBuilder builder(out, size); + builder.add("\n"); + return builder.result(); +} + +int gpxWaypoint(char* out, size_t size, const GpxPoint& point) { + TextBuilder builder(out, size); + addPointBody(builder, point, "wpt"); + builder.add("\n"); + return builder.result(); +} + +int gpxTrackStart(char* out, size_t size, const char* name) { + TextBuilder builder(out, size); + char escaped[256]; + escapeXml(name, escaped, sizeof(escaped)); + builder.addf("%s\n", escaped); + return builder.result(); +} + +int gpxTrackPoint(char* out, size_t size, const GpxPoint& point) { + TextBuilder builder(out, size); + addPointBody(builder, point, "trkpt"); + builder.add("\n"); + return builder.result(); +} + +int gpxTrackEnd(char* out, size_t size) { + TextBuilder builder(out, size); + builder.add("\n"); + return builder.result(); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/gpx/GpxWriter.h b/Apps/Maps/main/Source/gpx/GpxWriter.h new file mode 100644 index 0000000..09db944 --- /dev/null +++ b/Apps/Maps/main/Source/gpx/GpxWriter.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "GpxReader.h" + +#include +#include + +namespace maps { + +/** Formats UTC seconds as 2026-10-30T08:15:00Z into out (at least 21 bytes). */ +void formatGpxTime(int64_t seconds, char* out, size_t size); +/** XML-escapes text (& < > " ') into out, cut to fit, always NUL-terminated. */ +void escapeXml(const char* text, char* out, size_t size); +/** The opening of a GPX 1.1 file up to and including with creator="TactilityMaps ". */ +int gpxHeader(char* out, size_t size, const char* version); +/** "\n" */ +int gpxFooter(char* out, size_t size); +/** One line; elevation NaN or time -1 leave those elements out. */ +int gpxWaypoint(char* out, size_t size, const GpxPoint& point); +/** "..\n", one "...\n", and "\n". */ +int gpxTrackStart(char* out, size_t size, const char* name); +int gpxTrackPoint(char* out, size_t size, const GpxPoint& point); +int gpxTrackEnd(char* out, size_t size); + +} // namespace maps diff --git a/Apps/Maps/main/Source/gpx/Track.cpp b/Apps/Maps/main/Source/gpx/Track.cpp new file mode 100644 index 0000000..0641cba --- /dev/null +++ b/Apps/Maps/main/Source/gpx/Track.cpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Track.h" + +#include + +namespace maps { + +namespace { + +constexpr double EARTH_RADIUS = 6371008.8; +constexpr double DEGREES = 3.14159265358979323846 / 180.0; + +} // namespace + +double distanceMetres(double lat1, double lon1, double lat2, double lon2) { + // Haversine, with the arc sine written through atan2, which is exported to apps + const double dLat = (lat2 - lat1) * DEGREES; + double dLon = (lon2 - lon1) * DEGREES; + if (dLon > 3.14159265358979323846) { + dLon -= 2.0 * 3.14159265358979323846; + } else if (dLon < -3.14159265358979323846) { + dLon += 2.0 * 3.14159265358979323846; + } + const double s = std::sin(dLat / 2.0); + const double t = std::sin(dLon / 2.0); + const double h = s * s + std::cos(lat1 * DEGREES) * std::cos(lat2 * DEGREES) * t * t; + return 2.0 * EARTH_RADIUS * std::atan2(std::sqrt(h), std::sqrt(1.0 - h)); +} + +double bearingDegrees(double lat1, double lon1, double lat2, double lon2) { + const double phi1 = lat1 * DEGREES; + const double phi2 = lat2 * DEGREES; + const double dLon = (lon2 - lon1) * DEGREES; + const double y = std::sin(dLon) * std::cos(phi2); + const double x = std::cos(phi1) * std::sin(phi2) - std::sin(phi1) * std::cos(phi2) * std::cos(dLon); + const double bearing = std::atan2(y, x) / DEGREES; + return bearing < 0.0 ? bearing + 360.0 : bearing; +} + +bool TrackFilter::accept(const PositionFix& fix) { + if (std::isnan(fix.latitude) || std::isnan(fix.longitude) || (!std::isnan(fix.accuracy) && fix.accuracy >= APPROXIMATE_METRES)) { + return false; + } + if (!stored) { + stored = true; + last = fix; + return true; + } + const double moved = distanceMetres(last.latitude, last.longitude, fix.latitude, fix.longitude); + const double accuracy = std::isnan(fix.accuracy) ? 0.0 : static_cast(fix.accuracy); + const double step = accuracy > MIN_STEP_METRES ? accuracy : MIN_STEP_METRES; + // Every 30 s while moving at all: a slow walk still gets points, standing still adds none + const bool due = fix.timeMs - last.timeMs >= MOVING_INTERVAL_MS && moved > MIN_STEP_METRES / 2.0; + if (moved <= step && !due) { + return false; + } + totalMetres += moved; + last = fix; + return true; +} + +void TrackFilter::reset() { + stored = false; + last = {}; + totalMetres = 0.0; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/gpx/Track.h b/Apps/Maps/main/Source/gpx/Track.h new file mode 100644 index 0000000..6bd75a0 --- /dev/null +++ b/Apps/Maps/main/Source/gpx/Track.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../platform/Position.h" + +#include + +namespace maps { + +/** Ground distance in metres between two points, good to well under 1 % for the distances a walk or ride has. */ +double distanceMetres(double lat1, double lon1, double lat2, double lon2); + +/** Initial bearing from the first point to the second, degrees clockwise from north, 0 to 360. */ +double bearingDegrees(double lat1, double lon1, double lat2, double lon2); + +/** + * Decides which position fixes a recorded track keeps: a fix is stored when it moved more than max(5 m, accuracy) + * from the last stored one, or 30 s after it while still moving. Approximate fixes (IP location) are never stored. + * Also adds up the distance of the stored points. + */ +class TrackFilter { +public: + static constexpr double MIN_STEP_METRES = 5.0; + static constexpr int64_t MOVING_INTERVAL_MS = 30000; + /** Fixes this inaccurate are approximate positions, like PositionTracker's ring. */ + static constexpr float APPROXIMATE_METRES = 1500.0f; + + /** Whether to store this fix; when it returns true the fix became the last stored point. */ + bool accept(const PositionFix& fix); + void reset(); + bool hasPoint() const { return stored; } + double distance() const { return totalMetres; } + +private: + bool stored = false; + PositionFix last {}; + double totalMetres = 0.0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/ByteInput.cpp b/Apps/Maps/main/Source/mvt/ByteInput.cpp new file mode 100644 index 0000000..55867fe --- /dev/null +++ b/Apps/Maps/main/Source/mvt/ByteInput.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ByteInput.h" + +#include "../platform/Platform.h" + +namespace maps { + +FileBuffer::~FileBuffer() { + largeFree(data); +} + +bool FileBuffer::reserve(size_t size) { + if (size <= capacity) { + return true; + } + largeFree(data); + length = 0; + capacity = (size + 0xFFFF) & ~static_cast(0xFFFF); + data = static_cast(largeAlloc(capacity)); + if (data == nullptr) { + shortfall = size; + capacity = 0; + return false; + } + shortfall = 0; + return true; +} + +void FileBuffer::trim(size_t keepBytes) { + if (capacity > keepBytes) { + largeFree(data); + data = nullptr; + capacity = 0; + length = 0; + } +} + +bool FileBuffer::load(const char* path) { + length = 0; + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return false; + } + bool ok = fseek(file, 0, SEEK_END) == 0; + const long fileSize = ok ? ftell(file) : -1; + ok = ok && fileSize > 0 && fseek(file, 0, SEEK_SET) == 0; + ok = ok && reserve(static_cast(fileSize)); + if (ok) { + length = fread(data, 1, static_cast(fileSize), file); + ok = length == static_cast(fileSize); + } + fclose(file); + if (!ok) { + length = 0; + } + return ok; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/ByteInput.h b/Apps/Maps/main/Source/mvt/ByteInput.h new file mode 100644 index 0000000..f8c1b6d --- /dev/null +++ b/Apps/Maps/main/Source/mvt/ByteInput.h @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +namespace maps { + +/** A sequential byte source that can start over from the beginning. */ +class ByteInput { +public: + virtual ~ByteInput() = default; + /** Returns the number of bytes read, 0 at the end and a negative value on error. */ + virtual int read(uint8_t* buffer, size_t size) = 0; + virtual bool rewind() = 0; +}; + +class FileInput final : public ByteInput { + FILE* file = nullptr; + +public: + ~FileInput() override { close(); } + + bool open(const char* path) { + close(); + file = fopen(path, "rb"); + return file != nullptr; + } + + void close() { + if (file != nullptr) { + fclose(file); + file = nullptr; + } + } + + int read(uint8_t* buffer, size_t size) override { + if (file == nullptr) { + return -1; + } + const size_t count = fread(buffer, 1, size, file); + if (count == 0 && ferror(file)) { + return -1; + } + return static_cast(count); + } + + bool rewind() override { return file != nullptr && fseek(file, 0, SEEK_SET) == 0; } +}; + +class MemoryInput final : public ByteInput { + const uint8_t* data = nullptr; + size_t size = 0; + size_t position = 0; + +public: + MemoryInput() = default; + MemoryInput(const uint8_t* data, size_t size) : data(data), size(size) {} + + void reset(const uint8_t* newData, size_t newSize) { + data = newData; + size = newSize; + position = 0; + } + + int read(uint8_t* buffer, size_t count) override { + const size_t left = size - position; + const size_t n = count < left ? count : left; + memcpy(buffer, data + position, n); + position += n; + return static_cast(n); + } + + bool rewind() override { + position = 0; + return true; + } +}; + +/** A whole file in one buffer that grows as needed, so a tile is read from the card only once. */ +class FileBuffer { + uint8_t* data = nullptr; + size_t capacity = 0; + size_t length = 0; + size_t shortfall = 0; + +public: + FileBuffer() = default; + ~FileBuffer(); + FileBuffer(const FileBuffer&) = delete; + FileBuffer& operator=(const FileBuffer&) = delete; + + bool load(const char* path); + const uint8_t* bytes() const { return data; } + size_t size() const { return length; } + + /** For readers that fill the buffer themselves: room for size bytes, then setSize() with what was read. */ + bool reserve(size_t size); + uint8_t* writable() { return data; } + void setSize(size_t size) { length = size <= capacity ? size : 0; } + void clear() { + length = 0; + shortfall = 0; + } + /** The size reserve() could not get a block for since the last clear(), or 0: the file was there, memory was not. */ + size_t lastShortfall() const { return shortfall; } + /** Frees the buffer when it grew beyond keepBytes, so one big low-zoom tile does not hold PSRAM for good. */ + void trim(size_t keepBytes); +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/InflateReader.cpp b/Apps/Maps/main/Source/mvt/InflateReader.cpp new file mode 100644 index 0000000..f3d153f --- /dev/null +++ b/Apps/Maps/main/Source/mvt/InflateReader.cpp @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "InflateReader.h" + +#include "../platform/Platform.h" + +#include +#include +#include + +namespace maps { + +namespace { + +constexpr size_t WINDOW_SIZE = TINFL_LZ_DICT_SIZE; +constexpr size_t INPUT_BUFFER_SIZE = 4096; + +tinfl_decompressor* asDecompressor(void* pointer) { + return static_cast(pointer); +} + +} // namespace + +InflateReader::~InflateReader() { + largeFree(decompressor); + largeFree(window); + largeFree(inputBuffer); +} + +bool InflateReader::init() { + if (decompressor == nullptr) { + decompressor = fastAlloc(sizeof(tinfl_decompressor)); + } + if (window == nullptr) { + window = static_cast(largeAlloc(WINDOW_SIZE)); + } + if (inputBuffer == nullptr) { + inputBuffer = static_cast(fastAlloc(INPUT_BUFFER_SIZE)); + } + return decompressor != nullptr && window != nullptr && inputBuffer != nullptr; +} + +bool InflateReader::begin(ByteInput* source) { + input = source; + inputPosition = 0; + inputSize = 0; + inputEnded = false; + windowOffset = 0; + pending = nullptr; + pendingEnd = nullptr; + position = 0; + finished = false; + error = false; + if (decompressor == nullptr || !input->rewind() || !fillInput()) { + error = true; + return false; + } + tinfl_init(asDecompressor(decompressor)); + if (inputSize >= 2 && inputBuffer[0] == 0x1F && inputBuffer[1] == 0x8B) { + streamFormat = Format::Gzip; + if (!skipGzipHeader()) { + error = true; + return false; + } + } else if (inputSize >= 2 && (inputBuffer[0] & 0x0F) == 8 && ((inputBuffer[0] << 8) | inputBuffer[1]) % 31 == 0) { + streamFormat = Format::Zlib; + } else { + streamFormat = Format::Raw; + } + return true; +} + +bool InflateReader::fillInput() { + if (inputEnded) { + return false; + } + if (inputPosition < inputSize) { + memmove(inputBuffer, inputBuffer + inputPosition, inputSize - inputPosition); + } + inputSize -= inputPosition; + inputPosition = 0; + const int count = input->read(inputBuffer + inputSize, INPUT_BUFFER_SIZE - inputSize); + if (count < 0) { + error = true; + inputEnded = true; + return false; + } + if (count == 0) { + inputEnded = true; + return inputSize > 0; + } + inputSize += static_cast(count); + return true; +} + +int InflateReader::getInputByte() { + if (inputPosition >= inputSize && !fillInput()) { + return -1; + } + return inputBuffer[inputPosition++]; +} + +bool InflateReader::skipGzipHeader() { + uint8_t header[10]; + for (unsigned char& byte : header) { + const int value = getInputByte(); + if (value < 0) { + return false; + } + byte = static_cast(value); + } + if (header[2] != 8) { + return false; + } + const uint8_t flags = header[3]; + if (flags & 0x04) { + const int low = getInputByte(); + const int high = getInputByte(); + if (low < 0 || high < 0) { + return false; + } + for (int i = 0; i < (low | (high << 8)); i++) { + if (getInputByte() < 0) { + return false; + } + } + } + for (const uint8_t flag : { static_cast(0x08), static_cast(0x10) }) { + if (flags & flag) { + int value; + do { + value = getInputByte(); + } while (value > 0); + if (value < 0) { + return false; + } + } + } + if (flags & 0x02) { + getInputByte(); + getInputByte(); + } + return true; +} + +bool InflateReader::refill() { + if (finished || error) { + return false; + } + if (streamFormat == Format::Raw) { + if (inputPosition >= inputSize) { + inputSize = 0; + inputPosition = 0; + if (!fillInput()) { + finished = true; + return false; + } + } + pending = inputBuffer + inputPosition; + pendingEnd = inputBuffer + inputSize; + inputPosition = inputSize; + return true; + } + + while (true) { + if (inputPosition >= inputSize && !inputEnded) { + fillInput(); + } + size_t inBytes = inputSize - inputPosition; + size_t outBytes = WINDOW_SIZE - windowOffset; + mz_uint32 flags = inputEnded ? 0 : TINFL_FLAG_HAS_MORE_INPUT; + if (streamFormat == Format::Zlib) { + flags |= TINFL_FLAG_PARSE_ZLIB_HEADER; + } + const tinfl_status status = tinfl_decompress( + asDecompressor(decompressor), + inputBuffer + inputPosition, + &inBytes, + window, + window + windowOffset, + &outBytes, + flags + ); + inputPosition += inBytes; + if (outBytes > 0) { + pending = window + windowOffset; + pendingEnd = pending + outBytes; + windowOffset = (windowOffset + outBytes) & (WINDOW_SIZE - 1); + if (status == TINFL_STATUS_DONE) { + finished = true; + } + return true; + } + if (status == TINFL_STATUS_DONE) { + finished = true; + return false; + } + if (status < TINFL_STATUS_DONE) { + error = true; + return false; + } + if (status == TINFL_STATUS_NEEDS_MORE_INPUT && inputEnded) { + error = true; + return false; + } + } +} + +size_t InflateReader::read(uint8_t* buffer, size_t size) { + size_t done = 0; + while (done < size) { + if (pending == pendingEnd && !refill()) { + break; + } + size_t count = static_cast(pendingEnd - pending); + if (count > size - done) { + count = size - done; + } + memcpy(buffer + done, pending, count); + pending += count; + done += count; + } + position += done; + return done; +} + +bool InflateReader::skip(size_t count) { + while (count > 0) { + if (pending == pendingEnd && !refill()) { + return false; + } + size_t available = static_cast(pendingEnd - pending); + if (available > count) { + available = count; + } + pending += available; + position += available; + count -= available; + } + return true; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/InflateReader.h b/Apps/Maps/main/Source/mvt/InflateReader.h new file mode 100644 index 0000000..4306262 --- /dev/null +++ b/Apps/Maps/main/Source/mvt/InflateReader.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ByteInput.h" + +#include +#include + +namespace maps { + +/** + * Reads a gzip, zlib or uncompressed stream byte by byte, inflating with tinfl into a 32 KB ring window. + * The format is detected from the first two bytes. Memory is allocated once in init(). + */ +class InflateReader { +public: + enum class Format : uint8_t { + Raw, + Gzip, + Zlib + }; + + InflateReader() = default; + ~InflateReader(); + InflateReader(const InflateReader&) = delete; + InflateReader& operator=(const InflateReader&) = delete; + + bool init(); + /** Starts reading input from its beginning. */ + bool begin(ByteInput* input); + + /** Returns the next byte, or -1 at the end of the stream or on error. */ + inline int get() { + if (pending == pendingEnd && !refill()) { + return -1; + } + position++; + return *pending++; + } + + size_t read(uint8_t* buffer, size_t size); + bool skip(size_t count); + + /** Bytes of output consumed since begin(). */ + size_t tell() const { return position; } + bool failed() const { return error; } + Format format() const { return streamFormat; } + +private: + bool refill(); + bool fillInput(); + bool skipGzipHeader(); + int getInputByte(); + + ByteInput* input = nullptr; + void* decompressor = nullptr; + uint8_t* window = nullptr; + uint8_t* inputBuffer = nullptr; + size_t inputPosition = 0; + size_t inputSize = 0; + bool inputEnded = false; + size_t windowOffset = 0; + const uint8_t* pending = nullptr; + const uint8_t* pendingEnd = nullptr; + size_t position = 0; + Format streamFormat = Format::Raw; + bool finished = false; + bool error = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/MvtDecoder.cpp b/Apps/Maps/main/Source/mvt/MvtDecoder.cpp new file mode 100644 index 0000000..1e659f3 --- /dev/null +++ b/Apps/Maps/main/Source/mvt/MvtDecoder.cpp @@ -0,0 +1,616 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MvtDecoder.h" + +#include "../platform/Platform.h" + +#include + +namespace maps { + +namespace { + +constexpr uint32_t WIRE_VARINT = 0; +constexpr uint32_t WIRE_64BIT = 1; +constexpr uint32_t WIRE_LENGTH = 2; +constexpr uint32_t WIRE_32BIT = 5; + +constexpr uint16_t INTEGER_FLAG = 0x8000; + +constexpr uint32_t COMMAND_MOVE_TO = 1; +constexpr uint32_t COMMAND_LINE_TO = 2; +constexpr uint32_t COMMAND_CLOSE_PATH = 7; + +constexpr int32_t zigzag(uint32_t value) { + return static_cast((value >> 1) ^ (~(value & 1) + 1)); +} + +uint16_t integerCode(int64_t value) { + if (value < 0) { + return 0; + } + return static_cast(INTEGER_FLAG | (value > 0x7FFF ? 0x7FFF : value)); +} + +int32_t clampRank(int64_t value) { + if (value < INT32_MIN) { + return INT32_MIN; + } + if (value > INT32_MAX) { + return INT32_MAX; + } + return static_cast(value); +} + +} // namespace + +MvtDecoder::~MvtDecoder() { + largeFree(valuePool); + largeFree(namePool); + largeFree(chunk); +} + +bool MvtDecoder::init() { + if (valuePool == nullptr) { + valuePool = static_cast(largeAlloc(VALUE_POOL_SIZE * sizeof(ValueEntry))); + valuePoolCapacity = valuePool != nullptr ? VALUE_POOL_SIZE : 0; + } + if (namePool == nullptr) { + namePool = static_cast(largeAlloc(NAME_POOL_SIZE)); + namePoolCapacity = namePool != nullptr ? NAME_POOL_SIZE : 0; + } + if (chunk == nullptr) { + chunk = static_cast(largeAlloc(CHUNK_POINTS * sizeof(TilePoint))); + } + return reader.init() && valuePool != nullptr && namePool != nullptr && chunk != nullptr; +} + +bool MvtDecoder::growPools() { + // Without memory for the bigger pools the normal ones stay: more POI strings go without text, nothing fails + if (valuePoolCapacity < POI_VALUE_POOL_SIZE) { + if (auto* bigger = static_cast(largeAlloc(POI_VALUE_POOL_SIZE * sizeof(ValueEntry)))) { + largeFree(valuePool); + valuePool = bigger; + valuePoolCapacity = POI_VALUE_POOL_SIZE; + } + } + if (namePoolCapacity < POI_NAME_POOL_SIZE) { + if (auto* bigger = static_cast(largeAlloc(POI_NAME_POOL_SIZE))) { + largeFree(namePool); + namePool = bigger; + namePoolCapacity = POI_NAME_POOL_SIZE; + } + } + return valuePool != nullptr && namePool != nullptr; +} + +void MvtDecoder::shrinkPools() { + if (valuePoolCapacity > VALUE_POOL_SIZE) { + if (auto* smaller = static_cast(largeAlloc(VALUE_POOL_SIZE * sizeof(ValueEntry)))) { + largeFree(valuePool); + valuePool = smaller; + valuePoolCapacity = VALUE_POOL_SIZE; + } + } + if (namePoolCapacity > NAME_POOL_SIZE) { + if (auto* smaller = static_cast(largeAlloc(NAME_POOL_SIZE))) { + largeFree(namePool); + namePool = smaller; + namePoolCapacity = NAME_POOL_SIZE; + } + } + valuePoolUsed = 0; + namePoolUsed = 0; +} + +bool MvtDecoder::readVarint(uint64_t& value) { + value = 0; + for (int shift = 0; shift < 64; shift += 7) { + const int byte = reader.get(); + if (byte < 0) { + return false; + } + value |= static_cast(byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + return true; + } + } + return false; +} + +bool MvtDecoder::skipField(uint32_t wireType) { + uint64_t value; + switch (wireType) { + case WIRE_VARINT: + return readVarint(value); + case WIRE_64BIT: + return reader.skip(8); + case WIRE_LENGTH: + return readVarint(value) && reader.skip(static_cast(value)); + case WIRE_32BIT: + return reader.skip(4); + default: + return false; + } +} + +const MvtDecoder::ValueEntry* MvtDecoder::valueEntry(const LayerTable& table, uint32_t index) const { + if (index >= table.valueCount) { + return nullptr; + } + return &valuePool[table.valueOffset + index]; +} + +bool MvtDecoder::readLayerName(size_t layerEnd, Layer& layer) { + layer = Layer::Unknown; + while (reader.tell() < layerEnd) { + uint64_t key; + if (!readVarint(key)) { + return false; + } + if ((key >> 3) == 1 && (key & 7) == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + char name[24]; + if (length >= sizeof(name)) { + return reader.skip(static_cast(length)); + } + if (reader.read(reinterpret_cast(name), static_cast(length)) != length) { + return false; + } + layer = layerFromName(name, static_cast(length)); + return true; + } + if (!skipField(static_cast(key & 7))) { + return false; + } + } + return true; +} + +bool MvtDecoder::scanValue(size_t valueEnd, ValueEntry& entry) { + entry = {}; + // INT32_MIN: not an integer value (oneway and layer read negative integers from here) + entry.rank = INT32_MIN; + uint16_t code = 0; + int32_t rank = INT32_MIN; + uint32_t textOffset = 0; + uint8_t textLength = 0; + bool hasText = false; + while (reader.tell() < valueEnd) { + uint64_t key; + if (!readVarint(key)) { + return false; + } + const uint32_t field = static_cast(key >> 3); + const uint32_t wire = static_cast(key & 7); + uint64_t number; + if (field == 1 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + char text[256]; + size_t stored = 0; + if (length > 0) { + const size_t wanted = static_cast(length); + stored = wanted < sizeof(text) ? wanted : sizeof(text) - 1; + if (reader.read(reinterpret_cast(text), stored) != stored) { + return false; + } + if (wanted > stored) { + if (!reader.skip(wanted - stored)) { + return false; + } + while (stored > 0 && (static_cast(text[stored - 1]) & 0xC0) == 0x80) { + stored--; + } + if (stored > 0 && (static_cast(text[stored - 1]) & 0xC0) == 0xC0) { + stored--; + } + } + code = static_cast(valueFromString(text, stored)); + if (stored > 0 && namePool != nullptr && namePoolUsed + stored <= namePoolCapacity) { + memcpy(namePool + namePoolUsed, text, stored); + textOffset = namePoolUsed; + textLength = static_cast(stored); + hasText = true; + namePoolUsed += static_cast(stored); + } + } + } else if ((field == 4 || field == 5) && wire == WIRE_VARINT) { + if (!readVarint(number)) { + return false; + } + const int64_t value = static_cast(number); + code = integerCode(value); + rank = clampRank(value); + } else if (field == 6 && wire == WIRE_VARINT) { + if (!readVarint(number)) { + return false; + } + const int64_t value = zigzag(static_cast(number)); + code = integerCode(value); + rank = clampRank(value); + } else if (field == 7 && wire == WIRE_VARINT) { + if (!readVarint(number)) { + return false; + } + code = integerCode(static_cast(number)); + } else if (!skipField(wire)) { + return false; + } + } + entry.code = code; + entry.textLength = textLength; + entry.hasText = static_cast(hasText ? 1 : 0); + if (hasText) { + entry.textOffset = textOffset; + } else { + entry.rank = rank; + } + return true; +} + +bool MvtDecoder::scanLayer(size_t layerEnd, LayerTable& table, Layer layer) { + (void)layer; + uint32_t keyIndex = 0; + table.valueOffset = valuePoolUsed; + table.valueCount = 0; + while (reader.tell() < layerEnd) { + uint64_t key; + if (!readVarint(key)) { + return false; + } + const uint32_t field = static_cast(key >> 3); + const uint32_t wire = static_cast(key & 7); + if (field == 3 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + char name[16]; + if (length < sizeof(name)) { + if (reader.read(reinterpret_cast(name), static_cast(length)) != length) { + return false; + } + name[length] = '\0'; + const auto index = static_cast(keyIndex); + if (strcmp(name, "class") == 0) { + table.classKey = index; + } else if (strcmp(name, "subclass") == 0) { + table.subclassKey = index; + } else if (strcmp(name, "brunnel") == 0) { + table.brunnelKey = index; + } else if (strcmp(name, "admin_level") == 0) { + table.adminLevelKey = index; + } else if (strcmp(name, "maritime") == 0) { + table.maritimeKey = index; + } else if (strcmp(name, "ramp") == 0) { + table.rampKey = index; + } else if (strcmp(name, "oneway") == 0) { + table.onewayKey = index; + } else if (strcmp(name, "layer") == 0) { + table.layerKey = index; + } else if (strcmp(name, "name") == 0) { + table.nameKey = index; + } else if (strcmp(name, "name:latin") == 0) { + table.nameLatinKey = index; + } else if (strcmp(name, "rank") == 0) { + table.rankKey = index; + } else if (strcmp(name, "housenumber") == 0) { + table.houseNumberKey = index; + } + } else if (!reader.skip(static_cast(length))) { + return false; + } + keyIndex++; + } else if (field == 4 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + ValueEntry entry; + if (!scanValue(reader.tell() + static_cast(length), entry)) { + return false; + } + if (valuePoolUsed < valuePoolCapacity) { + valuePool[valuePoolUsed++] = entry; + table.valueCount++; + } + } else if (field == 5 && wire == WIRE_VARINT) { + uint64_t extent; + if (!readVarint(extent)) { + return false; + } + table.extent = static_cast(extent); + } else if (!skipField(wire)) { + return false; + } + } + return true; +} + +bool MvtDecoder::scan(ByteInput* source, bool withPoiLayers) { + input = source; + if (withPoiLayers && !growPools()) { + return false; + } + valuePoolUsed = 0; + namePoolUsed = 0; + totalSize = 0; + for (auto& table : tables) { + table = {}; + } + if (!reader.begin(input)) { + return false; + } + int8_t layerIndex = 0; + while (true) { + uint64_t key; + if (!readVarint(key)) { + break; + } + if ((key >> 3) == 3 && (key & 7) == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + const size_t layerEnd = reader.tell() + static_cast(length); + Layer layer; + if (!readLayerName(layerEnd, layer)) { + return false; + } + if (!withPoiLayers && (layer == Layer::Poi || layer == Layer::Housenumber)) { + layer = Layer::Unknown; + } + if (layer != Layer::Unknown) { + LayerTable& table = tables[static_cast(layer)]; + table.fileIndex = layerIndex; + if (!scanLayer(layerEnd, table, layer)) { + return false; + } + } + if (reader.tell() < layerEnd && !reader.skip(layerEnd - reader.tell())) { + return false; + } + layerIndex++; + } else if (!skipField(static_cast(key & 7))) { + return false; + } + } + totalSize = reader.tell(); + return !reader.failed(); +} + +void MvtDecoder::applyTag(const LayerTable& table, uint32_t key, uint32_t value, FeatureInfo& info) const { + const auto keyIndex = static_cast(key); + const ValueEntry* entry = valueEntry(table, value); + if (entry == nullptr) { + return; + } + const uint16_t code = entry->code; + const bool isInteger = (code & INTEGER_FLAG) != 0; + if (keyIndex == table.classKey && !isInteger) { + info.featureClass = static_cast(code); + if (entry->hasText != 0) { + info.classText = namePool + entry->textOffset; + info.classTextLength = entry->textLength; + } + } else if (keyIndex == table.subclassKey && !isInteger) { + info.subclass = static_cast(code); + if (entry->hasText != 0) { + info.subclassText = namePool + entry->textOffset; + info.subclassTextLength = entry->textLength; + } + } else if (keyIndex == table.brunnelKey && !isInteger) { + info.brunnel = static_cast(code); + } else if (keyIndex == table.adminLevelKey && isInteger) { + info.adminLevel = static_cast(code & 0x7F); + } else if (keyIndex == table.maritimeKey && isInteger) { + info.maritime = (code & 0x7FFF) != 0; + } else if (keyIndex == table.rampKey && isInteger) { + info.ramp = (code & 0x7FFF) != 0; + } else if (keyIndex == table.onewayKey && entry->hasText == 0 && entry->rank != INT32_MIN) { + // Negative integers have no integer code, their value is kept in rank + info.oneway = static_cast(entry->rank > 0 ? 1 : (entry->rank < 0 ? -1 : 0)); + } else if (keyIndex == table.layerKey && entry->hasText == 0 && entry->rank != INT32_MIN) { + info.layerNumber = static_cast(entry->rank < -8 ? -8 : (entry->rank > 7 ? 7 : entry->rank)); + } else if (keyIndex == table.nameKey && entry->hasText != 0) { + info.name = namePool + entry->textOffset; + info.nameLength = entry->textLength; + } else if (keyIndex == table.nameLatinKey && entry->hasText != 0) { + info.nameLatin = namePool + entry->textOffset; + info.nameLatinLength = entry->textLength; + } else if (keyIndex == table.rankKey && isInteger) { + info.rank = entry->rank; + } else if (keyIndex == table.houseNumberKey && entry->hasText != 0) { + info.houseNumber = namePool + entry->textOffset; + info.houseNumberLength = entry->textLength; + } +} + +bool MvtDecoder::decodeGeometry(size_t geometryEnd, GeometrySink& sink) { + int32_t x = 0; + int32_t y = 0; + int count = 0; + bool ringOpen = false; + while (reader.tell() < geometryEnd) { + uint64_t commandInteger; + if (!readVarint(commandInteger)) { + return false; + } + const uint32_t command = static_cast(commandInteger & 7); + const uint32_t repeat = static_cast(commandInteger >> 3); + if (command == COMMAND_MOVE_TO || command == COMMAND_LINE_TO) { + for (uint32_t i = 0; i < repeat; i++) { + uint64_t dx; + uint64_t dy; + if (!readVarint(dx) || !readVarint(dy)) { + return false; + } + x += zigzag(static_cast(dx)); + y += zigzag(static_cast(dy)); + if (command == COMMAND_MOVE_TO) { + if (ringOpen) { + sink.points(chunk, count); + sink.endRing(false); + } + sink.beginRing(); + ringOpen = true; + count = 0; + } else if (count == CHUNK_POINTS) { + sink.points(chunk, count); + chunk[0] = chunk[count - 1]; + count = 1; + } + chunk[count++] = { x, y }; + } + } else if (command == COMMAND_CLOSE_PATH) { + if (ringOpen) { + sink.points(chunk, count); + sink.endRing(true); + ringOpen = false; + count = 0; + } + } else { + return false; + } + } + if (ringOpen) { + sink.points(chunk, count); + sink.endRing(false); + } + return true; +} + +bool MvtDecoder::decodeFeature(size_t featureEnd, Layer layer, GeometrySink& sink) { + const LayerTable& table = tables[static_cast(layer)]; + FeatureInfo info; + info.layer = layer; + while (reader.tell() < featureEnd) { + uint64_t key; + if (!readVarint(key)) { + return false; + } + const uint32_t field = static_cast(key >> 3); + const uint32_t wire = static_cast(key & 7); + if (field == 2 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + const size_t tagsEnd = reader.tell() + static_cast(length); + while (reader.tell() < tagsEnd) { + uint64_t tagKey; + uint64_t tagValue; + if (!readVarint(tagKey) || !readVarint(tagValue)) { + return false; + } + applyTag(table, static_cast(tagKey), static_cast(tagValue), info); + } + } else if (field == 3 && wire == WIRE_VARINT) { + uint64_t type; + if (!readVarint(type)) { + return false; + } + info.geometryType = static_cast(type); + } else if (field == 4 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + const size_t geometryEnd = reader.tell() + static_cast(length); + if (sink.beginFeature(info)) { + if (!decodeGeometry(geometryEnd, sink)) { + return false; + } + sink.endFeature(); + } else if (!reader.skip(static_cast(length))) { + return false; + } + } else if (!skipField(wire)) { + return false; + } + } + return true; +} + +bool MvtDecoder::inflate(ByteInput* source, uint8_t* out, size_t size) { + if (!reader.begin(source)) { + return false; + } + if (reader.read(out, size) != size || reader.failed()) { + return false; + } + // Nothing may follow + return reader.get() < 0 && !reader.failed(); +} + +bool MvtDecoder::sweep(const Layer* layers, int count, GeometrySink& sink) { + int remaining = 0; + bool wanted[static_cast(Layer::Count)] = {}; + for (int i = 0; i < count; i++) { + const int index = static_cast(layers[i]); + if (tables[index].fileIndex >= 0 && !wanted[index]) { + wanted[index] = true; + remaining++; + } + } + if (remaining == 0) { + return true; + } + if (!reader.begin(input)) { + return false; + } + while (remaining > 0) { + uint64_t key; + if (!readVarint(key)) { + break; + } + if ((key >> 3) != 3 || (key & 7) != WIRE_LENGTH) { + if (!skipField(static_cast(key & 7))) { + return false; + } + continue; + } + uint64_t length; + if (!readVarint(length)) { + return false; + } + const size_t layerEnd = reader.tell() + static_cast(length); + Layer layer; + if (!readLayerName(layerEnd, layer)) { + return false; + } + if (layer != Layer::Unknown && wanted[static_cast(layer)]) { + while (reader.tell() < layerEnd) { + uint64_t layerKey; + if (!readVarint(layerKey)) { + return false; + } + if ((layerKey >> 3) == 2 && (layerKey & 7) == WIRE_LENGTH) { + if (sink.shouldStop()) { + return false; + } + uint64_t featureLength; + if (!readVarint(featureLength)) { + return false; + } + if (!decodeFeature(reader.tell() + static_cast(featureLength), layer, sink)) { + return false; + } + } else if (!skipField(static_cast(layerKey & 7))) { + return false; + } + } + remaining--; + } else if (reader.tell() < layerEnd && !reader.skip(layerEnd - reader.tell())) { + return false; + } + } + return !reader.failed(); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/MvtDecoder.h b/Apps/Maps/main/Source/mvt/MvtDecoder.h new file mode 100644 index 0000000..f0f6c24 --- /dev/null +++ b/Apps/Maps/main/Source/mvt/MvtDecoder.h @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ByteInput.h" +#include "InflateReader.h" +#include "Schema.h" + +#include + +namespace maps { + +struct TilePoint { + int32_t x; + int32_t y; +}; + +/** + * Receives decoded features. Each ring or line arrives in chunks of points between beginRing() and endRing(), + * so no complete geometry is ever buffered. + */ +class GeometrySink { +public: + virtual ~GeometrySink() = default; + /** Returns false to skip the geometry of this feature. */ + virtual bool beginFeature(const FeatureInfo& info) = 0; + virtual void beginRing() = 0; + virtual void points(const TilePoint* points, int count) = 0; + virtual void endRing(bool closed) = 0; + virtual void endFeature() = 0; + /** Checked before each feature: true ends the sweep early, which then fails. */ + virtual bool shouldStop() { return false; } +}; + +/** + * Streaming Mapbox Vector Tile (2.1) decoder. scan() reads the key and value tables of the wanted layers, + * which come after the features in each layer, then each sweep() decodes features of some layers in file order. + * Every pass inflates the tile again from the start instead of keeping the decompressed tile in memory. + */ +class MvtDecoder { +public: + static constexpr int CHUNK_POINTS = 256; + static constexpr int VALUE_POOL_SIZE = 8192; + static constexpr int NAME_POOL_SIZE = 24576; + /** + * The pools are this big when the poi and housenumber layers are scanned: every tag value of them counts. Those + * layers come last in OpenMapTiles tiles, so in the densest tiles only some of their strings miss the pools (those + * POIs lose their names), never a place or street name. 96 KB + 160 KB, given back after each conversion: a + * 384 KB name pool took the T-Deck's largest free PSRAM block and the label section could not grow (device test, + * 10 Nov 2026). + */ + static constexpr int POI_VALUE_POOL_SIZE = 12288; + static constexpr int POI_NAME_POOL_SIZE = 163840; + + MvtDecoder() = default; + ~MvtDecoder(); + MvtDecoder(const MvtDecoder&) = delete; + MvtDecoder& operator=(const MvtDecoder&) = delete; + + bool init(); + /** + * Reads the tables of the layers. The poi and housenumber layers are skipped, as if the tile had none, unless + * withPoiLayers is set: only the prepared tile converter wants them, and their strings fill the pools. + */ + bool scan(ByteInput* input, bool withPoiLayers = false); + /** Back to the normal pool sizes after a scan with the POI layers. */ + void shrinkPools(); + /** Decodes the features of the given layers, in the order they appear in the tile. */ + bool sweep(const Layer* layers, int count, GeometrySink& sink); + + /** Position of a layer in the tile, -1 when the tile does not have it. */ + int fileIndex(Layer layer) const { return tables[static_cast(layer)].fileIndex; } + int extent(Layer layer) const { return tables[static_cast(layer)].extent; } + size_t decompressedSize() const { return totalSize; } + /** Inflates a whole stream into out with the decoder's decompressor. Returns false unless exactly size bytes came out. */ + bool inflate(ByteInput* source, uint8_t* out, size_t size); + InflateReader::Format format() const { return reader.format(); } + +private: + struct ValueEntry { + uint16_t code; + uint8_t textLength; + uint8_t hasText; + union { + uint32_t textOffset; + int32_t rank; + }; + }; + + struct LayerTable { + int8_t fileIndex = -1; + int16_t classKey = -1; + int16_t subclassKey = -1; + int16_t brunnelKey = -1; + int16_t adminLevelKey = -1; + int16_t maritimeKey = -1; + int16_t rampKey = -1; + int16_t onewayKey = -1; + int16_t layerKey = -1; + int16_t nameKey = -1; + int16_t nameLatinKey = -1; + int16_t rankKey = -1; + int16_t houseNumberKey = -1; + uint32_t valueOffset = 0; + uint32_t valueCount = 0; + int32_t extent = 4096; + }; + + bool growPools(); + bool readVarint(uint64_t& value); + bool readLayerName(size_t layerEnd, Layer& layer); + bool scanLayer(size_t layerEnd, LayerTable& table, Layer layer); + bool scanValue(size_t valueEnd, ValueEntry& entry); + bool decodeFeature(size_t featureEnd, Layer layer, GeometrySink& sink); + void applyTag(const LayerTable& table, uint32_t key, uint32_t value, FeatureInfo& info) const; + bool decodeGeometry(size_t geometryEnd, GeometrySink& sink); + bool skipField(uint32_t wireType); + const ValueEntry* valueEntry(const LayerTable& table, uint32_t index) const; + + InflateReader reader; + ByteInput* input = nullptr; + LayerTable tables[static_cast(Layer::Count)] {}; + ValueEntry* valuePool = nullptr; + uint32_t valuePoolUsed = 0; + uint32_t valuePoolCapacity = 0; + char* namePool = nullptr; + uint32_t namePoolUsed = 0; + uint32_t namePoolCapacity = 0; + TilePoint* chunk = nullptr; + size_t totalSize = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/PlaceFinder.cpp b/Apps/Maps/main/Source/mvt/PlaceFinder.cpp new file mode 100644 index 0000000..192a03f --- /dev/null +++ b/Apps/Maps/main/Source/mvt/PlaceFinder.cpp @@ -0,0 +1,513 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "PlaceFinder.h" + +#include "../platform/Platform.h" + +#include +#include + +namespace maps { + +namespace { + +constexpr uint32_t WIRE_VARINT = 0; +constexpr uint32_t WIRE_64BIT = 1; +constexpr uint32_t WIRE_LENGTH = 2; +constexpr uint32_t WIRE_32BIT = 5; + +constexpr uint32_t COMMAND_MOVE_TO = 1; +constexpr uint32_t COMMAND_LINE_TO = 2; +constexpr uint32_t COMMAND_CLOSE_PATH = 7; + +constexpr int32_t zigzag(uint32_t value) { + return static_cast((value >> 1) ^ (~(value & 1) + 1)); +} + +bool streamEnd(size_t start, uint64_t length, size_t& end) { + if (length > static_cast(SIZE_MAX - start)) { + return false; + } + end = start + static_cast(length); + return true; +} + +} // namespace + +PlaceFinder::~PlaceFinder() { + largeFree(valueOffsets); + largeFree(valueLengths); + largeFree(valueRanks); +} + +bool PlaceFinder::init() { + if (valueOffsets == nullptr) { + valueOffsets = static_cast(largeAlloc(VALUE_TABLE_SIZE * sizeof(uint32_t))); + } + if (valueLengths == nullptr) { + valueLengths = static_cast(largeAlloc(VALUE_TABLE_SIZE * sizeof(uint16_t))); + } + if (valueRanks == nullptr) { + valueRanks = static_cast(largeAlloc(VALUE_TABLE_SIZE * sizeof(uint8_t))); + } + return reader.init() && valueOffsets != nullptr && valueLengths != nullptr && valueRanks != nullptr; +} + +bool PlaceFinder::readVarint(uint64_t& value) { + value = 0; + for (int shift = 0; shift < 64; shift += 7) { + const int byte = reader.get(); + if (byte < 0) { + return false; + } + value |= static_cast(byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + return true; + } + } + return false; +} + +bool PlaceFinder::skipField(uint32_t wireType) { + uint64_t value; + switch (wireType) { + case WIRE_VARINT: + return readVarint(value); + case WIRE_64BIT: + return reader.skip(8); + case WIRE_LENGTH: + return readVarint(value) && reader.skip(static_cast(value)); + case WIRE_32BIT: + return reader.skip(4); + default: + return false; + } +} + +uint8_t PlaceFinder::valueRank(uint32_t index) const { + if (index >= layer.valueCount || index >= VALUE_TABLE_SIZE) { + return 0; + } + return valueRanks[index]; +} + +bool PlaceFinder::scanValue(size_t valueEnd, uint32_t index) { + uint32_t offset = 0; + uint16_t length = 0; + uint8_t rank = 0; + bool stored = false; + while (reader.tell() < valueEnd) { + uint64_t key; + if (!readVarint(key)) { + return false; + } + const uint32_t field = static_cast(key >> 3); + const uint32_t wire = static_cast(key & 7); + if (field == 1 && wire == WIRE_LENGTH) { + uint64_t stringLength; + if (!readVarint(stringLength)) { + return false; + } + const size_t stringOffset = reader.tell(); + if (stringLength < 16) { + char text[16]; + if (reader.read(reinterpret_cast(text), static_cast(stringLength)) != stringLength) { + return false; + } + text[stringLength] = '\0'; + if (strcmp(text, "city") == 0) { + rank = 1; + } else if (strcmp(text, "town") == 0) { + rank = 2; + } else if (strcmp(text, "village") == 0) { + rank = 3; + } + } else if (!reader.skip(static_cast(stringLength))) { + return false; + } + if (stringOffset <= 0xFFFFFFFFu && stringLength <= 0xFFFFFFFFu) { + offset = static_cast(stringOffset); + length = static_cast(stringLength > 0xFFFF ? 0xFFFF : stringLength); + stored = true; + } + } else if (!skipField(wire)) { + return false; + } + if (reader.tell() > valueEnd) { + return false; + } + } + if (index < VALUE_TABLE_SIZE) { + valueOffsets[index] = stored ? offset : 0; + valueLengths[index] = stored ? length : 0; + valueRanks[index] = rank; + } + return true; +} + +bool PlaceFinder::scanTile(ByteInput* tile) { + layer = {}; + layer.extent = 4096; + layer.classKey = -1; + layer.nameKey = -1; + layer.latinKey = -1; + if (!reader.begin(tile)) { + return false; + } + while (true) { + uint64_t key; + if (!readVarint(key)) { + break; + } + const uint32_t field = static_cast(key >> 3); + const uint32_t wire = static_cast(key & 7); + if (field == 3 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + const size_t layerStart = reader.tell(); + size_t layerEnd; + if (!streamEnd(layerStart, length, layerEnd)) { + return false; + } + bool isPlace = false; + bool nameSeen = false; + int16_t classKey = -1; + int16_t nameKey = -1; + int16_t latinKey = -1; + int32_t extent = 4096; + uint32_t keyIndex = 0; + uint32_t valueIndex = 0; + while (reader.tell() < layerEnd) { + uint64_t layerKey; + if (!readVarint(layerKey)) { + return false; + } + const uint32_t layerField = static_cast(layerKey >> 3); + const uint32_t layerWire = static_cast(layerKey & 7); + if (layerField == 1 && layerWire == WIRE_LENGTH) { + uint64_t nameLength; + if (!readVarint(nameLength)) { + return false; + } + char name[16]; + if (nameLength < sizeof(name)) { + if (reader.read(reinterpret_cast(name), static_cast(nameLength)) != nameLength) { + return false; + } + name[nameLength] = '\0'; + isPlace = nameLength == 5 && strcmp(name, "place") == 0; + } else if (!reader.skip(static_cast(nameLength))) { + return false; + } + nameSeen = true; + if (!isPlace) { + if (reader.tell() < layerEnd && !reader.skip(layerEnd - reader.tell())) { + return false; + } + break; + } + } else if (layerField == 3 && layerWire == WIRE_LENGTH) { + uint64_t keyLength; + if (!readVarint(keyLength)) { + return false; + } + char text[16]; + if (keyLength < sizeof(text)) { + if (reader.read(reinterpret_cast(text), static_cast(keyLength)) != keyLength) { + return false; + } + text[keyLength] = '\0'; + if (strcmp(text, "class") == 0) { + classKey = static_cast(keyIndex); + } else if (strcmp(text, "name") == 0) { + nameKey = static_cast(keyIndex); + } else if (strcmp(text, "name:latin") == 0) { + latinKey = static_cast(keyIndex); + } + } else if (!reader.skip(static_cast(keyLength))) { + return false; + } + keyIndex++; + } else if (layerField == 4 && layerWire == WIRE_LENGTH) { + uint64_t valueLength; + if (!readVarint(valueLength)) { + return false; + } + const size_t valueStart = reader.tell(); + size_t valueEnd; + if (!streamEnd(valueStart, valueLength, valueEnd) || valueEnd > layerEnd) { + return false; + } + if (isPlace || !nameSeen) { + if (!scanValue(valueEnd, valueIndex)) { + return false; + } + } else if (!reader.skip(valueEnd - valueStart)) { + return false; + } + valueIndex++; + } else if (layerField == 5 && layerWire == WIRE_VARINT) { + uint64_t value; + if (!readVarint(value)) { + return false; + } + extent = static_cast(value); + } else if (!skipField(layerWire)) { + return false; + } + if (reader.tell() > layerEnd) { + return false; + } + } + if (isPlace && nameSeen) { + layer.found = true; + layer.start = layerStart; + layer.length = static_cast(length); + layer.extent = extent > 0 ? extent : 4096; + layer.classKey = classKey; + layer.nameKey = nameKey; + layer.latinKey = latinKey; + layer.valueCount = valueIndex; + break; + } + if (reader.tell() < layerEnd && !reader.skip(layerEnd - reader.tell())) { + return false; + } + } else if (!skipField(wire)) { + return false; + } + } + return !reader.failed(); +} + +bool PlaceFinder::readGeometry(size_t geometryEnd, int32_t& px, int32_t& py, bool& hasPoint) { + int32_t x = 0; + int32_t y = 0; + while (reader.tell() < geometryEnd) { + uint64_t commandInteger; + if (!readVarint(commandInteger)) { + return false; + } + const uint32_t command = static_cast(commandInteger & 7); + const uint32_t repeat = static_cast(commandInteger >> 3); + if (command == COMMAND_MOVE_TO || command == COMMAND_LINE_TO) { + for (uint32_t i = 0; i < repeat; i++) { + uint64_t dx; + uint64_t dy; + if (!readVarint(dx) || !readVarint(dy)) { + return false; + } + x += zigzag(static_cast(dx)); + y += zigzag(static_cast(dy)); + if (command == COMMAND_MOVE_TO && !hasPoint) { + px = x; + py = y; + hasPoint = true; + } + } + } else if (command != COMMAND_CLOSE_PATH) { + return false; + } + if (reader.tell() > geometryEnd) { + return false; + } + } + return true; +} + +bool PlaceFinder::readFeature(size_t featureEnd, double fx, double fy) { + uint8_t type = 0; + int32_t px = 0; + int32_t py = 0; + bool hasPoint = false; + uint32_t nameIndex = 0; + bool hasName = false; + uint32_t latinIndex = 0; + bool hasLatin = false; + int rank = -1; + while (reader.tell() < featureEnd) { + uint64_t key; + if (!readVarint(key)) { + return false; + } + const uint32_t field = static_cast(key >> 3); + const uint32_t wire = static_cast(key & 7); + if (field == 2 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + const size_t tagsStart = reader.tell(); + size_t tagsEnd; + if (!streamEnd(tagsStart, length, tagsEnd) || tagsEnd > featureEnd) { + return false; + } + while (reader.tell() < tagsEnd) { + uint64_t tagKey; + uint64_t tagValue; + if (!readVarint(tagKey) || !readVarint(tagValue)) { + return false; + } + const uint32_t tagKeyIndex = static_cast(tagKey); + const uint32_t tagValueIndex = static_cast(tagValue); + if (layer.classKey >= 0 && tagKeyIndex == static_cast(layer.classKey)) { + const uint8_t value = valueRank(tagValueIndex); + if (value > 0) { + rank = static_cast(value) - 1; + } + } else if (layer.nameKey >= 0 && tagKeyIndex == static_cast(layer.nameKey)) { + nameIndex = tagValueIndex; + hasName = true; + } else if (layer.latinKey >= 0 && tagKeyIndex == static_cast(layer.latinKey)) { + latinIndex = tagValueIndex; + hasLatin = true; + } + } + } else if (field == 3 && wire == WIRE_VARINT) { + uint64_t value; + if (!readVarint(value)) { + return false; + } + type = static_cast(value); + } else if (field == 4 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + size_t geometryEnd; + if (!streamEnd(reader.tell(), length, geometryEnd) || geometryEnd > featureEnd) { + return false; + } + if (!readGeometry(geometryEnd, px, py, hasPoint)) { + return false; + } + } else if (!skipField(wire)) { + return false; + } + if (reader.tell() > featureEnd) { + return false; + } + } + if (hasLatin && latinIndex < layer.valueCount && latinIndex < VALUE_TABLE_SIZE && valueLengths[latinIndex] > 0) { + nameIndex = latinIndex; + hasName = true; + } + if (type != 1 || !hasPoint || !hasName || rank < 0) { + return true; + } + if (nameIndex >= layer.valueCount || nameIndex >= VALUE_TABLE_SIZE || valueLengths[nameIndex] == 0) { + return true; + } + const int32_t extent = layer.extent > 0 ? layer.extent : 4096; + const double dx = static_cast(px) / extent - fx; + const double dy = static_cast(py) / extent - fy; + const double distance = sqrt(dx * dx + dy * dy); + const double score = distance * (1 + rank); + if (bestRank < 0 || score < bestScore) { + bestScore = score; + bestDistance = distance * extent; + bestRank = rank; + bestName = nameIndex; + } + return true; +} + +bool PlaceFinder::findFeatures(ByteInput* tile, double fx, double fy) { + bestScore = 0.0; + bestDistance = 0.0; + bestRank = -1; + bestName = 0; + if (!reader.begin(tile)) { + return false; + } + if (!reader.skip(layer.start)) { + return false; + } + const size_t layerEnd = layer.start + layer.length; + while (reader.tell() < layerEnd) { + uint64_t key; + if (!readVarint(key)) { + return false; + } + const uint32_t field = static_cast(key >> 3); + const uint32_t wire = static_cast(key & 7); + if (field == 2 && wire == WIRE_LENGTH) { + uint64_t length; + if (!readVarint(length)) { + return false; + } + const size_t featureStart = reader.tell(); + size_t featureEnd; + if (!streamEnd(featureStart, length, featureEnd) || featureEnd > layerEnd) { + return false; + } + if (!readFeature(featureEnd, fx, fy)) { + return false; + } + if (reader.tell() < featureEnd && !reader.skip(featureEnd - reader.tell())) { + return false; + } + } else if (!skipField(wire)) { + return false; + } + if (reader.tell() > layerEnd) { + return false; + } + } + return !reader.failed(); +} + +bool PlaceFinder::readName(ByteInput* tile, uint32_t index, PlaceName& out) { + if (index >= layer.valueCount || index >= VALUE_TABLE_SIZE) { + return false; + } + const uint16_t length = valueLengths[index]; + if (length == 0) { + return false; + } + if (!reader.begin(tile)) { + return false; + } + if (!reader.skip(valueOffsets[index])) { + return false; + } + const size_t count = length < 47 ? length : 47; + if (reader.read(reinterpret_cast(out.name), count) != count) { + return false; + } + size_t cut = count; + if (length > count) { + while (cut > 0 && (static_cast(out.name[cut - 1]) & 0xC0) == 0x80) { + cut--; + } + if (cut > 0 && (static_cast(out.name[cut - 1]) & 0xC0) == 0xC0) { + cut--; + } + } + out.name[cut] = '\0'; + out.rank = bestRank; + out.distance = bestDistance; + return true; +} + +bool PlaceFinder::nearest(ByteInput* tile, double fx, double fy, PlaceName& out) { + if (!init()) { + return false; + } + if (!scanTile(tile)) { + return false; + } + if (!layer.found) { + return false; + } + if (!findFeatures(tile, fx, fy)) { + return false; + } + if (bestRank < 0) { + return false; + } + return readName(tile, bestName, out); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/PlaceFinder.h b/Apps/Maps/main/Source/mvt/PlaceFinder.h new file mode 100644 index 0000000..a6d4e96 --- /dev/null +++ b/Apps/Maps/main/Source/mvt/PlaceFinder.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ByteInput.h" +#include "InflateReader.h" + +#include +#include + +namespace maps { + +/** A settlement found in a tile: its name, class rank (0 city, 1 town, 2 village) and distance in tile units. */ +struct PlaceName { + char name[48]; + int rank; + double distance; +}; + +/** Finds the settlement nearest to a point of a vector tile, reading the tile as a stream. */ +class PlaceFinder { +public: + PlaceFinder() = default; + ~PlaceFinder(); + PlaceFinder(const PlaceFinder&) = delete; + PlaceFinder& operator=(const PlaceFinder&) = delete; + /** Allocates the decompressor and tables once (malloc is fine here, about 40 KB together). */ + bool init(); + /** + * Looks through the tile's place layer for city, town and village points. (x, y) is the point in tile units of + * that layer's extent (0..extent, usually 4096; scale it yourself from the fraction the caller gives: see fx, fy). + * fx and fy are the point as a fraction of the tile, 0..1 from the top left corner. + * The best place is the one with the lowest score = distance in tile fractions * (1 + rank), so a city a little + * farther away wins over a village next to the point. Returns false when the tile has no such place or is damaged. + */ + bool nearest(ByteInput* tile, double fx, double fy, PlaceName& out); + +private: + static constexpr int VALUE_TABLE_SIZE = 2048; + + struct LayerInfo { + bool found; + size_t start; + size_t length; + int32_t extent; + int16_t classKey; + int16_t nameKey; + /** "name:latin", preferred: region titles are shown with the system font, which has only Latin letters. */ + int16_t latinKey; + uint32_t valueCount; + }; + + bool readVarint(uint64_t& value); + bool skipField(uint32_t wireType); + bool scanTile(ByteInput* tile); + bool scanValue(size_t valueEnd, uint32_t index); + bool findFeatures(ByteInput* tile, double fx, double fy); + bool readFeature(size_t featureEnd, double fx, double fy); + bool readGeometry(size_t geometryEnd, int32_t& px, int32_t& py, bool& hasPoint); + bool readName(ByteInput* tile, uint32_t index, PlaceName& out); + uint8_t valueRank(uint32_t index) const; + + InflateReader reader; + LayerInfo layer {}; + uint32_t* valueOffsets = nullptr; + uint16_t* valueLengths = nullptr; + uint8_t* valueRanks = nullptr; + double bestScore = 0.0; + double bestDistance = 0.0; + int bestRank = -1; + uint32_t bestName = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/Schema.cpp b/Apps/Maps/main/Source/mvt/Schema.cpp new file mode 100644 index 0000000..b3b5737 --- /dev/null +++ b/Apps/Maps/main/Source/mvt/Schema.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Schema.h" + +#include + +namespace maps { + +namespace { + +const char* const VALUE_NAMES[] = { + "", +#define MAPS_VALUE_NAME(name) #name, + MAPS_SCHEMA_VALUES(MAPS_VALUE_NAME) +#undef MAPS_VALUE_NAME +}; + +struct LayerName { + const char* name; + Layer layer; +}; + +constexpr LayerName LAYER_NAMES[] = { + { "aeroway", Layer::Aeroway }, + { "boundary", Layer::Boundary }, + { "building", Layer::Building }, + { "landcover", Layer::Landcover }, + { "landuse", Layer::Landuse }, + { "park", Layer::Park }, + { "transportation", Layer::Transportation }, + { "water", Layer::Water }, + { "waterway", Layer::Waterway }, + { "place", Layer::Place }, + { "transportation_name", Layer::TransportationName }, + { "water_name", Layer::WaterName }, + { "mountain_peak", Layer::MountainPeak }, + { "poi", Layer::Poi }, + { "housenumber", Layer::Housenumber }, +}; + +bool equals(const char* text, size_t length, const char* name) { + return strlen(name) == length && memcmp(text, name, length) == 0; +} + +} // namespace + +Layer layerFromName(const char* name, size_t length) { + for (const auto& entry : LAYER_NAMES) { + if (equals(name, length, entry.name)) { + return entry.layer; + } + } + return Layer::Unknown; +} + +Value valueFromString(const char* text, size_t length) { + for (uint16_t i = 1; i < static_cast(Value::Count); i++) { + if (equals(text, length, VALUE_NAMES[i])) { + return static_cast(i); + } + } + return Value::None; +} + +const char* valueName(Value value) { + const auto index = static_cast(value); + return index < static_cast(Value::Count) ? VALUE_NAMES[index] : ""; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/mvt/Schema.h b/Apps/Maps/main/Source/mvt/Schema.h new file mode 100644 index 0000000..87c27ed --- /dev/null +++ b/Apps/Maps/main/Source/mvt/Schema.h @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace maps { + +/** The OpenMapTiles layers that are drawn. Every other layer is skipped. */ +enum class Layer : uint8_t { + Unknown, + Aeroway, + Boundary, + Building, + Landcover, + Landuse, + Park, + Transportation, + Water, + Waterway, + Place, + TransportationName, + WaterName, + MountainPeak, + /** Not drawn. Read only when a caller asks the decoder to scan them (MvtDecoder::scan), for the label records. */ + Poi, + Housenumber, + Count +}; + +#define MAPS_SCHEMA_VALUES(X) \ + X(ocean) X(lake) X(river) X(pond) X(dock) X(swimming_pool) X(stream) X(canal) X(drain) X(ditch) \ + X(farmland) X(ice) X(wood) X(rock) X(grass) X(wetland) X(sand) \ + X(residential) X(suburb) X(quarter) X(neighbourhood) X(commercial) X(industrial) X(retail) X(railway) \ + X(cemetery) X(military) X(hospital) X(school) X(university) X(college) X(kindergarten) X(stadium) \ + X(pitch) X(playground) X(track) X(theme_park) X(zoo) X(garages) X(dam) X(park) X(garden) \ + X(national_park) X(nature_reserve) X(protected_area) \ + X(motorway) X(trunk) X(primary) X(secondary) X(tertiary) X(minor) X(service) X(path) X(raceway) \ + X(busway) X(bus_guideway) X(ferry) X(rail) X(transit) X(cable_car) X(gondola) X(pier) X(bridge) \ + X(tunnel) X(ford) X(aerodrome) X(heliport) X(runway) X(helipad) X(taxiway) X(apron) X(gate) \ + X(city) X(town) X(village) X(hamlet) X(isolated_dwelling) X(continent) X(country) X(state) \ + X(province) X(island) X(peak) X(volcano) X(saddle) X(sea) X(bay) X(strait) X(lagoon) + +/** String values the style needs. Value::None means a string that is not in this list. */ +enum class Value : uint16_t { + None, +#define MAPS_VALUE_ENUM(name) name, + MAPS_SCHEMA_VALUES(MAPS_VALUE_ENUM) +#undef MAPS_VALUE_ENUM + Count +}; + +/** Feature attributes the style needs, resolved from the tags of one feature. */ +struct FeatureInfo { + Layer layer = Layer::Unknown; + uint8_t geometryType = 0; + Value featureClass = Value::None; + Value subclass = Value::None; + Value brunnel = Value::None; + int8_t adminLevel = -1; + bool maritime = false; + bool ramp = false; + /** "oneway" of the transportation layer: 1, -1, or 0 when absent. */ + int8_t oneway = 0; + /** "layer" of the transportation layer (bridges, tunnels), 0 when absent. */ + int8_t layerNumber = 0; + /** UTF-8 name of the feature from "name", not null-terminated, nullptr when absent. Valid during beginFeature(). */ + const char* name = nullptr; + uint16_t nameLength = 0; + /** The same from "name:latin" (OpenMapTiles' Latin-script name), nullptr when absent. */ + const char* nameLatin = nullptr; + uint16_t nameLatinLength = 0; + /** "rank" as an integer, -1 when absent (place, mountain_peak and poi layers). */ + int32_t rank = -1; + /** The raw "class" and "subclass" strings of any layer, nullptr when absent. Valid during beginFeature(). */ + const char* classText = nullptr; + uint16_t classTextLength = 0; + const char* subclassText = nullptr; + uint16_t subclassTextLength = 0; + /** The "housenumber" tag (housenumber layer), nullptr when absent. */ + const char* houseNumber = nullptr; + uint16_t houseNumberLength = 0; +}; + +enum GeometryType : uint8_t { + GEOMETRY_UNKNOWN = 0, + GEOMETRY_POINT = 1, + GEOMETRY_LINE = 2, + GEOMETRY_POLYGON = 3 +}; + +Layer layerFromName(const char* name, size_t length); +Value valueFromString(const char* text, size_t length); +const char* valueName(Value value); + +} // namespace maps diff --git a/Apps/Maps/main/Source/platform/Platform.h b/Apps/Maps/main/Source/platform/Platform.h new file mode 100644 index 0000000..c132cd4 --- /dev/null +++ b/Apps/Maps/main/Source/platform/Platform.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace maps { + +/** Allocates a buffer, preferring external RAM where the device has it. */ +void* largeAlloc(size_t size); +void largeFree(void* pointer); +/** Allocates a small buffer that is used very often, preferring internal RAM. Free it with largeFree(). */ +void* fastAlloc(size_t size); + +uint32_t nowMs(); +uint64_t nowUs(); + +void logInfo(const char* format, ...); +void logError(const char* format, ...); + +bool fileExists(const char* path); +/** The size of a regular file. Returns false when there is none. */ +bool fileSize(const char* path, uint32_t& size); + +bool removeFile(const char* path); +/** Removes an empty folder. */ +bool removeFolder(const char* path); + +/** Calls back for each entry of a folder until the callback returns false. */ +using DirectoryCallback = bool (*)(const char* name, bool isDirectory, void* context); +bool listDirectory(const char* path, DirectoryCallback callback, void* context); + +/** Logs free internal RAM and external RAM with a label. */ +void logMemory(const char* label); + +} // namespace maps diff --git a/Apps/Maps/main/Source/platform/Position.h b/Apps/Maps/main/Source/platform/Position.h new file mode 100644 index 0000000..52ab145 --- /dev/null +++ b/Apps/Maps/main/Source/platform/Position.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace maps { + +/** One position fix. Values the provider doesn't know are NAN (floats) or 0. */ +struct PositionFix { + double latitude; + double longitude; + /** Radius of 68 % confidence in metres. */ + float accuracy; + /** Direction of travel in degrees clockwise from north. */ + float course; + /** Metres per second. */ + float speed; + /** Milliseconds since the Unix epoch. */ + int64_t timeMs; +}; + +enum class PositionRead : uint8_t { + Fix, + /** Location services are on, but no fix was reported yet. */ + NoFix, + /** Location services are off in the system settings. */ + Off +}; + +/** + * Where the position comes from. On the device this is Tactility's location module and nothing else, + * on the PC a fake that tests feed with fixes. The app only reads, it never changes location settings. + */ +class PositionSource { +public: + virtual ~PositionSource() = default; + virtual PositionRead read(PositionFix& fix) = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/CacheIndex.cpp b/Apps/Maps/main/Source/render/CacheIndex.cpp new file mode 100644 index 0000000..47af6e7 --- /dev/null +++ b/Apps/Maps/main/Source/render/CacheIndex.cpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "CacheIndex.h" + +#include "../platform/Platform.h" +#include "PreparedTile.h" + +#include + +namespace maps { + +namespace { + +constexpr char MAGIC[4] = { 'T', 'M', 'C', 'I' }; +constexpr uint32_t FORMAT_VERSION = 1; +constexpr size_t ENTRY_BYTES = 17; + +uint32_t readU32(const uint8_t* bytes) { + return static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); +} + +} // namespace + +CacheIndex::~CacheIndex() { + largeFree(entries); +} + +bool CacheIndex::init(int maxEntries, uint64_t capBytes) { + largeFree(entries); + entries = static_cast(largeAlloc(sizeof(Entry) * static_cast(maxEntries))); + capacity = entries != nullptr ? maxEntries : 0; + cap = capBytes; + clear(); + return entries != nullptr; +} + +void CacheIndex::clear() { + count = 0; + total = 0; + clock = 0; + evicting = false; + complete = false; + dirty = false; +} + +int CacheIndex::find(const TileKey& tile) const { + for (int i = 0; i < count; i++) { + if (entries[i].tile == tile) { + return i; + } + } + return -1; +} + +bool CacheIndex::contains(const TileKey& tile) const { + return find(tile) >= 0; +} + +void CacheIndex::put(const TileKey& tile, uint32_t size) { + const int index = find(tile); + if (index >= 0) { + total = total - entries[index].size + size; + entries[index].size = size; + entries[index].lastUse = ++clock; + } else if (count < capacity) { + entries[count++] = { tile, size, ++clock }; + total += size; + } else { + // The index is full: the tile is on the card but unknown, the next evictions make room + return; + } + dirty = true; +} + +void CacheIndex::touch(const TileKey& tile) { + const int index = find(tile); + if (index >= 0) { + entries[index].lastUse = ++clock; + dirty = true; + } +} + +void CacheIndex::erase(const TileKey& tile) { + const int index = find(tile); + if (index < 0) { + return; + } + total -= entries[index].size; + entries[index] = entries[--count]; + dirty = true; +} + +bool CacheIndex::nextEviction(TileKey& tile) { + const uint64_t lowWater = cap * LOW_WATER_PERCENT / 100; + const int lowEntries = capacity * LOW_WATER_PERCENT / 100; + if (isOverCap()) { + evicting = true; + } + if (evicting && total <= lowWater && count <= lowEntries) { + evicting = false; + } + if (!evicting || count == 0) { + return false; + } + int oldest = 0; + for (int i = 1; i < count; i++) { + if (entries[i].lastUse < entries[oldest].lastUse) { + oldest = i; + } + } + tile = entries[oldest].tile; + return true; +} + +void CacheIndex::save(ByteWriter& out) const { + out.clear(); + out.putBytes(MAGIC, 4); + out.putU32(FORMAT_VERSION); + out.putU32(static_cast(count)); + out.putU32(clock); + for (int i = 0; i < count; i++) { + const Entry& entry = entries[i]; + out.put(static_cast(entry.tile.z)); + out.putU32(static_cast(entry.tile.x)); + out.putU32(static_cast(entry.tile.y)); + out.putU32(entry.size); + out.putU32(entry.lastUse); + } +} + +bool CacheIndex::load(const uint8_t* data, size_t size) { + clear(); + if (data == nullptr || size < 16 || memcmp(data, MAGIC, 4) != 0 || readU32(data + 4) != FORMAT_VERSION) { + return false; + } + const uint32_t stored = readU32(data + 8); + if (size != 16 + static_cast(stored) * ENTRY_BYTES) { + return false; + } + clock = readU32(data + 12); + const uint8_t* cursor = data + 16; + for (uint32_t i = 0; i < stored && count < capacity; i++, cursor += ENTRY_BYTES) { + const TileKey tile { cursor[0], static_cast(readU32(cursor + 1)), static_cast(readU32(cursor + 5)) }; + const uint32_t fileSize = readU32(cursor + 9); + const uint32_t lastUse = readU32(cursor + 13); + if (!isValidTile(tile)) { + continue; + } + entries[count++] = { tile, fileSize, lastUse }; + total += fileSize; + clock = lastUse > clock ? lastUse : clock; + } + complete = true; + return true; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/CacheIndex.h b/Apps/Maps/main/Source/render/CacheIndex.h new file mode 100644 index 0000000..8ac4758 --- /dev/null +++ b/Apps/Maps/main/Source/render/CacheIndex.h @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../tiles/TileMath.h" + +#include +#include + +namespace maps { + +class ByteWriter; + +/** + * Sizes and last use of the rendered tiles in the SD card cache of one style, so the cache stays under a size + * cap by deleting the least recently used tiles first. Kept in cache//index.bin. + */ +class CacheIndex { +public: + static constexpr uint64_t DEFAULT_CAP_BYTES = 200ull * 1000 * 1000; + /** Eviction stops at this share of the cap, so it does not run after every write. */ + static constexpr int LOW_WATER_PERCENT = 90; + + CacheIndex() = default; + ~CacheIndex(); + CacheIndex(const CacheIndex&) = delete; + CacheIndex& operator=(const CacheIndex&) = delete; + + bool init(int maxEntries, uint64_t capBytes = DEFAULT_CAP_BYTES); + void clear(); + + /** Adds or updates a tile and marks it as just used. */ + void put(const TileKey& tile, uint32_t size); + void touch(const TileKey& tile); + void erase(const TileKey& tile); + bool contains(const TileKey& tile) const; + + uint64_t totalBytes() const { return total; } + int size() const { return count; } + bool isFull() const { return count >= capacity; } + bool isOverCap() const { return total > cap || isFull(); } + + /** + * The least recently used tile while the cache is above the low water mark, or while it is over the cap or + * the entry limit. Returns false when nothing needs to go. + */ + bool nextEviction(TileKey& tile); + + void save(ByteWriter& out) const; + bool load(const uint8_t* data, size_t size); + + /** Set once the index matches the files on the card. */ + bool isComplete() const { return complete; } + void setComplete(bool value) { complete = value; } + bool isDirty() const { return dirty; } + void setClean() { dirty = false; } + +private: + struct Entry { + TileKey tile; + uint32_t size; + uint32_t lastUse; + }; + + int find(const TileKey& tile) const; + + Entry* entries = nullptr; + int count = 0; + int capacity = 0; + uint64_t total = 0; + uint64_t cap = DEFAULT_CAP_BYTES; + uint32_t clock = 0; + bool evicting = false; + bool complete = false; + bool dirty = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/CacheWalker.cpp b/Apps/Maps/main/Source/render/CacheWalker.cpp new file mode 100644 index 0000000..e124666 --- /dev/null +++ b/Apps/Maps/main/Source/render/CacheWalker.cpp @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "CacheWalker.h" + +#include "../platform/Platform.h" + +#include +#include +#include + +namespace maps { + +namespace { + +bool isNumber(const char* text) { + if (*text == '\0') { + return false; + } + for (; *text != '\0'; text++) { + if (*text < '0' || *text > '9') { + return false; + } + } + return true; +} + +struct TopList { + char (*names)[16]; + int count; + int capacity; +}; + +bool addTop(const char* name, bool isDirectory, void* context) { + auto* list = static_cast(context); + if (isDirectory && strlen(name) < 16 && list->count < list->capacity) { + // FAT may report short names in upper case + char* out = list->names[list->count++]; + for (size_t i = 0; i <= strlen(name); i++) { + out[i] = (name[i] >= 'A' && name[i] <= 'Z') ? static_cast(name[i] - 'A' + 'a') : name[i]; + } + } + return true; +} + +struct ColumnList { + int* columns; + int count; + int capacity; +}; + +bool addColumn(const char* name, bool isDirectory, void* context) { + auto* list = static_cast(context); + if (isDirectory && isNumber(name) && list->count < list->capacity) { + list->columns[list->count++] = atoi(name); + } + return true; +} + +} // namespace + +CacheWalker::~CacheWalker() { + largeFree(columns); +} + +void CacheWalker::begin(const char* cacheRoot, const char* styleId, bool keepCurrent) { + snprintf(root, sizeof(root), "%s", cacheRoot); + snprintf(style, sizeof(style), "%s", styleId); + keep = keepCurrent; + phase = Phase::ListTop; + topCount = 0; + topIndex = 0; + zoom = -1; + columnCount = -1; + column = 0; +} + +bool CacheWalker::removeInCache(const char* path, bool folder) { + // Everything outside the cache folder, maps/ above all, is never deleted here + if (!isInsideCache(path, root)) { + logError("Refusing to delete %s outside the cache", path); + return false; + } + return folder ? removeFolder(path) : removeFile(path); +} + +bool CacheWalker::listColumns(const char* folder) { + if (columns == nullptr) { + columns = static_cast(largeAlloc(MAX_COLUMNS * sizeof(int))); + if (columns == nullptr) { + return false; + } + } + ColumnList list { columns, 0, MAX_COLUMNS }; + listDirectory(folder, addColumn, &list); + columnCount = list.count; + column = 0; + return true; +} + +void CacheWalker::indexColumn(CacheIndex& index, const IndexLock* lock, int z, int x) { + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/cache/%s/%d/%d", root, style, z, x); + struct Visit { + CacheIndex* index; + const IndexLock* lock; + const char* folder; + int z; + int x; + } visit { &index, lock, folder, z, x }; + listDirectory(folder, [](const char* name, bool isDirectory, void* context) { + auto* v = static_cast(context); + if (isDirectory || !isNumber(name)) { + return true; + } + char path[PATH_MAX_LENGTH]; + uint32_t size = 0; + snprintf(path, sizeof(path), "%s/%s", v->folder, name); + const TileKey tile { v->z, v->x, atoi(name) }; + if (!fileSize(path, size)) { + return true; + } + if (v->lock != nullptr) { + v->lock->lock(v->lock->context); + } + // Files already in the index keep their last use + if (!v->index->contains(tile)) { + v->index->put(tile, size); + } + if (v->lock != nullptr) { + v->lock->unlock(v->lock->context); + } + return true; + }, &visit); +} + +bool CacheWalker::deleteColumn(const char* zoomFolder, int x) { + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/%d", zoomFolder, x); + struct Visit { + CacheWalker* walker; + const char* folder; + int removed; + } visit { this, folder, 0 }; + listDirectory(folder, [](const char* name, bool isDirectory, void* context) { + auto* v = static_cast(context); + if (!isDirectory) { + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/%s", v->folder, name); + v->walker->removeInCache(path, false); + v->removed++; + } + // A few files per step, so the card is free again soon for the tiles on screen + return v->removed < MAX_DELETES_PER_STEP; + }, &visit); + if (visit.removed >= MAX_DELETES_PER_STEP) { + return false; + } + removeInCache(folder, true); + return true; +} + +bool CacheWalker::step(CacheIndex& index, const IndexLock* lock) { + while (phase != Phase::Done) { + if (phase == Phase::ListTop) { + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/cache", root); + TopList list { top, 0, MAX_TOP }; + listDirectory(folder, addTop, &list); + topCount = list.count; + topIndex = 0; + zoom = -1; + columnCount = -1; + phase = Phase::Top; + return true; + } + if (topIndex >= topCount) { + // The index of the current style matches the card now, also when it has no folder yet + index.setComplete(true); + phase = Phase::Done; + break; + } + const char* name = top[topIndex]; + const bool current = keep && strcmp(name, style) == 0; + const bool legacy = isNumber(name); + if (current && index.isComplete()) { + topIndex++; + continue; + } + // The old layout has zoom folders right under cache/, a style folder has them one level down + if (zoom < 0) { + zoom = 0; + } + if (zoom > MAX_VIEW_ZOOM) { + char path[PATH_MAX_LENGTH]; + if (current) { + index.setComplete(true); + } else { + if (!legacy) { + snprintf(path, sizeof(path), "%s/cache/%s/index.bin", root, name); + removeInCache(path, false); + } + snprintf(path, sizeof(path), "%s/cache/%s", root, name); + removeInCache(path, true); + } + topIndex++; + zoom = -1; + columnCount = -1; + continue; + } + char zoomFolder[PATH_MAX_LENGTH]; + if (legacy) { + snprintf(zoomFolder, sizeof(zoomFolder), "%s/cache/%s", root, name); + } else { + snprintf(zoomFolder, sizeof(zoomFolder), "%s/cache/%s/%d", root, name, zoom); + } + if (columnCount < 0 && !listColumns(zoomFolder)) { + return false; + } + if (column < columnCount) { + if (current) { + indexColumn(index, lock, zoom, columns[column]); + } else { + if (!deleteColumn(zoomFolder, columns[column])) { + return true; + } + } + column++; + return true; + } + if (!current && !legacy) { + removeInCache(zoomFolder, true); + } + // The old layout has a single zoom folder: moving past it ends this entry + zoom = legacy ? MAX_VIEW_ZOOM + 1 : zoom + 1; + columnCount = -1; + } + return false; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/CacheWalker.h b/Apps/Maps/main/Source/render/CacheWalker.h new file mode 100644 index 0000000..f9f54d9 --- /dev/null +++ b/Apps/Maps/main/Source/render/CacheWalker.h @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../tiles/TilePaths.h" +#include "CacheIndex.h" + +#include + +namespace maps { + +/** + * Tidies the SD card cache a folder at a time in idle time: it fills the index of the current style from the + * files when the index was lost, and deletes the folders of other styles and of the old cache layout + * (cache///). It only ever deletes inside /cache. + */ +class CacheWalker { +public: + /** Guards the index while the walk adds to it, it is shared with the render lanes. */ + struct IndexLock { + void (*lock)(void* context); + void (*unlock)(void* context); + void* context; + }; + + ~CacheWalker(); + + /** keepCurrent false deletes the current style's cache too, when the SD card cache is switched off. */ + void begin(const char* root, const char* style, bool keepCurrent = true); + bool isComplete() const { return phase == Phase::Done; } + + /** Looks at one folder. Returns false once the walk is complete. lock may be null. */ + bool step(CacheIndex& index, const IndexLock* lock = nullptr); + +private: + enum class Phase : uint8_t { + ListTop, + Top, + Done + }; + + static constexpr int MAX_TOP = 32; + static constexpr int MAX_COLUMNS = 4096; + /** A FAT delete holds the card, and the display's shared bus, for up to about 150 ms. */ + static constexpr int MAX_DELETES_PER_STEP = 2; + + bool listColumns(const char* folder); + void indexColumn(CacheIndex& index, const IndexLock* lock, int z, int x); + /** Deletes up to MAX_DELETES_PER_STEP files of a column. Returns true once the column is gone. */ + bool deleteColumn(const char* zoomFolder, int x); + bool removeInCache(const char* path, bool folder); + + char root[PATH_MAX_LENGTH] = ""; + char style[16] = ""; + bool keep = true; + Phase phase = Phase::Done; + char top[MAX_TOP][16] {}; + int topCount = 0; + int topIndex = 0; + /** The zoom folder being worked on, -1 before the first. */ + int zoom = -1; + int* columns = nullptr; + int columnCount = -1; + int column = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/Compose.cpp b/Apps/Maps/main/Source/render/Compose.cpp new file mode 100644 index 0000000..b948f2f --- /dev/null +++ b/Apps/Maps/main/Source/render/Compose.cpp @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Compose.h" + +#include +#include +#include + +namespace maps { + +int composeView(uint16_t* view, int width, int height, double centerX, double centerY, int zoom, TileLookup lookup, void* context, uint16_t placeholder) { + const auto left = static_cast(std::floor(centerX - width / 2.0)); + const auto top = static_cast(std::floor(centerY - height / 2.0)); + const int64_t worldSize = static_cast(TILE_SIZE) << zoom; + const auto firstTileX = static_cast(std::floor(static_cast(left) / TILE_SIZE)); + const auto firstTileY = static_cast(std::floor(static_cast(top) / TILE_SIZE)); + const auto lastTileX = static_cast(std::floor(static_cast(left + width - 1) / TILE_SIZE)); + const auto lastTileY = static_cast(std::floor(static_cast(top + height - 1) / TILE_SIZE)); + int missing = 0; + for (int tileY = firstTileY; tileY <= lastTileY; tileY++) { + for (int tileX = firstTileX; tileX <= lastTileX; tileX++) { + const int64_t tileLeft = static_cast(tileX) * TILE_SIZE; + const int64_t tileTop = static_cast(tileY) * TILE_SIZE; + const int x0 = static_cast(std::max(tileLeft, left) - left); + const int x1 = static_cast(std::min(tileLeft + TILE_SIZE, left + width) - left); + const int y0 = static_cast(std::max(tileTop, top) - top); + const int y1 = static_cast(std::min(tileTop + TILE_SIZE, top + height) - top); + const uint16_t* pixels = nullptr; + if (tileTop >= 0 && tileTop < worldSize) { + pixels = lookup({ zoom, wrapTileX(tileX, zoom), tileY }, context); + } + if (pixels == nullptr) { + missing++; + for (int y = y0; y < y1; y++) { + uint16_t* row = view + y * width; + for (int x = x0; x < x1; x++) { + row[x] = placeholder; + } + } + continue; + } + const int sourceX = static_cast(left + x0 - tileLeft); + for (int y = y0; y < y1; y++) { + const int sourceY = static_cast(top + y - tileTop); + memcpy(view + y * width + x0, pixels + sourceY * TILE_SIZE + sourceX, static_cast(x1 - x0) * sizeof(uint16_t)); + } + } + } + return missing; +} + +void scaleView(const uint16_t* source, uint16_t* target, int width, int height, float scale, float fromX, float fromY, float toX, float toY, + uint16_t fill) { + if (scale <= 0.0f) { + return; + } + // Source column of each target column, worked out once per frame + int columns[1024]; + const int columnCount = width < 1024 ? width : 1024; + for (int x = 0; x < columnCount; x++) { + columns[x] = static_cast(std::floor(fromX + (static_cast(x) + 0.5f - toX) / scale)); + } + for (int y = 0; y < height; y++) { + const int sourceY = static_cast(std::floor(fromY + (static_cast(y) + 0.5f - toY) / scale)); + uint16_t* out = target + y * width; + if (sourceY < 0 || sourceY >= height) { + for (int x = 0; x < width; x++) { + out[x] = fill; + } + continue; + } + const uint16_t* row = source + sourceY * width; + for (int x = 0; x < columnCount; x++) { + const int sourceX = columns[x]; + out[x] = sourceX >= 0 && sourceX < width ? row[sourceX] : fill; + } + } +} + +AffineView scaledView(double worldX, double worldY, int zoom, float scale, float anchorX, float anchorY, int width, int height, + float cosine, float sine) { + const float inverse = 1.0f / scale; + // The screen centre lies (width / 2 - anchorX, height / 2 - anchorY) screen pixels from the anchor + const double offsetX = width / 2.0 - anchorX; + const double offsetY = height / 2.0 - anchorY; + const double centerX = worldX + (cosine * offsetX - sine * offsetY) * inverse; + const double centerY = worldY + (sine * offsetX + cosine * offsetY) * inverse; + return { centerX, centerY, zoom, cosine * inverse, -sine * inverse, sine * inverse, cosine * inverse }; +} + +namespace { + +constexpr int FALLBACK_LEVELS = 4; + +void resolveCell(AffineScratch::Cell& cell, int tileX, int tileY, int zoom, TileLookup lookup, void* context) { + cell = {}; + const int tileCount = 1 << zoom; + if (tileY < 0 || tileY >= tileCount) { + return; + } + const int x = wrapTileX(tileX, zoom); + if ((cell.pixels = lookup({ zoom, x, tileY }, context)) != nullptr) { + return; + } + if (zoom < MAX_VIEW_ZOOM) { + for (int i = 0; i < 4; i++) { + cell.children[i] = lookup({ zoom + 1, x * 2 + (i & 1), tileY * 2 + (i >> 1) }, context); + cell.hasChildren = cell.hasChildren || cell.children[i] != nullptr; + } + if (cell.hasChildren) { + return; + } + } + for (int level = 1; level <= FALLBACK_LEVELS && zoom - level >= 0; level++) { + const TileKey parent = ancestorOf({ zoom, x, tileY }, zoom - level); + if ((cell.pixels = lookup(parent, context)) != nullptr) { + const int size = TILE_SIZE >> level; + cell.shift = static_cast(level); + cell.originX = static_cast((x - (parent.x << level)) * size); + cell.originY = static_cast((tileY - (parent.y << level)) * size); + return; + } + } +} + +inline uint16_t sampleCell(const AffineScratch::Cell& cell, int localX, int localY, uint16_t placeholder) { + if (cell.pixels != nullptr) { + return cell.pixels[(cell.originY + (localY >> cell.shift)) * TILE_SIZE + cell.originX + (localX >> cell.shift)]; + } + if (cell.hasChildren) { + const uint16_t* child = cell.children[(localY >> 7) * 2 + (localX >> 7)]; + return child != nullptr ? child[((localY & 127) << 1) * TILE_SIZE + ((localX & 127) << 1)] : placeholder; + } + return placeholder; +} + +} // namespace + +uint16_t lerpRgb565(uint16_t from, uint16_t to, uint32_t weight) { + if (from == to || weight == 0) { + return from; + } + // Green moved up into the upper half, so all three channels have room for the 5-bit weight + const uint32_t a = (from | (static_cast(from) << 16)) & 0x07E0F81Fu; + const uint32_t b = (to | (static_cast(to) << 16)) & 0x07E0F81Fu; + const uint32_t mixed = ((a * (32 - weight) + b * weight) >> 5) & 0x07E0F81Fu; + return static_cast(mixed | (mixed >> 16)); +} + +int composeAffine(uint16_t* view, int width, int height, const AffineView& transform, TileLookup lookup, void* context, uint16_t placeholder, + AffineScratch& scratch, bool bilinear, int rowBegin, int rowEnd) { + // The tiles under the four screen corners bound the ones the view needs + const float halfWidth = width / 2.0f; + const float halfHeight = height / 2.0f; + double minX = 1e300; + double minY = 1e300; + double maxX = -1e300; + double maxY = -1e300; + for (int corner = 0; corner < 4; corner++) { + const float sx = (corner & 1) != 0 ? halfWidth : -halfWidth; + const float sy = (corner & 2) != 0 ? halfHeight : -halfHeight; + const double wx = transform.centerX + transform.a * sx + transform.b * sy; + const double wy = transform.centerY + transform.c * sx + transform.d * sy; + minX = std::min(minX, wx); + minY = std::min(minY, wy); + maxX = std::max(maxX, wx); + maxY = std::max(maxY, wy); + } + constexpr int maxGrid = AffineScratch::MAX_GRID; + const int gridX0 = static_cast(std::floor(minX / TILE_SIZE)); + const int gridY0 = static_cast(std::floor(minY / TILE_SIZE)); + const int gridWidth = std::min(maxGrid, static_cast(std::floor(maxX / TILE_SIZE)) - gridX0 + 1); + const int gridHeight = std::min(maxGrid, static_cast(std::floor(maxY / TILE_SIZE)) - gridY0 + 1); + AffineScratch::Cell* grid = scratch.cells; + int missing = 0; + for (int y = 0; y < gridHeight; y++) { + for (int x = 0; x < gridWidth; x++) { + AffineScratch::Cell& cell = grid[y * gridWidth + x]; + resolveCell(cell, gridX0 + x, gridY0 + y, transform.zoom, lookup, context); + if (cell.pixels == nullptr && !cell.hasChildren) { + missing++; + } + } + } + + // 16.16 fixed point from the grid's top left corner: 12 tiles are 3072 pixels, well inside 32 bits + const double gridLeft = static_cast(gridX0) * TILE_SIZE; + const double gridTop = static_cast(gridY0) * TILE_SIZE; + const int32_t limitX = gridWidth * TILE_SIZE; + const int32_t limitY = gridHeight * TILE_SIZE; + // lround is not exported to apps + const auto stepXx = static_cast(std::floor(transform.a * 65536.0 + 0.5)); + const auto stepXy = static_cast(std::floor(transform.c * 65536.0 + 0.5)); + + if (!bilinear && rowBegin == 0 && rowEnd < 0 && transform.b == 0.0f && transform.c == 0.0f && width <= AffineScratch::MAX_WIDTH) { + // Scale only, the zoom animations and pinch: the source column of each screen column is the same on every + // row, worked out once, and each row is copied in runs that stay in one tile + int16_t* columns = scratch.columns; + const double firstX = transform.centerX + transform.a * (0.5f - halfWidth) - gridLeft; + auto fx = static_cast(std::floor(firstX * 65536.0)); + for (int x = 0; x < width; x++, fx += stepXx) { + const int32_t px = fx >> 16; + columns[x] = static_cast(px >= 0 && px < limitX ? px : -1); + } + for (int y = 0; y < height; y++) { + const double rowY = transform.centerY + transform.d * (static_cast(y) + 0.5f - halfHeight) - gridTop; + const auto py = static_cast(std::floor(rowY)); + uint16_t* out = view + y * width; + if (py < 0 || py >= limitY) { + for (int x = 0; x < width; x++) { + out[x] = placeholder; + } + continue; + } + const AffineScratch::Cell* cells = grid + (py >> 8) * gridWidth; + const int localY = py & (TILE_SIZE - 1); + int x = 0; + while (x < width) { + const int column = columns[x]; + if (column < 0) { + out[x++] = placeholder; + continue; + } + const int cellX = column >> 8; + const AffineScratch::Cell& cell = cells[cellX]; + if (cell.pixels != nullptr) { + const uint16_t* source = cell.pixels + (cell.originY + (localY >> cell.shift)) * TILE_SIZE + cell.originX; + const int shift = cell.shift; + // The run ends where the next tile begins + for (; x < width && columns[x] >= 0 && (columns[x] >> 8) == cellX; x++) { + out[x] = source[(columns[x] & (TILE_SIZE - 1)) >> shift]; + } + } else { + for (; x < width && columns[x] >= 0 && (columns[x] >> 8) == cellX; x++) { + out[x] = sampleCell(cell, columns[x] & (TILE_SIZE - 1), localY, placeholder); + } + } + } + } + return missing; + } + + const int lastRow = rowEnd < 0 || rowEnd > height ? height : rowEnd; + if (bilinear) { + // Each screen pixel blends the four source pixels around its centre; the sample point is shifted by half a + // pixel so a pixel centre maps onto a source pixel centre + auto sample = [&](int32_t px, int32_t py) -> uint16_t { + if (px < 0 || py < 0 || px >= limitX || py >= limitY) { + return placeholder; + } + return sampleCell(grid[(py >> 8) * gridWidth + (px >> 8)], px & (TILE_SIZE - 1), py & (TILE_SIZE - 1), placeholder); + }; + for (int y = rowBegin; y < lastRow; y++) { + const float sy = static_cast(y) + 0.5f - halfHeight; + const float sx = 0.5f - halfWidth; + const double rowX = transform.centerX + transform.a * sx + transform.b * sy - gridLeft - 0.5; + const double rowY = transform.centerY + transform.c * sx + transform.d * sy - gridTop - 0.5; + auto fx = static_cast(std::floor(rowX * 65536.0)); + auto fy = static_cast(std::floor(rowY * 65536.0)); + uint16_t* out = view + y * width; + for (int x = 0; x < width; x++, fx += stepXx, fy += stepXy) { + const int32_t px = fx >> 16; + const int32_t py = fy >> 16; + const auto weightX = static_cast((fx >> 11) & 31); + const auto weightY = static_cast((fy >> 11) & 31); + const uint16_t p00 = sample(px, py); + const uint16_t p10 = sample(px + 1, py); + const uint16_t p01 = sample(px, py + 1); + const uint16_t p11 = sample(px + 1, py + 1); + out[x] = lerpRgb565(lerpRgb565(p00, p10, weightX), lerpRgb565(p01, p11, weightX), weightY); + } + } + return missing; + } + + for (int y = rowBegin; y < lastRow; y++) { + const float sy = static_cast(y) + 0.5f - halfHeight; + const float sx = 0.5f - halfWidth; + const double rowX = transform.centerX + transform.a * sx + transform.b * sy - gridLeft; + const double rowY = transform.centerY + transform.c * sx + transform.d * sy - gridTop; + auto fx = static_cast(std::floor(rowX * 65536.0)); + auto fy = static_cast(std::floor(rowY * 65536.0)); + uint16_t* out = view + y * width; + for (int x = 0; x < width; x++, fx += stepXx, fy += stepXy) { + const int32_t px = fx >> 16; + const int32_t py = fy >> 16; + if (px < 0 || py < 0 || px >= limitX || py >= limitY) { + out[x] = placeholder; + continue; + } + out[x] = sampleCell(grid[(py >> 8) * gridWidth + (px >> 8)], px & (TILE_SIZE - 1), py & (TILE_SIZE - 1), placeholder); + } + } + return missing; +} + +int snapZoom(int zoom, float scale, int maxZoom) { + if (scale <= 0.0f) { + return zoom; + } + // The nearest power of two, without log2f, which Tactility does not export to apps + int steps = 0; + while (scale >= 1.41421356f) { + scale *= 0.5f; + steps++; + } + while (scale < 0.70710678f) { + scale *= 2.0f; + steps--; + } + const int snapped = zoom + steps; + return snapped < 0 ? 0 : (snapped > maxZoom ? maxZoom : snapped); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/Compose.h b/Apps/Maps/main/Source/render/Compose.h new file mode 100644 index 0000000..780adff --- /dev/null +++ b/Apps/Maps/main/Source/render/Compose.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../tiles/TileMath.h" + +#include + +namespace maps { + +/** Returns the rendered 256x256 pixels of a tile, or nullptr when it is not ready. */ +using TileLookup = const uint16_t* (*)(const TileKey& tile, void* context); + +/** + * Copies the tiles that cover a view into it. centerX and centerY are world pixel coordinates at zoom. + * Returns the number of tiles that were missing and drawn as placeholder. + */ +int composeView(uint16_t* view, int width, int height, double centerX, double centerY, int zoom, TileLookup lookup, void* context, uint16_t placeholder); + +/** + * Draws source scaled by scale into target, both width x height, so the source point fromX, fromY lands on + * toX, toY. Nearest pixel, for live pinch zoom from the pixels on screen. Uncovered parts get fill. + */ +void scaleView(const uint16_t* source, uint16_t* target, int width, int height, float scale, float fromX, float fromY, float toX, float toY, + uint16_t fill); + +/** The whole zoom level nearest to zoom scaled by scale, within 0 and maxZoom. */ +int snapZoom(int zoom, float scale, int maxZoom); + +/** + * Where the screen samples the world: the world pixel at zoom of the screen point (sx, sy), measured from the + * screen centre, is (centerX + a * sx + b * sy, centerY + c * sx + d * sy). A plain view is a = d = 1, b = c = 0; + * a = d = 1 / scale magnifies by scale; a rotation fills in b and c. + */ +struct AffineView { + double centerX; + double centerY; + int zoom; + float a; + float b; + float c; + float d; +}; + +/** + * The view of a map magnified by scale around the screen point (anchorX, anchorY), which shows the world point at zoom + * (worldX, worldY), turned by the angle whose cosine and sine are given (a screen offset turns by it into a world offset). + */ +AffineView scaledView(double worldX, double worldY, int zoom, float scale, float anchorX, float anchorY, int width, int height, + float cosine = 1.0f, float sine = 0.0f); + +/** Working memory of composeAffine, about 6 KB: too much for a task stack, so the caller keeps one (in PSRAM). */ +struct AffineScratch { + static constexpr int MAX_GRID = 12; + static constexpr int MAX_WIDTH = 1024; + + /** How the pixels of one tile of the view are found: in the tile itself, in an ancestor, or in its four children. */ + struct Cell { + const uint16_t* pixels; + /** Children, top left, top right, bottom left, bottom right, when pixels is null. */ + const uint16_t* children[4]; + /** Where the tile lies in the ancestor's pixels, and how many levels up (0 for the tile itself). */ + int16_t originX; + int16_t originY; + uint8_t shift; + bool hasChildren; + }; + + Cell cells[MAX_GRID * MAX_GRID]; + int16_t columns[MAX_WIDTH]; +}; + +/** + * Draws the view through an affine transform from rendered tiles, nearest pixel: for zoom animations and pinch, and + * later rotation. A tile that is not ready is stood in for by a cached ancestor (magnified) or by its four children + * (shrunk). lookup must return only tiles that are really rendered. Returns the number of tiles drawn as placeholder. + * With bilinear, each pixel blends the four nearest source pixels: about four times the work, for a settled turned + * view. rowBegin and rowEnd (exclusive, -1 for the last row) limit the rows drawn, so slow frames can go in strips. + */ +int composeAffine(uint16_t* view, int width, int height, const AffineView& transform, TileLookup lookup, void* context, uint16_t placeholder, + AffineScratch& scratch, bool bilinear = false, int rowBegin = 0, int rowEnd = -1); + +/** RGB565 from from to to by weight / 32 (0..32). */ +uint16_t lerpRgb565(uint16_t from, uint16_t to, uint32_t weight); + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/LabelLayout.cpp b/Apps/Maps/main/Source/render/LabelLayout.cpp new file mode 100644 index 0000000..e6025a4 --- /dev/null +++ b/Apps/Maps/main/Source/render/LabelLayout.cpp @@ -0,0 +1,990 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "LabelLayout.h" + +#include "Raster.h" + +#include +#include +#include + +namespace maps { + +namespace { + +/** Whether the app's label font (assets/fonts/LabelSans.ttf, see host/make_label_font.py) has letters for this codepoint's script. */ +bool labelFontCovers(uint32_t codepoint) { + return codepoint < 0x0530 || (codepoint >= 0x1E00 && codepoint <= 0x1EFF) || (codepoint >= 0x2000 && codepoint <= 0x20CF) || + codepoint == 0x2116; +} + +/** + * Whether every character of a UTF-8 name can be drawn with the label font: asked of the font itself, since the app's + * font has only the characters that map names use (tools/label_codepoints.txt); without a font, by script. + */ +bool labelFontCoversText(LabelFont* font, const char* text, uint16_t length) { + // Plain ASCII (every house number, most names) is always in the font: no glyph lookups + bool ascii = true; + for (uint16_t i = 0; i < length && ascii; i++) { + ascii = static_cast(text[i]) < 0x80; + } + if (ascii) { + return true; + } + for (uint16_t i = 0; i < length;) { + const auto first = static_cast(text[i]); + uint32_t codepoint; + int bytes; + if (first < 0x80) { + codepoint = first; + bytes = 1; + } else if ((first & 0xE0) == 0xC0) { + codepoint = first & 0x1F; + bytes = 2; + } else if ((first & 0xF0) == 0xE0) { + codepoint = first & 0x0F; + bytes = 3; + } else { + codepoint = first & 0x07; + bytes = 4; + } + if (i + bytes > length) { + return false; + } + for (int k = 1; k < bytes; k++) { + codepoint = (codepoint << 6) | (static_cast(text[i + k]) & 0x3F); + } + LabelGlyph glyph {}; + if (font != nullptr ? !font->glyph(codepoint, glyph, false) : !labelFontCovers(codepoint)) { + return false; + } + i = static_cast(i + bytes); + } + return true; +} + +constexpr int CELL = 8; +constexpr int MAX_LINE_POINTS = 6000; +/** A line stretch is straight enough when no segment turns more than this from the stretch's direction. */ +constexpr float MAX_BEND = 0.42f; +constexpr float PI = 3.14159265f; +/** Space kept free around a label, so neighbours do not touch. */ +constexpr float GAP = 2.0f; +constexpr int SIZE_SMALL = 0; +constexpr int SIZE_NORMAL = 1; +constexpr int SIZE_LARGE = 2; + +/** Priorities, the most important first. */ +enum Priority : uint8_t { + PRIORITY_REGION, + PRIORITY_CITY, + PRIORITY_TOWN, + PRIORITY_VILLAGE, + PRIORITY_SUBURB, + PRIORITY_MAJOR_ROAD, + PRIORITY_MINOR_ROAD, + PRIORITY_HAMLET, + PRIORITY_WATER, + PRIORITY_PEAK, + PRIORITY_POI, + PRIORITY_HOUSE_NUMBER +}; + +uint32_t nextCodepoint(const char*& text, const char* end) { + const auto first = static_cast(*text++); + if (first < 0x80) { + return first; + } + if ((first & 0xE0) == 0xC0 && text < end) { + return ((first & 0x1Fu) << 6) | (static_cast(*text++) & 0x3Fu); + } + if ((first & 0xF0) == 0xE0 && text + 1 < end) { + const uint32_t second = static_cast(*text++) & 0x3Fu; + const uint32_t third = static_cast(*text++) & 0x3Fu; + return ((first & 0x0Fu) << 12) | (second << 6) | third; + } + while (text < end && (static_cast(*text) & 0xC0) == 0x80) { + text++; + } + return '?'; +} + +/** The first zoom level a road name of a class shows at. */ +int roadZoom(Value value) { + switch (value) { + case Value::motorway: + case Value::trunk: + case Value::primary: + return 12; + case Value::secondary: + case Value::tertiary: + return 14; + default: + return 15; + } +} + +} // namespace + +LabelLayout::~LabelLayout() { + end(); +} + +bool LabelLayout::begin(void* (*allocate)(size_t), void (*release)(void*)) { + allocateMemory = allocate; + releaseMemory = release; + arena = static_cast(allocate(ARENA_BYTES)); + texts = static_cast(allocate(TEXT_BYTES)); + candidates = static_cast(allocate(sizeof(Candidate) * MAX_CANDIDATES)); + linePoints = static_cast(allocate(sizeof(float) * 2 * MAX_LINE_POINTS)); + widths = static_cast(allocate(sizeof(WidthEntry) * WIDTH_ENTRIES)); + if (widths != nullptr) { + memset(widths, 0, sizeof(WidthEntry) * WIDTH_ENTRIES); + } + if (arena == nullptr || texts == nullptr || candidates == nullptr || linePoints == nullptr || widths == nullptr) { + end(); + return false; + } + return true; +} + +void LabelLayout::end() { + if (releaseMemory != nullptr) { + for (void* block : { static_cast(arena), static_cast(texts), static_cast(candidates), static_cast(linePoints), + static_cast(widths) }) { + if (block != nullptr) { + releaseMemory(block); + } + } + } + arena = nullptr; + texts = nullptr; + candidates = nullptr; + linePoints = nullptr; + widths = nullptr; + placedCount = 0; + candidateCount = 0; +} + +void LabelLayout::setFonts(LabelFont* small, LabelFont* normal, LabelFont* large) { + // New fonts measure differently + if (widths != nullptr) { + memset(widths, 0, sizeof(WidthEntry) * WIDTH_ENTRIES); + } + fonts[SIZE_SMALL] = small; + fonts[SIZE_NORMAL] = normal != nullptr ? normal : small; + fonts[SIZE_LARGE] = large != nullptr ? large : fonts[SIZE_NORMAL]; +} + +LabelFont* LabelLayout::fontFor(int size) const { + return fonts[std::clamp(size, 0, 2)]; +} + +void LabelLayout::clear() { + placedCount = 0; +} + +void LabelLayout::beginView(int width, int height, int viewZoom) { + screenWidth = width; + screenHeight = height; + zoom = viewZoom; + candidateCount = 0; + recordCount = 0; + linePointCount = 0; + textsUsed = 0; + gridColumns = (width + CELL - 1) / CELL; + gridRows = (height + CELL - 1) / CELL; + if (gridColumns * gridRows > static_cast(sizeof(grid) * 8)) { + gridRows = static_cast(sizeof(grid) * 8) / gridColumns; + } +} + +bool LabelLayout::wants(const LabelRecord& record, uint8_t& priority, uint8_t& fontSize, uint16_t& color) const { + if (density == LabelDensity::Off) { + return false; + } + // Few shows each kind one or two zoom levels later and leaves out the least important ones + const int later = density == LabelDensity::Few ? 1 : 0; + fontSize = SIZE_SMALL; + switch (record.kind) { + case LabelKind::Place: + color = style.place; + switch (record.labelClass) { + case Value::continent: + case Value::country: + priority = PRIORITY_REGION; + fontSize = SIZE_LARGE; + return zoom <= 7; + case Value::state: + case Value::province: + priority = PRIORITY_REGION; + return zoom >= 5 && zoom <= 8 && density == LabelDensity::Normal; + case Value::city: + priority = PRIORITY_CITY; + fontSize = SIZE_LARGE; + return zoom >= 4; + case Value::town: + priority = PRIORITY_TOWN; + fontSize = SIZE_NORMAL; + return zoom >= 8 + 2 * later; + case Value::village: + priority = PRIORITY_VILLAGE; + return zoom >= 11 + later; + case Value::suburb: + case Value::quarter: + priority = PRIORITY_SUBURB; + return zoom >= 12 + later; + case Value::neighbourhood: + case Value::hamlet: + priority = PRIORITY_HAMLET; + return zoom >= 14 && density == LabelDensity::Normal; + case Value::isolated_dwelling: + priority = PRIORITY_HAMLET; + return zoom >= 16 && density == LabelDensity::Normal; + case Value::island: + priority = PRIORITY_WATER; + return zoom >= 12; + default: + priority = PRIORITY_HAMLET; + return zoom >= 14 && density == LabelDensity::Normal; + } + case LabelKind::Road: { + color = style.road; + const int first = roadZoom(record.labelClass); + priority = first == 12 ? PRIORITY_MAJOR_ROAD : PRIORITY_MINOR_ROAD; + if (density == LabelDensity::Few && first > 14) { + return false; + } + return zoom >= first + later; + } + case LabelKind::Water: + color = style.water; + priority = PRIORITY_WATER; + if (record.labelClass == Value::ocean || record.labelClass == Value::sea) { + return zoom >= 3; + } + return zoom >= 9 + 2 * later; + case LabelKind::Waterway: + color = style.water; + priority = PRIORITY_WATER; + if (record.labelClass == Value::river) { + return zoom >= 12 + later; + } + return zoom >= 15 && density == LabelDensity::Normal; + case LabelKind::Peak: + color = style.peak; + priority = PRIORITY_PEAK; + return zoom >= 12 + later; + case LabelKind::Poi: { + PoiAppearance look {}; + if (poiLookup == nullptr || !poiLookup(static_cast(record.labelClass), look) || look.icon == nullptr) { + return false; + } + color = style.binary ? style.place : look.color; + priority = PRIORITY_POI; + // From the type's zoom (15 for most); the most important ones (rank 1-2) one level earlier, at least z14 + int first = look.minZoom; + if (record.rank >= 0 && record.rank <= 2) { + first = std::min(first, 14); + } + return zoom >= std::max(14, first) + later; + } + case LabelKind::HouseNumber: + color = style.houseNumber; + priority = PRIORITY_HOUSE_NUMBER; + return zoom >= 17 + later; + } + return false; +} + +void LabelLayout::addSection(const uint8_t* section, size_t size, int count, const LabelTransform& transform, const LabelCells* cells) { + if (candidates == nullptr) { + return; + } + if (cells == nullptr) { + addRecords(section, size, count, transform); + return; + } + addRecords(section, cells->generalBytes, static_cast(cells->generalCount), transform); + // POIs, then house numbers, of the cells near the screen only: at z17 that is about a tenth of a z14 tile + for (int kind = 0; kind < 2; kind++) { + const uint32_t* offsets = kind == 0 ? cells->poiOffset : cells->numberOffset; + const uint32_t* counts = kind == 0 ? cells->poiCount : cells->numberCount; + for (int cell = 0; cell < LABEL_CELLS; cell++) { + if (counts[cell] != 0 && offsets[cell] < size && cellNearScreen(cell, transform)) { + addRecords(section + offsets[cell], size - offsets[cell], static_cast(counts[cell]), transform); + } + } + } +} + +bool LabelLayout::cellNearScreen(int cell, const LabelTransform& transform) const { + const auto u0 = static_cast((cell % LABEL_GRID) * LABEL_CELL_UNITS); + const auto v0 = static_cast((cell / LABEL_GRID) * LABEL_CELL_UNITS); + const auto span = static_cast(LABEL_CELL_UNITS); + float minX = 1e9f; + float minY = 1e9f; + float maxX = -1e9f; + float maxY = -1e9f; + for (int corner = 0; corner < 4; corner++) { + const float u = u0 + ((corner & 1) != 0 ? span : 0.0f); + const float v = v0 + ((corner & 2) != 0 ? span : 0.0f); + const float x = transform.a * u + transform.b * v + transform.x; + const float y = transform.c * u + transform.d * v + transform.y; + minX = std::min(minX, x); + minY = std::min(minY, y); + maxX = std::max(maxX, x); + maxY = std::max(maxY, y); + } + // The same margins as the test for single points below + return maxX >= -40.0f && maxY >= -20.0f && minX <= screenWidth + 40.0f && minY <= screenHeight + 20.0f; +} + +void LabelLayout::addRecords(const uint8_t* bytes, size_t size, int count, const LabelTransform& transform) { + LabelReader reader; + reader.begin(bytes, size, count); + LabelRecord record {}; + while (candidateCount < MAX_CANDIDATES && reader.next(record)) { + recordCount++; + Candidate candidate {}; + // POIs and house numbers never crowd out place and street names: they stop before the candidates are full + if ((record.kind == LabelKind::Poi && candidateCount >= POI_CANDIDATE_LIMIT) || + (record.kind == LabelKind::HouseNumber && candidateCount >= NUMBER_CANDIDATE_LIMIT)) { + continue; + } + if (!wants(record, candidate.priority, candidate.fontSize, candidate.color)) { + continue; + } + candidate.kind = record.kind; + if (record.kind == LabelKind::Poi) { + PoiAppearance look {}; + poiLookup(static_cast(record.labelClass), look); + candidate.poiType = static_cast(record.labelClass); + candidate.icon = look.icon; + candidate.iconSize = static_cast(look.iconSize); + } + // The Latin-script name when asked for, and for names in scripts the label font has no letters for (Japanese, + // Chinese, Arabic and others) + const bool hasLatin = record.nameLatin != nullptr && record.nameLatinLength > 0; + const bool latin = hasLatin && (preferLatin || !labelFontCoversText(fonts[SIZE_NORMAL], record.name, record.nameLength)); + const char* name = latin ? record.nameLatin : record.name; + const uint16_t length = latin ? record.nameLatinLength : record.nameLength; + // A POI may have no name: its icon is the label, the sheet names its type + if ((length == 0 && record.kind != LabelKind::Poi) || textsUsed + length + 1 > TEXT_BYTES) { + continue; + } + candidate.line = record.pointCount >= 2; + candidate.rank = record.rank < 0 ? INT32_MAX : record.rank; + if (candidate.line) { + if (linePointCount + record.pointCount > MAX_LINE_POINTS) { + continue; + } + candidate.firstPoint = static_cast(std::min(linePointCount, 65535)); + candidate.pointCount = static_cast(record.pointCount); + float minX = 1e9f; + float minY = 1e9f; + float maxX = -1e9f; + float maxY = -1e9f; + for (int i = 0; i < record.pointCount; i++) { + const auto u = static_cast(record.points[2 * i]); + const auto v = static_cast(record.points[2 * i + 1]); + const float x = transform.a * u + transform.b * v + transform.x; + const float y = transform.c * u + transform.d * v + transform.y; + linePoints[2 * (linePointCount + i)] = x; + linePoints[2 * (linePointCount + i) + 1] = y; + minX = std::min(minX, x); + minY = std::min(minY, y); + maxX = std::max(maxX, x); + maxY = std::max(maxY, y); + } + // A line that does not reach the screen keeps none of its points + if (maxX < 0.0f || maxY < 0.0f || minX > screenWidth || minY > screenHeight) { + continue; + } + linePointCount += record.pointCount; + } else { + const auto u = static_cast(record.points[0]); + const auto v = static_cast(record.points[1]); + candidate.x = transform.a * u + transform.b * v + transform.x; + candidate.y = transform.c * u + transform.d * v + transform.y; + // Points off screen cannot show their label + if (candidate.x < -40.0f || candidate.y < -20.0f || candidate.x > screenWidth + 40.0f || candidate.y > screenHeight + 20.0f) { + continue; + } + } + candidate.textOffset = static_cast(textsUsed); + candidate.textLength = length; + memcpy(texts + textsUsed, name, length); + texts[textsUsed + length] = '\0'; + textsUsed += length + 1u; + candidates[candidateCount++] = candidate; + } +} + +int LabelLayout::candidateWidth(LabelFont& font, const Candidate& candidate) { + const char* text = texts + candidate.textOffset; + if (widths == nullptr) { + return textWidth(font, text, candidate.textLength); + } + // FNV-1a over the font size and the text; 0 marks an empty slot + uint32_t hash = 2166136261u ^ candidate.fontSize; + for (int i = 0; i < candidate.textLength; i++) { + hash = (hash ^ static_cast(text[i])) * 16777619u; + } + hash = hash == 0 ? 1 : hash; + WidthEntry& entry = widths[hash & (WIDTH_ENTRIES - 1)]; + if (entry.hash == hash && entry.used != 0) { + return entry.width; + } + const int width = textWidth(font, text, candidate.textLength); + entry = { hash, static_cast(std::clamp(width, 0, 65535)), 1 }; + return width; +} + +int LabelLayout::textWidth(LabelFont& font, const char* text, int length) { + const char* end = text + length; + int widthX16 = 0; + uint32_t previous = 0; + bool hasPrevious = false; + while (text < end) { + LabelGlyph glyph {}; + if (!font.glyph(nextCodepoint(text, end), glyph, false)) { + continue; + } + if (hasPrevious) { + widthX16 += font.kerningX16(previous, glyph.id); + } + widthX16 += glyph.advanceX16; + previous = glyph.id; + hasPrevious = true; + } + return (widthX16 + 8) >> 4; +} + +bool LabelLayout::gridFree(float cx, float cy, float halfWidth, float halfHeight, float angle) const { + // A turned box is checked as squares of its height along its axis + const float ux = std::cos(angle); + const float uy = std::sin(angle); + const float step = std::max(2.0f, halfHeight); + for (float t = -halfWidth; t <= halfWidth + 0.01f; t += step) { + const float px = cx + ux * t; + const float py = cy + uy * t; + const int left = static_cast(std::floor((px - halfHeight) / CELL)); + const int right = static_cast(std::floor((px + halfHeight) / CELL)); + const int top = static_cast(std::floor((py - halfHeight) / CELL)); + const int bottom = static_cast(std::floor((py + halfHeight) / CELL)); + for (int row = top; row <= bottom; row++) { + for (int column = left; column <= right; column++) { + if (row < 0 || column < 0 || row >= gridRows || column >= gridColumns) { + // Labels stay inside the screen + return false; + } + const int cell = row * gridColumns + column; + if ((grid[cell >> 5] >> (cell & 31)) & 1u) { + return false; + } + } + } + } + return true; +} + +void LabelLayout::gridMark(float cx, float cy, float halfWidth, float halfHeight, float angle) { + const float ux = std::cos(angle); + const float uy = std::sin(angle); + const float step = std::max(2.0f, halfHeight); + for (float t = -halfWidth; t <= halfWidth + 0.01f; t += step) { + const float px = cx + ux * t; + const float py = cy + uy * t; + const int left = std::max(0, static_cast(std::floor((px - halfHeight) / CELL))); + const int right = std::min(gridColumns - 1, static_cast(std::floor((px + halfHeight) / CELL))); + const int top = std::max(0, static_cast(std::floor((py - halfHeight) / CELL))); + const int bottom = std::min(gridRows - 1, static_cast(std::floor((py + halfHeight) / CELL))); + for (int row = top; row <= bottom; row++) { + for (int column = left; column <= right; column++) { + const int cell = row * gridColumns + column; + grid[cell >> 5] |= 1u << (cell & 31); + } + } + } +} + +bool LabelLayout::alreadyPlaced(const Candidate& candidate, float x, float y) const { + // The same name from a neighbouring tile, or the same street again too soon + const char* name = texts + candidate.textOffset; + // House numbers repeat in every street: only the same number at the same spot (two tiles' copies) is one + const float reach = candidate.line ? static_cast(LINE_REPEAT_PIXELS) : (candidate.kind == LabelKind::HouseNumber ? 6.0f : 120.0f); + for (int i = 0; i < placedCount; i++) { + const Placed& other = placed[i]; + if (other.line != candidate.line || other.kind != candidate.kind || strcmp(texts + other.textOffset, name) != 0) { + continue; + } + const float dx = other.x + other.width / 2.0f - x; + const float dy = other.y + other.height / 2.0f - y; + if (dx * dx + dy * dy < reach * reach) { + return true; + } + } + return false; +} + +bool LabelLayout::placePoint(const Candidate& candidate) { + LabelFont* font = fontFor(candidate.fontSize); + if (font == nullptr || alreadyPlaced(candidate, candidate.x, candidate.y)) { + return false; + } + const int width = candidateWidth(*font, candidate); + const float halfWidth = width / 2.0f + GAP; + const float halfHeight = font->lineHeight() / 2.0f + GAP; + // Peaks get their name above the point, places centred on it + const float centerY = candidate.priority == PRIORITY_PEAK ? candidate.y - halfHeight - 3.0f : candidate.y; + if (!gridFree(candidate.x, centerY, halfWidth, halfHeight, 0.0f)) { + return false; + } + if (!drawSprite(candidate, candidate.x, centerY, 0.0f, width)) { + return false; + } + gridMark(candidate.x, centerY, halfWidth, halfHeight, 0.0f); + return true; +} + +bool LabelLayout::placePoi(const Candidate& candidate) { + LabelFont* font = fontFor(candidate.fontSize); + if (font == nullptr || candidate.icon == nullptr) { + return false; + } + // Chains repeat names (two shops of the same name a street apart): only a copy right next to it is the same POI + const char* name = texts + candidate.textOffset; + for (int i = 0; i < placedCount; i++) { + const Placed& other = placed[i]; + if (other.kind == LabelKind::Poi && other.poiType == candidate.poiType && strcmp(texts + other.textOffset, name) == 0 && + std::fabs(other.anchorX - candidate.x) < 6.0f && std::fabs(other.anchorY - candidate.y) < 6.0f) { + return false; + } + } + const float iconHalf = candidate.iconSize / 2.0f + GAP; + // Where even the icon has no room, nothing is measured or drawn + if (!gridFree(candidate.x, candidate.y, iconHalf, iconHalf, 0.0f)) { + return false; + } + // The name to the right of the icon when zoomed in and there is room, else the icon alone + if (zoom >= POI_NAME_ZOOM && candidate.textLength > 0) { + const int textPixels = candidateWidth(*font, candidate); + const float contentWidth = candidate.iconSize + 3.0f + textPixels; + const float centerX = candidate.x - candidate.iconSize / 2.0f + contentWidth / 2.0f; + const float halfHeight = std::max(iconHalf, font->lineHeight() / 2.0f + GAP); + if (gridFree(centerX, candidate.y, contentWidth / 2.0f + GAP, halfHeight, 0.0f) && + drawSprite(candidate, centerX, candidate.y, 0.0f, textPixels, true)) { + gridMark(centerX, candidate.y, contentWidth / 2.0f + GAP, halfHeight, 0.0f); + return true; + } + } + if (!drawSprite(candidate, candidate.x, candidate.y, 0.0f, 0, false)) { + return false; + } + gridMark(candidate.x, candidate.y, iconHalf, iconHalf, 0.0f); + return true; +} + +int LabelLayout::placeLine(const Candidate& candidate) { + LabelFont* font = fontFor(candidate.fontSize); + if (font == nullptr) { + return 0; + } + const float* points = linePoints + 2 * candidate.firstPoint; + const int count = candidate.pointCount; + const int width = candidateWidth(*font, candidate); + const float needed = static_cast(width) + 8.0f; + const float halfHeight = font->lineHeight() / 2.0f + GAP; + // Distance along the line at each point + float lengths[LabelReader::MAX_POINTS]; + lengths[0] = 0.0f; + const int used = std::min(count, LabelReader::MAX_POINTS); + for (int i = 1; i < used; i++) { + const float dx = points[2 * i] - points[2 * i - 2]; + const float dy = points[2 * i + 1] - points[2 * i - 1]; + lengths[i] = lengths[i - 1] + std::sqrt(dx * dx + dy * dy); + } + const float total = lengths[used - 1]; + if (total < needed) { + return 0; + } + auto pointAt = [&](float distance, float& x, float& y, int& segment) { + segment = 1; + while (segment < used - 1 && lengths[segment] < distance) { + segment++; + } + const float span = std::max(1e-3f, lengths[segment] - lengths[segment - 1]); + const float t = std::clamp((distance - lengths[segment - 1]) / span, 0.0f, 1.0f); + x = points[2 * segment - 2] + (points[2 * segment] - points[2 * segment - 2]) * t; + y = points[2 * segment - 1] + (points[2 * segment + 1] - points[2 * segment - 1]) * t; + }; + int placedHere = 0; + for (float start = 0.0f; start + needed <= total && placedCount < MAX_LABELS;) { + float x0; + float y0; + float x1; + float y1; + int first; + int last; + pointAt(start, x0, y0, first); + pointAt(start + needed, x1, y1, last); + const float chordX = x1 - x0; + const float chordY = y1 - y0; + const float chord = std::sqrt(chordX * chordX + chordY * chordY); + bool straight = chord >= needed * 0.92f; + // Every segment inside the stretch must run close to the chord's direction + const float angle = std::atan2(chordY, chordX); + for (int i = first; i <= last && straight; i++) { + const float sx = points[2 * i] - points[2 * i - 2]; + const float sy = points[2 * i + 1] - points[2 * i - 1]; + if (sx * sx + sy * sy < 4.0f) { + continue; + } + float bend = std::fabs(std::atan2(sy, sx) - angle); + if (bend > PI) { + bend = 2.0f * PI - bend; + } + straight = bend <= MAX_BEND; + } + const float centerX = (x0 + x1) / 2.0f; + const float centerY = (y0 + y1) / 2.0f; + if (centerX < halfHeight || centerY < halfHeight || centerX > screenWidth - halfHeight || centerY > screenHeight - halfHeight) { + start += 16.0f; + continue; + } + // Upright: text never runs from right to left + float upright = angle; + if (upright > PI / 2.0f) { + upright -= PI; + } else if (upright < -PI / 2.0f) { + upright += PI; + } + if (straight && !alreadyPlaced(candidate, centerX, centerY) && gridFree(centerX, centerY, width / 2.0f + GAP, halfHeight, upright) && + drawSprite(candidate, centerX, centerY, upright, width)) { + gridMark(centerX, centerY, width / 2.0f + GAP, halfHeight, upright); + placedHere++; + start += needed + LINE_REPEAT_PIXELS; + } else { + start += 16.0f; + } + } + return placedHere; +} + +bool LabelLayout::drawSprite(const Candidate& candidate, float centerX, float centerY, float angle, int textPixels, bool withText) { + LabelFont* font = fontFor(candidate.fontSize); + if (placedCount >= MAX_LABELS || font == nullptr) { + return false; + } + const float cosine = std::cos(angle); + const float sine = std::sin(angle); + // The text box, with room for the halo, turned + // A POI's icon comes first, its name (if drawn) after a small gap + const int iconSize = candidate.icon != nullptr ? candidate.iconSize : 0; + const int iconWidth = iconSize > 0 ? iconSize + (withText ? 3 : 0) : 0; + const float boxWidth = static_cast(iconWidth + (withText ? textPixels : 0)) + 6.0f; + const float boxHeight = static_cast(std::max(withText ? font->lineHeight() : 0, iconSize)) + 4.0f; + const int width = static_cast(std::ceil(std::fabs(boxWidth * cosine) + std::fabs(boxHeight * sine))) + 2; + const int height = static_cast(std::ceil(std::fabs(boxWidth * sine) + std::fabs(boxHeight * cosine))) + 2; + if (candidate.line && (centerX - width / 2.0f < -0.5f || centerY - height / 2.0f < -0.5f || centerX + width / 2.0f > screenWidth + 0.5f || + centerY + height / 2.0f > screenHeight + 0.5f)) { + return false; // a steep line label's turned box would stick out of the screen + } + const size_t bytes = static_cast(width) * height * 2; + if (arenaUsed + bytes + 2 * static_cast(width) * height > ARENA_BYTES) { + return false; + } + const uint32_t started = clock != nullptr ? clock() : 0; + uint8_t* textPlane = arena + arenaUsed; + uint8_t* haloPlane = textPlane + static_cast(width) * height; + memset(textPlane, 0, bytes); + // Glyphs along the baseline, the text's centre at the sprite's centre + const float spriteCenterX = width / 2.0f; + const float spriteCenterY = height / 2.0f; + const float baseline = font->ascent() - font->lineHeight() / 2.0f; + const char* text = texts + candidate.textOffset; + const char* end = withText ? text + candidate.textLength : text; + const int contentWidth = iconWidth + (withText ? textPixels : 0); + // The text starts after the icon; without one this is -textPixels * 8, the text centred + int penX16 = static_cast((static_cast(iconWidth) - contentWidth / 2.0f) * 16.0f); + if (iconSize > 0) { + // The icon's mask at the left end, centred on the line; icons are only drawn level + const int iconX = static_cast(std::floor(spriteCenterX - contentWidth / 2.0f + 0.5f)); + const int iconY = static_cast(std::floor(spriteCenterY - iconSize / 2.0f + 0.5f)); + for (int gy = 0; gy < iconSize; gy++) { + const int y = iconY + gy; + for (int gx = 0; gx < iconSize && y >= 0 && y < height; gx++) { + const int x = iconX + gx; + if (x >= 0 && x < width) { + uint8_t& target = textPlane[y * width + x]; + target = std::max(target, candidate.icon[gy * iconSize + gx]); + } + } + } + } + uint32_t previous = 0; + bool hasPrevious = false; + while (text < end) { + LabelGlyph glyph {}; + if (!font->glyph(nextCodepoint(text, end), glyph, true)) { + continue; + } + if (hasPrevious) { + penX16 += font->kerningX16(previous, glyph.id); + } + previous = glyph.id; + hasPrevious = true; + if (glyph.alpha != nullptr && glyph.width > 0 && glyph.height > 0 && angle == 0.0f) { + // Level text: the mask is copied to the nearest whole pixel + const int originX = static_cast(std::floor(spriteCenterX + penX16 / 16.0f + glyph.left + 0.5f)); + const int originY = static_cast(std::floor(spriteCenterY + baseline + glyph.top + 0.5f)); + for (int gy = 0; gy < glyph.height; gy++) { + const int y = originY + gy; + if (y < 0 || y >= height) { + continue; + } + for (int gx = 0; gx < glyph.width; gx++) { + const int x = originX + gx; + if (x >= 0 && x < width) { + uint8_t& target = textPlane[y * width + x]; + target = std::max(target, glyph.alpha[gy * glyph.width + gx]); + } + } + } + } else if (glyph.alpha != nullptr && glyph.width > 0 && glyph.height > 0) { + // The glyph's top left corner in text coordinates, then each sprite pixel near it sampled back + const float originX = penX16 / 16.0f + glyph.left; + const float originY = baseline + glyph.top; + const float cornersX[4] = { originX, originX + glyph.width, originX, originX + glyph.width }; + const float cornersY[4] = { originY, originY, originY + glyph.height, originY + glyph.height }; + float minX = 1e9f; + float minY = 1e9f; + float maxX = -1e9f; + float maxY = -1e9f; + for (int i = 0; i < 4; i++) { + const float sx = spriteCenterX + cornersX[i] * cosine - cornersY[i] * sine; + const float sy = spriteCenterY + cornersX[i] * sine + cornersY[i] * cosine; + minX = std::min(minX, sx); + minY = std::min(minY, sy); + maxX = std::max(maxX, sx); + maxY = std::max(maxY, sy); + } + const int left = std::max(0, static_cast(std::floor(minX)) - 1); + const int right = std::min(width - 1, static_cast(std::ceil(maxX)) + 1); + const int top = std::max(0, static_cast(std::floor(minY)) - 1); + const int bottom = std::min(height - 1, static_cast(std::ceil(maxY)) + 1); + for (int y = top; y <= bottom; y++) { + for (int x = left; x <= right; x++) { + // Back into the glyph's mask, sampled at the pixel centre with bilinear filtering + const float dx = x + 0.5f - spriteCenterX; + const float dy = y + 0.5f - spriteCenterY; + const float gx = dx * cosine + dy * sine - originX - 0.5f; + const float gy = -dx * sine + dy * cosine - originY - 0.5f; + if (gx < -1.0f || gy < -1.0f || gx >= glyph.width || gy >= glyph.height) { + continue; + } + const int ix = static_cast(std::floor(gx)); + const int iy = static_cast(std::floor(gy)); + const float fx = gx - ix; + const float fy = gy - iy; + auto at = [&](int px, int py) -> float { + return px < 0 || py < 0 || px >= glyph.width || py >= glyph.height ? 0.0f : glyph.alpha[py * glyph.width + px]; + }; + const float value = (at(ix, iy) * (1 - fx) + at(ix + 1, iy) * fx) * (1 - fy) + (at(ix, iy + 1) * (1 - fx) + at(ix + 1, iy + 1) * fx) * fy; + uint8_t& target = textPlane[y * width + x]; + target = std::max(target, static_cast(std::min(255.0f, value + 0.5f))); + } + } + } + penX16 += glyph.advanceX16; + } + // The halo: the text grown by one pixel, and half as strong one more pixel out + uint8_t* scratch = haloPlane + static_cast(width) * height; + uint8_t* scratch2 = scratch + static_cast(width) * height; + // A 3 x 3 maximum: by rows into rows, then by columns into to + auto grow = [&](const uint8_t* from, uint8_t* to, uint8_t* rows) { + for (int y = 0; y < height; y++) { + const uint8_t* in = from + y * width; + uint8_t* out = rows + y * width; + for (int x = 0; x < width; x++) { + uint8_t value = in[x]; + if (x > 0 && in[x - 1] > value) { + value = in[x - 1]; + } + if (x + 1 < width && in[x + 1] > value) { + value = in[x + 1]; + } + out[x] = value; + } + } + for (int y = 0; y < height; y++) { + const uint8_t* above = rows + (y > 0 ? y - 1 : y) * width; + const uint8_t* here = rows + y * width; + const uint8_t* below = rows + (y + 1 < height ? y + 1 : y) * width; + uint8_t* out = to + y * width; + for (int x = 0; x < width; x++) { + out[x] = std::max(here[x], std::max(above[x], below[x])); + } + } + }; + grow(textPlane, haloPlane, scratch); + grow(haloPlane, scratch, scratch2); + for (int i = 0; i < width * height; i++) { + haloPlane[i] = std::max(haloPlane[i], static_cast(scratch[i] / 2)); + } + Placed& label = placed[placedCount++]; + label.textOffset = candidate.textOffset; + label.x = static_cast(std::floor(centerX - width / 2.0f + 0.5f)); + label.y = static_cast(std::floor(centerY - height / 2.0f + 0.5f)); + label.width = static_cast(width); + label.height = static_cast(height); + label.color = candidate.color; + label.line = candidate.line; + label.spriteOffset = static_cast(arenaUsed); + label.kind = candidate.kind; + label.poiType = candidate.poiType; + label.anchorX = static_cast(std::clamp(candidate.x, -30000.0f, 30000.0f)); + label.anchorY = static_cast(std::clamp(candidate.y, -30000.0f, 30000.0f)); + arenaUsed += bytes; + if (clock != nullptr) { + spriteTime += clock() - started; + } + return true; +} + +int LabelLayout::place() { + placedCount = 0; + arenaUsed = 0; + spriteTime = 0; + memset(grid, 0, sizeof(grid)); + if (candidates == nullptr || density == LabelDensity::Off) { + return 0; + } + // The most important first; within a kind the lower rank first + std::sort(candidates, candidates + candidateCount, [](const Candidate& a, const Candidate& b) { + if (a.priority != b.priority) { + return a.priority < b.priority; + } + return a.rank < b.rank; + }); + for (int i = 0; i < candidateCount && placedCount < MAX_LABELS; i++) { + if (candidates[i].line) { + placeLine(candidates[i]); + } else if (candidates[i].kind == LabelKind::Poi) { + placePoi(candidates[i]); + } else { + placePoint(candidates[i]); + } + } + return placedCount; +} + +void LabelLayout::draw(Canvas& canvas, int offsetX, int offsetY, bool lineLabels) const { + for (int i = 0; i < placedCount; i++) { + const Placed& label = placed[i]; + if (label.line && !lineLabels) { + continue; + } + const uint8_t* textPlane = arena + label.spriteOffset; + const uint8_t* haloPlane = textPlane + static_cast(label.width) * label.height; + const int originX = label.x + offsetX; + const int originY = label.y + offsetY; + const int top = std::max(0, -originY); + const int bottom = std::min(static_cast(label.height), canvas.height - originY); + const int left = std::max(0, -originX); + const int right = std::min(static_cast(label.width), canvas.width - originX); + for (int y = top; y < bottom; y++) { + uint16_t* row = canvas.pixels + (originY + y) * canvas.width + originX; + const uint8_t* halo = haloPlane + y * label.width; + const uint8_t* ink = textPlane + y * label.width; + for (int x = left; x < right; x++) { + if (style.binary) { + // Black and white displays: no blending, the halo first + if (ink[x] >= 128) { + row[x] = label.color; + } else if (halo[x] >= 128) { + row[x] = style.halo; + } + continue; + } + if (halo[x] != 0) { + const uint32_t alpha = (halo[x] + 4u) >> 3; + row[x] = alpha >= 32 ? style.halo : blendRgb565(style.halo, row[x], alpha); + } + if (ink[x] != 0) { + const uint32_t alpha = (ink[x] + 4u) >> 3; + row[x] = alpha >= 32 ? label.color : blendRgb565(label.color, row[x], alpha); + } + } + } + } +} + +bool LabelLayout::item(int index, PlacedItem& out) const { + if (index < 0 || index >= placedCount) { + return false; + } + const Placed& label = placed[index]; + if (label.kind != LabelKind::Poi && label.kind != LabelKind::HouseNumber) { + return false; + } + out = { label.kind, label.poiType, texts + label.textOffset, label.anchorX, label.anchorY }; + return true; +} + +int LabelLayout::pick(int x, int y, int radius, int offsetX, int offsetY) const { + int best = -1; + int bestDistance = radius * radius + 1; + for (int i = 0; i < placedCount; i++) { + const Placed& label = placed[i]; + if (label.kind != LabelKind::Poi && label.kind != LabelKind::HouseNumber) { + continue; + } + // Inside the label's box counts as on it; otherwise the distance to its point + const int left = label.x + offsetX; + const int top = label.y + offsetY; + const bool inside = x >= left && y >= top && x < left + label.width && y < top + label.height; + const int dx = label.anchorX + offsetX - x; + const int dy = label.anchorY + offsetY - y; + const int distance = inside ? 0 : dx * dx + dy * dy; + if (distance < bestDistance) { + best = i; + bestDistance = distance; + } + } + return best; +} + +int LabelLayout::byDistance(int x, int y, int* out, int max, int offsetX, int offsetY) const { + int count = 0; + int distances[MAX_LABELS]; + for (int i = 0; i < placedCount && count < max && count < MAX_LABELS; i++) { + const Placed& label = placed[i]; + if (label.kind != LabelKind::Poi && label.kind != LabelKind::HouseNumber) { + continue; + } + const int dx = label.anchorX + offsetX - x; + const int dy = label.anchorY + offsetY - y; + // Insertion into the sorted list, nearest first + const int distance = dx * dx + dy * dy; + int at = count++; + while (at > 0 && distances[at - 1] > distance) { + distances[at] = distances[at - 1]; + out[at] = out[at - 1]; + at--; + } + distances[at] = distance; + out[at] = i; + } + return count; +} + +void LabelLayout::bounds(int index, int& x, int& y, int& w, int& h) const { + const Placed& label = placed[index]; + x = label.x; + y = label.y; + w = label.width; + h = label.height; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/LabelLayout.h b/Apps/Maps/main/Source/render/LabelLayout.h new file mode 100644 index 0000000..1cd66ed --- /dev/null +++ b/Apps/Maps/main/Source/render/LabelLayout.h @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "PreparedLabels.h" + +#include "../tiles/TileMath.h" +#include "../ui/Canvas.h" + +#include +#include + +namespace maps { + +/** One glyph of a label font: its alpha mask (one byte per pixel, rows of width bytes) relative to the pen. */ +struct LabelGlyph { + uint32_t id; + int advanceX16; + /** Left edge of the mask from the pen, and its top edge from the baseline (negative is above it). */ + int left; + int top; + int width; + int height; + const uint8_t* alpha; +}; + +/** A font that gives glyph masks, so labels can be drawn with a halo and turned along a line. */ +class LabelFont { +public: + virtual ~LabelFont() = default; + virtual int ascent() const = 0; + virtual int lineHeight() const = 0; + /** + * The glyph of a codepoint; with withMask its alpha is decoded and stays valid until the next call, otherwise alpha + * is nullptr (measuring only). False when the font has none. + */ + virtual bool glyph(uint32_t codepoint, LabelGlyph& out, bool withMask) = 0; + virtual int kerningX16(uint32_t leftId, uint32_t rightId) = 0; +}; + +/** How many labels: Off, Few (the important places and roads only, from higher zoom levels), Normal. */ +enum class LabelDensity : uint8_t { + Off, + Few, + Normal +}; + +/** Colours of the labels in the current palette, and whether the display only has black and white. */ +struct LabelStyle { + uint16_t place; + uint16_t road; + uint16_t water; + uint16_t peak; + uint16_t halo; + bool binary; + /** House numbers: small and quiet. */ + uint16_t houseNumber = 0x7BEF; +}; + +/** How a POI type looks: its icon (an alpha mask, iconSize x iconSize), colour, and the first zoom it shows at. */ +struct PoiAppearance { + const uint8_t* icon; + int iconSize; + uint16_t color; + uint8_t minZoom; +}; + +/** Gives the appearance of a POI type; false for types that are never shown. */ +using PoiLookup = bool (*)(uint8_t poiType, PoiAppearance& out); + +/** A placed POI or house number, for the sheet: its kind, POI type, name ("" when it has none) and screen point. */ +struct PlacedItem { + LabelKind kind; + uint8_t poiType; + const char* name; + int x; + int y; +}; + +/** + * Where the tile a label section belongs to is on screen: screen = (a * u + b * v + x, c * u + d * v + y) for a point + * u, v in the tile's 2048 extent. Scale and, later, rotation of the view are in a, b, c, d. + */ +struct LabelTransform { + float a; + float b; + float c; + float d; + float x; + float y; +}; + +/** + * Labels placed in screen space for one view: names of places, peaks and water at their points, street and river + * names along straight stretches of their lines. Labels stay upright, never overlap (a collision grid, the most + * important first: city, town, village, suburb, major road, minor road, water, peak) and a name that several tiles + * carry shows once, a line name at most once per LINE_REPEAT_PIXELS. + * + * Each placed label is drawn once into a small sprite with its halo, in a fixed arena, so drawing a frame only blends + * the sprites, and a pan moves them without placing again. + */ +class LabelLayout { +public: + static constexpr int MAX_LABELS = 160; + static constexpr int MAX_CANDIDATES = 640; + /** POIs and house numbers stop being taken in once the candidates are this full, so names always find room. */ + static constexpr int POI_CANDIDATE_LIMIT = MAX_CANDIDATES * 3 / 4; + static constexpr int NUMBER_CANDIDATE_LIMIT = MAX_CANDIDATES / 2; + /** POI names show next to their icon from this zoom. */ + static constexpr int POI_NAME_ZOOM = 17; + static constexpr int LINE_REPEAT_PIXELS = 300; + /** Sprite pixels for all labels of a view, two bytes each (text and halo coverage). */ + static constexpr size_t ARENA_BYTES = 192 * 1024; + /** Room for the names of the candidates of one view. */ + static constexpr size_t TEXT_BYTES = 24 * 1024; + + ~LabelLayout(); + /** Allocates the arena; allocate() comes from the platform so the arena can be in PSRAM. */ + bool begin(void* (*allocate)(size_t), void (*release)(void*)); + void end(); + + /** Fonts by size: small (roads, water, peaks, villages), normal (towns), large (cities, countries). */ + void setFonts(LabelFont* small, LabelFont* normal, LabelFont* large); + void setStyle(const LabelStyle& value) { style = value; } + void setDensity(LabelDensity value) { density = value; } + void setPreferLatin(bool value) { preferLatin = value; } + /** Without a lookup no POIs are shown. */ + void setPoiLookup(PoiLookup value) { poiLookup = value; } + + /** Starts a new placement for a screen of width x height at a view zoom level. */ + void beginView(int width, int height, int viewZoom); + /** + * Adds the labels of one source tile's label section. With cells (a split tile's label file) only the POI and house + * number cells near the screen are read. + */ + void addSection(const uint8_t* section, size_t size, int count, const LabelTransform& transform, const LabelCells* cells = nullptr); + /** Places the candidates and draws their sprites. Returns the number of labels placed. */ + int place(); + /** Drops all labels, for example when the view changed so much that they would be wrong. */ + void clear(); + + /** Draws the placed labels moved by offset pixels; lineLabels false leaves street and river names out. */ + void draw(Canvas& canvas, int offsetX, int offsetY, bool lineLabels) const; + int count() const { return placedCount; } + int candidateTotal() const { return candidateCount; } + /** Records read by the addSection() calls since beginView(), for the log. */ + int recordsRead() const { return recordCount; } + /** Time the last place() spent drawing sprites, set through setClock(). */ + uint32_t spriteMicros() const { return spriteTime; } + void setClock(uint32_t (*micros)()) { clock = micros; } + /** For tests: the box a placed label covers on screen. */ + void bounds(int index, int& x, int& y, int& w, int& h) const; + bool isLineLabel(int index) const { return placed[index].line; } + + /** A placed label that can be selected (a POI or a house number); false for the others. */ + bool item(int index, PlacedItem& out) const; + /** The selectable label nearest to (x, y) within radius pixels (offset: how far the labels moved since placed); -1 if none. */ + int pick(int x, int y, int radius, int offsetX = 0, int offsetY = 0) const; + /** + * The selectable labels by distance from (x, y), nearest first, at most max of them, into out. Returns how many. + * Enter walks this list: the first press takes the nearest, the next the one after it. + */ + int byDistance(int x, int y, int* out, int max, int offsetX = 0, int offsetY = 0) const; + const char* text(int index) const { return texts + placed[index].textOffset; } + +private: + struct Candidate { + uint32_t textOffset; + uint16_t textLength; + uint8_t priority; + uint8_t fontSize; + uint16_t color; + bool line; + /** Point labels: the point; line labels: index of the first point in linePoints. */ + float x; + float y; + uint16_t firstPoint; + uint16_t pointCount; + int32_t rank; + LabelKind kind; + uint8_t poiType; + uint8_t iconSize; + const uint8_t* icon; + }; + struct Placed { + uint32_t textOffset; + int16_t x; + int16_t y; + int16_t width; + int16_t height; + uint16_t color; + bool line; + uint32_t spriteOffset; + LabelKind kind; + uint8_t poiType; + /** The labelled point on screen when placed (for POIs and house numbers). */ + int16_t anchorX; + int16_t anchorY; + }; + + bool wants(const LabelRecord& record, uint8_t& priority, uint8_t& fontSize, uint16_t& color) const; + LabelFont* fontFor(int size) const; + int textWidth(LabelFont& font, const char* text, int length); + /** textWidth of a candidate's name, remembered across placements (the same names come back view after view). */ + int candidateWidth(LabelFont& font, const Candidate& candidate); + struct WidthEntry { + uint32_t hash; + uint16_t width; + uint16_t used; + }; + static constexpr int WIDTH_ENTRIES = 1024; + WidthEntry* widths = nullptr; + bool gridFree(float cx, float cy, float halfWidth, float halfHeight, float angle) const; + void gridMark(float cx, float cy, float halfWidth, float halfHeight, float angle); + bool placePoint(const Candidate& candidate); + bool placePoi(const Candidate& candidate); + int placeLine(const Candidate& candidate); + /** Draws text turned by angle (radians) around the centre of a new sprite; false when the arena is full. */ + bool drawSprite(const Candidate& candidate, float centerX, float centerY, float angle, int textPixels, bool withText = true); + bool alreadyPlaced(const Candidate& candidate, float x, float y) const; + + void* (*allocateMemory)(size_t) = nullptr; + void (*releaseMemory)(void*) = nullptr; + uint8_t* arena = nullptr; + size_t arenaUsed = 0; + char* texts = nullptr; + size_t textsUsed = 0; + Candidate* candidates = nullptr; + int candidateCount = 0; + float* linePoints = nullptr; + int linePointCount = 0; + Placed placed[MAX_LABELS] {}; + int placedCount = 0; + /** One bit per 8 x 8 pixel cell of the screen. */ + uint32_t grid[128] {}; + int gridColumns = 0; + int gridRows = 0; + int screenWidth = 0; + int screenHeight = 0; + int zoom = 0; + LabelFont* fonts[3] {}; + LabelStyle style {}; + LabelDensity density = LabelDensity::Normal; + bool preferLatin = false; + PoiLookup poiLookup = nullptr; + uint32_t (*clock)() = nullptr; + uint32_t spriteTime = 0; + int recordCount = 0; + void addRecords(const uint8_t* bytes, size_t size, int count, const LabelTransform& transform); + bool cellNearScreen(int cell, const LabelTransform& transform) const; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/PoiIconData.h b/Apps/Maps/main/Source/render/PoiIconData.h new file mode 100644 index 0000000..5b8fb25 --- /dev/null +++ b/Apps/Maps/main/Source/render/PoiIconData.h @@ -0,0 +1,1532 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Alpha masks generated from Maki icons (https://github.com/mapbox/maki, CC0 1.0) +// by host/make_poi_icons.py. Never edit by hand. +#pragma once +#include + +namespace maps { + +constexpr int POI_ICON_SIZE = 15; + +struct PoiIconMask { const char* name; uint8_t alpha[POI_ICON_SIZE * POI_ICON_SIZE]; }; + +/** Sorted by name, for a binary search. */ +constexpr PoiIconMask POI_ICON_MASKS[] = { + { "aerialway", { + 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 32, 64, 96, 112, 0, + 0, 0, 32, 64, 96, 128, 175, 255, 239, 255, 223, 191, 159, 112, 0, + 0, 223, 223, 191, 159, 128, 112, 255, 48, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 255, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 255, 0, + 0, 255, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 255, 0, + 0, 255, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 255, 0, + 0, 255, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 255, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "alcohol-shop", { + 0, 0, 0, 112, 255, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 223, 255, 223, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 128, 255, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 223, 255, 223, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 143, 255, 255, 255, 143, 0, 0, 0, 255, 255, 255, 255, 0, + 0, 80, 255, 255, 255, 255, 255, 80, 0, 0, 255, 0, 0, 255, 0, + 0, 223, 255, 255, 255, 255, 255, 223, 0, 0, 255, 0, 0, 255, 0, + 0, 255, 207, 64, 0, 64, 207, 255, 0, 0, 255, 0, 0, 255, 0, + 0, 255, 64, 0, 0, 0, 64, 255, 0, 0, 191, 175, 175, 191, 0, + 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 16, 175, 175, 16, 0, + 0, 255, 64, 0, 0, 0, 64, 255, 0, 0, 0, 128, 128, 0, 0, + 0, 255, 207, 64, 0, 64, 207, 255, 0, 0, 0, 128, 128, 0, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 128, 128, 0, 0, + 0, 207, 255, 255, 255, 255, 255, 207, 0, 0, 96, 255, 255, 96, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "amusement-park", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 48, 159, 239, 255, 239, 159, 48, 0, 0, 0, 0, + 0, 0, 0, 96, 255, 159, 80, 112, 80, 159, 255, 96, 0, 0, 0, + 0, 0, 80, 239, 96, 0, 0, 64, 0, 0, 96, 239, 80, 0, 0, + 0, 0, 239, 112, 32, 32, 0, 64, 0, 32, 32, 112, 239, 0, 0, + 0, 80, 239, 0, 0, 32, 64, 159, 64, 32, 0, 0, 239, 80, 0, + 0, 128, 207, 64, 64, 64, 223, 255, 223, 64, 64, 64, 207, 128, 0, + 0, 128, 191, 0, 0, 0, 223, 255, 223, 0, 0, 0, 191, 128, 0, + 0, 80, 239, 0, 0, 32, 64, 255, 64, 32, 0, 0, 239, 80, 0, + 0, 0, 239, 112, 32, 32, 0, 255, 0, 32, 32, 112, 239, 0, 0, + 0, 0, 80, 239, 96, 0, 0, 255, 0, 0, 96, 239, 80, 0, 0, + 0, 0, 0, 96, 255, 159, 80, 255, 80, 159, 255, 96, 0, 0, 0, + 0, 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, 0, + 0, 0, 0, 191, 255, 255, 255, 255, 255, 255, 255, 191, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "aquarium", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 159, 223, 255, 255, 223, 175, 64, 0, + 0, 0, 0, 0, 48, 191, 255, 255, 255, 255, 159, 64, 0, 0, 0, + 0, 0, 0, 48, 239, 255, 255, 255, 255, 255, 32, 0, 0, 0, 0, + 0, 0, 48, 239, 255, 255, 255, 255, 255, 255, 16, 0, 64, 128, 0, + 0, 0, 207, 255, 159, 159, 255, 255, 255, 255, 175, 223, 255, 191, 0, + 0, 96, 255, 207, 0, 0, 207, 255, 255, 255, 255, 255, 255, 191, 0, + 0, 223, 255, 239, 32, 32, 239, 255, 255, 255, 175, 223, 255, 191, 0, + 0, 143, 255, 255, 255, 255, 255, 255, 255, 255, 16, 0, 64, 128, 0, + 0, 0, 159, 255, 255, 255, 255, 255, 255, 255, 32, 0, 0, 0, 0, + 0, 0, 0, 112, 239, 255, 255, 255, 255, 255, 159, 64, 0, 0, 0, + 0, 0, 0, 0, 16, 96, 175, 223, 255, 255, 255, 191, 143, 64, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "art-gallery", { + 0, 0, 0, 0, 0, 0, 48, 223, 48, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 48, 223, 96, 223, 48, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 48, 223, 48, 0, 48, 223, 48, 0, 0, 0, 0, + 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 0, + 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, + 0, 255, 0, 0, 207, 207, 0, 0, 0, 0, 0, 0, 0, 255, 0, + 0, 255, 0, 0, 207, 207, 0, 0, 0, 128, 0, 0, 0, 255, 0, + 0, 255, 0, 0, 0, 0, 0, 0, 64, 255, 64, 0, 0, 255, 0, + 0, 255, 0, 0, 0, 64, 64, 0, 191, 255, 191, 0, 0, 255, 0, + 0, 255, 0, 0, 0, 191, 191, 64, 255, 255, 255, 64, 0, 255, 0, + 0, 255, 0, 0, 64, 255, 255, 223, 255, 255, 255, 191, 0, 255, 0, + 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, + 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "attraction", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 191, 255, 255, 255, 191, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 64, 255, 255, 255, 255, 255, 64, 0, 0, 0, 0, + 0, 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 128, 0, + 0, 255, 64, 255, 255, 207, 48, 0, 48, 207, 255, 255, 255, 255, 0, + 0, 255, 255, 255, 223, 16, 32, 128, 32, 16, 223, 255, 255, 255, 0, + 0, 255, 255, 255, 143, 0, 223, 255, 223, 0, 143, 255, 255, 255, 0, + 0, 255, 255, 255, 143, 0, 223, 255, 223, 0, 143, 255, 255, 255, 0, + 0, 255, 255, 255, 223, 16, 32, 128, 32, 16, 223, 255, 255, 255, 0, + 0, 255, 255, 255, 255, 207, 48, 0, 48, 207, 255, 255, 255, 255, 0, + 0, 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 128, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "bakery", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 64, 223, 255, 223, 64, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 191, 255, 255, 255, 191, 0, 0, 0, 0, 0, + 0, 0, 191, 255, 143, 128, 255, 255, 255, 128, 143, 255, 191, 0, 0, + 0, 0, 223, 255, 191, 112, 255, 255, 255, 112, 191, 255, 223, 0, 0, + 32, 64, 159, 255, 255, 64, 255, 255, 255, 64, 255, 255, 159, 64, 32, + 239, 175, 96, 255, 255, 80, 255, 255, 255, 80, 255, 255, 96, 191, 239, + 255, 223, 32, 255, 255, 48, 255, 255, 255, 48, 255, 255, 32, 239, 255, + 255, 207, 0, 0, 0, 0, 80, 128, 80, 0, 0, 0, 0, 207, 255, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "bank", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, + 255, 48, 48, 255, 255, 239, 80, 0, 80, 239, 255, 255, 48, 16, 255, + 255, 48, 255, 255, 255, 128, 0, 64, 0, 128, 255, 255, 255, 48, 255, + 255, 255, 255, 255, 255, 32, 64, 128, 64, 32, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 0, 64, 128, 64, 0, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 32, 112, 128, 32, 32, 255, 255, 255, 255, 255, + 255, 48, 255, 255, 255, 128, 64, 128, 80, 128, 255, 255, 255, 48, 255, + 255, 48, 48, 255, 255, 239, 80, 0, 80, 239, 255, 255, 48, 16, 255, + 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "bar", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 143, 191, 191, 239, 255, 255, 255, 255, 255, 239, 191, 191, 143, 0, + 0, 96, 239, 80, 16, 0, 0, 0, 0, 0, 16, 112, 255, 96, 0, + 0, 0, 96, 255, 255, 255, 255, 255, 255, 255, 255, 255, 96, 0, 0, + 0, 0, 0, 96, 255, 255, 255, 255, 255, 255, 255, 96, 0, 0, 0, + 0, 0, 0, 0, 96, 255, 255, 255, 255, 255, 96, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 96, 255, 255, 255, 96, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 96, 255, 96, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 48, 128, 255, 128, 48, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "beer", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 64, 175, 223, 255, 255, 255, 223, 175, 64, 0, 0, 0, + 0, 0, 0, 255, 96, 32, 0, 0, 0, 32, 96, 255, 0, 0, 0, + 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, + 0, 0, 0, 255, 96, 32, 0, 0, 0, 32, 96, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, 0, + 0, 0, 0, 159, 255, 255, 255, 255, 255, 255, 255, 159, 0, 0, 0, + 0, 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, 0, + 0, 0, 0, 0, 207, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, + 0, 0, 0, 0, 143, 255, 255, 255, 255, 255, 143, 0, 0, 0, 0, + 0, 0, 0, 0, 128, 128, 0, 0, 0, 128, 128, 0, 0, 0, 0, + 0, 0, 0, 0, 175, 64, 0, 0, 0, 64, 175, 0, 0, 0, 0, + 0, 0, 0, 0, 223, 191, 128, 128, 128, 191, 223, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 80, 128, 128, 128, 80, 16, 0, 0, 0, 0, + } }, + { "bicycle", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 223, 255, 239, 0, 0, 0, 0, 0, + 0, 0, 0, 223, 255, 223, 0, 0, 0, 255, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 159, 96, 0, 0, 96, 255, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 80, 207, 0, 128, 239, 191, 112, 0, 0, 0, 0, + 0, 80, 223, 255, 223, 255, 175, 207, 16, 128, 255, 255, 223, 80, 0, + 80, 207, 64, 0, 80, 255, 175, 16, 80, 207, 223, 64, 64, 207, 80, + 223, 64, 0, 48, 239, 159, 223, 0, 223, 64, 112, 159, 0, 64, 223, + 255, 0, 0, 207, 96, 0, 255, 0, 255, 0, 16, 223, 0, 0, 255, + 223, 64, 0, 0, 0, 64, 223, 0, 223, 64, 0, 0, 0, 64, 223, + 80, 207, 64, 0, 64, 207, 80, 0, 80, 207, 64, 0, 64, 207, 80, + 0, 80, 223, 255, 223, 80, 0, 0, 0, 80, 223, 255, 223, 80, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "bicycle-share", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 207, 207, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 207, 207, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 159, 239, 48, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 159, 255, 239, 207, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 159, 255, 239, 48, 159, 255, 223, 0, 0, 0, + 0, 0, 0, 0, 0, 159, 255, 96, 0, 0, 0, 0, 0, 0, 0, + 0, 143, 239, 239, 143, 0, 96, 239, 0, 0, 143, 239, 239, 143, 0, + 143, 159, 16, 16, 159, 143, 0, 255, 0, 143, 159, 16, 16, 159, 143, + 239, 16, 0, 0, 16, 239, 0, 255, 0, 239, 16, 0, 0, 16, 239, + 239, 16, 0, 0, 16, 239, 0, 223, 0, 239, 16, 0, 0, 16, 239, + 143, 159, 16, 16, 159, 143, 0, 0, 0, 143, 159, 16, 16, 159, 143, + 0, 143, 239, 239, 143, 0, 0, 0, 0, 0, 143, 239, 239, 143, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "bus", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 96, 239, 255, 255, 255, 255, 255, 255, 255, 239, 96, 0, 0, + 0, 0, 239, 255, 32, 0, 0, 0, 0, 0, 32, 255, 239, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 16, 0, 0, 0, 0, 0, 0, 0, 16, 255, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, + 0, 0, 255, 16, 0, 0, 0, 0, 0, 0, 0, 16, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 48, 48, 255, 255, 255, 255, 255, 48, 48, 255, 0, 0, + 0, 0, 255, 48, 48, 255, 255, 255, 255, 255, 48, 48, 255, 0, 0, + 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, + 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 255, 255, 0, 0, 0, + 0, 0, 0, 207, 207, 0, 0, 0, 0, 0, 207, 207, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "cafe", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 96, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 48, 239, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 48, 239, 0, + 0, 0, 175, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 96, 0, + 0, 0, 32, 239, 255, 255, 255, 255, 239, 32, 0, 0, 0, 0, 0, + 0, 0, 0, 32, 175, 239, 255, 175, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 223, 255, 255, 255, 255, 255, 255, 223, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "campsite", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 32, 223, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 191, 255, 191, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 80, 255, 255, 255, 80, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 239, 255, 128, 255, 239, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 143, 255, 159, 0, 159, 255, 143, 0, 0, 0, 0, + 0, 0, 0, 64, 255, 239, 16, 0, 16, 239, 255, 64, 0, 0, 0, + 0, 0, 0, 207, 255, 112, 0, 0, 0, 112, 255, 207, 0, 0, 0, + 0, 0, 112, 255, 207, 0, 0, 0, 0, 0, 207, 255, 112, 0, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "car", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 112, 255, 255, 255, 255, 255, 255, 255, 112, 0, 0, 0, + 0, 0, 16, 239, 64, 0, 0, 0, 0, 0, 64, 239, 16, 0, 0, + 0, 0, 128, 191, 0, 0, 0, 0, 0, 0, 0, 191, 128, 0, 0, + 0, 96, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 96, 0, + 0, 255, 16, 48, 96, 255, 255, 255, 255, 255, 96, 48, 16, 255, 0, + 0, 255, 16, 0, 16, 255, 255, 255, 255, 255, 16, 0, 16, 255, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 239, 255, 239, 0, 0, 0, 0, 0, 0, 0, 239, 255, 239, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "car-repair", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 96, 239, 239, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, + 0, 96, 239, 239, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 255, 255, 255, 255, 255, 191, 0, 0, 0, 0, + 0, 0, 0, 112, 191, 0, 0, 0, 0, 0, 191, 96, 0, 0, 0, + 0, 0, 48, 239, 48, 0, 0, 0, 0, 0, 48, 239, 48, 0, 0, + 0, 0, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, + 0, 0, 255, 32, 80, 191, 255, 255, 255, 191, 80, 48, 255, 0, 0, + 0, 0, 255, 128, 128, 191, 255, 255, 255, 191, 128, 128, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 239, 255, 112, 0, 0, 0, 0, 0, 112, 255, 239, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "castle", { + 0, 0, 0, 223, 0, 207, 207, 0, 207, 207, 0, 223, 0, 0, 0, + 0, 0, 0, 255, 0, 255, 255, 0, 255, 255, 0, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 48, 255, 255, 255, 255, 255, 255, 255, 48, 0, 0, 0, + 0, 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 128, 0, 128, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 143, 255, 255, 0, 0, 0, 255, 255, 143, 0, 0, 0, + 0, 0, 0, 191, 255, 255, 0, 0, 0, 255, 255, 191, 0, 0, 0, + 0, 0, 0, 239, 255, 255, 0, 0, 0, 255, 255, 239, 0, 0, 0, + 0, 0, 48, 255, 255, 255, 0, 0, 0, 255, 255, 255, 48, 0, 0, + 0, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 223, 0, + } }, + { "cemetery", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 80, 207, 255, 223, 80, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 32, 255, 255, 255, 255, 255, 32, 0, 0, 0, 0, + 0, 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 239, 0, 0, 0, + 0, 0, 0, 223, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, 0, + 0, 0, 0, 191, 128, 0, 0, 0, 0, 0, 128, 191, 0, 0, 0, + 0, 0, 0, 143, 255, 255, 255, 255, 255, 255, 255, 143, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, 0, + 0, 0, 0, 48, 255, 255, 255, 255, 255, 255, 255, 32, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 223, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, + 0, 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 239, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "charging-station", { + 0, 0, 0, 0, 0, 0, 0, 16, 112, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 191, 255, 64, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 112, 255, 239, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 48, 255, 255, 175, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 223, 255, 255, 96, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 175, 255, 255, 255, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 96, 255, 255, 255, 255, 255, 255, 255, 255, 112, 0, 0, + 0, 0, 48, 239, 255, 255, 255, 255, 255, 255, 255, 239, 48, 0, 0, + 0, 0, 112, 255, 255, 255, 255, 255, 255, 255, 255, 112, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 32, 255, 255, 255, 175, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 96, 255, 255, 223, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 175, 255, 255, 48, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 239, 255, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 48, 255, 191, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 112, 16, 0, 0, 0, 0, 0, 0, 0, + } }, + { "cinema", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 48, 191, 255, 191, 48, 0, 0, 0, 0, + 0, 0, 96, 239, 239, 96, 191, 128, 0, 128, 191, 0, 0, 0, 0, + 0, 0, 239, 48, 48, 239, 255, 0, 0, 0, 255, 0, 0, 0, 0, + 0, 0, 239, 48, 48, 239, 191, 128, 0, 128, 191, 0, 0, 0, 0, + 0, 0, 96, 239, 239, 96, 48, 191, 255, 191, 48, 0, 0, 0, 0, + 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 239, 0, 16, 223, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 16, 223, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 239, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "clothing-store", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 159, 255, 191, 0, 0, 0, 191, 255, 159, 0, 0, 0, + 0, 16, 191, 255, 255, 255, 64, 0, 64, 255, 255, 255, 191, 16, 0, + 0, 207, 255, 255, 255, 255, 191, 0, 191, 255, 255, 255, 255, 207, 0, + 0, 255, 255, 255, 255, 255, 255, 128, 255, 255, 255, 255, 255, 255, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "college", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 32, 128, 223, 128, 16, 0, 0, 0, 0, 0, + 0, 0, 0, 32, 159, 255, 255, 255, 255, 255, 159, 32, 0, 0, 0, + 0, 64, 159, 255, 255, 255, 255, 255, 255, 255, 255, 255, 159, 48, 0, + 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 128, + 0, 32, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 159, 48, 0, + 0, 0, 255, 32, 159, 255, 255, 255, 255, 255, 159, 32, 0, 0, 0, + 0, 128, 255, 143, 0, 32, 128, 223, 128, 16, 0, 96, 0, 0, 0, + 0, 255, 255, 255, 0, 96, 0, 0, 16, 112, 223, 255, 0, 0, 0, + 0, 128, 255, 159, 64, 255, 223, 159, 239, 255, 255, 255, 0, 0, 0, + 0, 64, 255, 48, 175, 255, 255, 255, 255, 255, 255, 239, 0, 0, 0, + 0, 159, 255, 159, 80, 255, 255, 255, 255, 255, 255, 143, 0, 0, 0, + 0, 255, 255, 255, 0, 255, 255, 255, 255, 255, 175, 16, 0, 0, 0, + 0, 191, 255, 191, 0, 48, 128, 128, 112, 32, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "confectionery", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 207, 207, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 207, 0, 0, + 0, 0, 0, 0, 0, 80, 207, 255, 207, 80, 255, 255, 255, 207, 0, + 0, 0, 0, 0, 80, 223, 128, 80, 64, 223, 255, 255, 255, 207, 0, + 0, 0, 0, 0, 223, 64, 0, 128, 0, 64, 207, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 0, 0, 128, 0, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 64, 0, 128, 0, 64, 223, 0, 0, 0, 0, + 0, 207, 255, 255, 255, 207, 64, 80, 128, 239, 80, 0, 0, 0, 0, + 0, 207, 255, 255, 255, 80, 207, 255, 207, 80, 0, 0, 0, 0, 0, + 0, 0, 207, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 207, 207, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "convenience", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 175, 191, 191, 191, 175, 0, 0, 0, 0, 0, + 0, 0, 0, 191, 255, 255, 255, 255, 255, 255, 255, 191, 0, 0, 0, + 0, 0, 16, 223, 0, 0, 0, 0, 0, 0, 0, 223, 16, 0, 0, + 0, 0, 80, 175, 0, 0, 0, 0, 0, 0, 0, 175, 64, 0, 0, + 0, 0, 128, 112, 0, 0, 0, 0, 0, 0, 0, 112, 128, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 0, 223, 143, 96, 255, 32, 255, 32, 255, 96, 143, 223, 0, 0, + 0, 0, 159, 191, 32, 255, 0, 255, 0, 255, 16, 191, 159, 0, 0, + 0, 0, 96, 207, 0, 255, 0, 255, 0, 255, 0, 223, 96, 0, 0, + 0, 0, 32, 255, 96, 255, 96, 255, 96, 255, 96, 255, 32, 0, 0, + 0, 0, 0, 191, 255, 255, 255, 255, 255, 255, 255, 191, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "dentist", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 80, 239, 255, 143, 0, 0, 0, 96, 239, 239, 96, 0, 0, + 0, 0, 207, 255, 255, 255, 112, 0, 112, 255, 255, 255, 223, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, + 0, 0, 191, 255, 255, 255, 255, 255, 255, 255, 255, 255, 191, 0, 0, + 0, 0, 80, 255, 255, 255, 255, 255, 255, 255, 255, 255, 80, 0, 0, + 0, 0, 0, 175, 255, 255, 207, 16, 191, 255, 255, 175, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 96, 0, 80, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 16, 0, 0, 239, 255, 128, 0, 0, 0, + 0, 0, 0, 64, 255, 191, 0, 0, 0, 159, 255, 128, 0, 0, 0, + 0, 0, 0, 64, 255, 112, 0, 0, 0, 96, 255, 112, 0, 0, 0, + 0, 0, 0, 32, 239, 16, 0, 0, 0, 0, 207, 48, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "doctor", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 128, 255, 96, 0, 96, 255, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 16, 0, 0, 0, 16, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 223, 64, 0, 0, 0, 64, 223, 0, 0, 0, 0, 0, 0, + 0, 0, 80, 207, 64, 0, 64, 207, 80, 16, 191, 255, 191, 16, 0, + 0, 0, 0, 96, 255, 255, 255, 96, 0, 112, 175, 0, 175, 112, 0, + 0, 0, 0, 0, 159, 255, 159, 0, 0, 112, 175, 0, 175, 112, 0, + 0, 0, 0, 0, 32, 255, 48, 0, 0, 16, 191, 255, 191, 16, 0, + 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 223, 64, 0, 0, 0, 64, 223, 0, 0, 0, + 0, 0, 0, 0, 0, 80, 207, 64, 0, 64, 207, 80, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 80, 223, 255, 223, 80, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "dog-park", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 96, 128, 48, 0, 0, 0, 80, 175, 0, 0, 0, 0, + 0, 0, 191, 255, 255, 112, 0, 0, 0, 143, 255, 128, 32, 0, 0, + 0, 0, 255, 143, 0, 0, 0, 0, 0, 191, 255, 255, 255, 64, 0, + 0, 0, 255, 128, 0, 0, 0, 0, 0, 191, 255, 191, 255, 239, 48, + 0, 0, 255, 191, 128, 128, 128, 128, 48, 16, 159, 255, 255, 255, 223, + 0, 0, 255, 255, 255, 255, 255, 255, 239, 80, 0, 112, 128, 128, 64, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 143, 0, 0, 0, 0, + 0, 32, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 80, 175, 255, 255, 255, 128, 128, 128, 191, 255, 255, 0, 0, 0, 0, + 255, 255, 223, 255, 255, 0, 0, 0, 64, 255, 255, 0, 0, 0, 0, + 255, 128, 0, 207, 255, 0, 0, 0, 0, 239, 255, 0, 0, 0, 0, + 255, 207, 0, 143, 255, 223, 0, 0, 0, 159, 255, 223, 0, 0, 0, + 112, 128, 0, 32, 128, 128, 0, 0, 0, 16, 128, 128, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "drinking-water", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 96, 239, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 0, 0, 255, 255, 255, 16, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 239, 255, 239, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 239, 255, 255, 255, 255, 255, 239, 0, 0, 0, 0, 0, 0, + 0, 0, 191, 64, 0, 0, 0, 64, 191, 0, 0, 0, 0, 0, 0, + 0, 0, 128, 128, 0, 0, 0, 128, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 64, 255, 255, 255, 255, 255, 64, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 191, 255, 255, 255, 191, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 112, 255, 255, 255, 112, 0, 0, 0, 0, 0, 0, 0, + } }, + { "embassy", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 80, 255, 80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 80, 255, 80, 0, 143, 255, 223, 96, 0, 16, 128, 239, 0, 0, + 0, 0, 96, 0, 159, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 0, 255, 255, 191, 223, 255, 255, 255, 255, 239, 0, 0, + 0, 0, 255, 0, 223, 48, 0, 0, 128, 239, 239, 159, 32, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 223, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "entrance", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 207, 207, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 207, 207, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 80, 80, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 0, 0, 143, 255, 255, 255, 207, + 0, 0, 0, 0, 0, 0, 255, 207, 0, 159, 255, 255, 255, 255, 207, + 0, 0, 0, 0, 0, 0, 207, 16, 159, 255, 255, 112, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 16, 159, 255, 255, 96, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 159, 255, 255, 96, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 159, 255, 255, 96, 0, 0, 0, 0, 0, 0, + 0, 0, 207, 255, 255, 255, 255, 96, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 207, 255, 255, 255, 96, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "fast-food", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 96, 239, 255, 255, 255, 255, 255, 255, 255, 239, 96, 0, 0, + 0, 0, 239, 255, 255, 64, 255, 255, 255, 64, 255, 255, 239, 0, 0, + 0, 0, 255, 64, 255, 255, 255, 64, 255, 255, 255, 64, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 0, 0, + 0, 0, 143, 255, 255, 255, 255, 255, 255, 255, 255, 255, 143, 0, 0, + 0, 0, 0, 143, 239, 255, 255, 255, 255, 255, 239, 143, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "ferry", { + 0, 0, 0, 0, 0, 207, 255, 255, 255, 207, 0, 0, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 255, 16, 0, 0, 0, 0, 0, 16, 255, 0, 0, 0, + 0, 0, 0, 255, 0, 0, 16, 96, 16, 0, 0, 255, 0, 0, 0, + 0, 0, 0, 255, 0, 96, 223, 255, 223, 96, 0, 255, 0, 0, 0, + 0, 0, 0, 255, 207, 255, 255, 255, 255, 255, 191, 255, 0, 0, 0, + 0, 16, 175, 255, 255, 255, 255, 255, 255, 255, 255, 255, 175, 16, 0, + 0, 64, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 64, 0, + 0, 0, 191, 255, 255, 255, 255, 255, 255, 255, 255, 255, 191, 0, 0, + 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, + 0, 0, 0, 191, 255, 255, 255, 255, 255, 255, 255, 191, 0, 0, 0, + 0, 0, 0, 48, 32, 0, 64, 175, 64, 0, 32, 32, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 239, 80, 32, 207, 255, 143, 0, 143, 255, 223, 48, 80, 239, 0, + 0, 64, 239, 239, 96, 16, 191, 255, 175, 16, 143, 255, 223, 48, 0, + } }, + { "fire-station", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 159, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 16, 112, 0, 0, 128, 255, 128, 0, 0, 112, 16, 0, 0, + 0, 0, 175, 239, 0, 48, 255, 255, 255, 48, 0, 239, 175, 0, 0, + 0, 64, 255, 255, 48, 207, 255, 255, 255, 207, 48, 255, 255, 64, 0, + 0, 159, 255, 255, 239, 255, 255, 223, 255, 255, 239, 255, 255, 159, 0, + 0, 239, 255, 255, 255, 255, 191, 0, 191, 255, 255, 255, 255, 239, 0, + 0, 255, 255, 255, 255, 239, 32, 0, 32, 239, 255, 255, 255, 255, 0, + 0, 239, 255, 255, 255, 128, 0, 0, 0, 128, 255, 255, 255, 239, 0, + 0, 159, 255, 255, 255, 32, 0, 0, 0, 32, 255, 255, 255, 159, 0, + 0, 64, 255, 255, 255, 16, 0, 0, 0, 16, 255, 255, 255, 64, 0, + 0, 0, 143, 255, 255, 128, 0, 0, 0, 128, 255, 255, 143, 0, 0, + 0, 0, 0, 143, 255, 255, 191, 128, 191, 255, 255, 143, 0, 0, 0, + 0, 0, 0, 0, 64, 159, 239, 255, 239, 159, 64, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "fitness-centre", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 128, 255, 0, 0, 0, 0, 0, 0, 0, 255, 128, 0, 0, + 0, 128, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 255, 128, 0, + 0, 128, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 255, 128, 0, + 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 128, + 0, 128, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 255, 128, 0, + 0, 128, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 255, 128, 0, + 0, 0, 128, 255, 0, 0, 0, 0, 0, 0, 0, 255, 128, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "florist", { + 0, 0, 0, 0, 32, 0, 0, 0, 0, 0, 16, 96, 16, 0, 0, + 0, 16, 159, 32, 128, 32, 159, 16, 0, 112, 159, 255, 159, 96, 0, + 0, 0, 128, 191, 223, 191, 128, 0, 64, 255, 239, 128, 239, 255, 48, + 0, 0, 112, 255, 255, 255, 112, 0, 0, 223, 128, 0, 128, 207, 0, + 0, 0, 16, 191, 255, 191, 16, 0, 64, 255, 207, 128, 239, 255, 48, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 175, 255, 143, 112, 0, + 0, 0, 0, 0, 143, 0, 0, 16, 112, 0, 16, 128, 16, 0, 0, + 0, 0, 0, 16, 143, 0, 16, 159, 16, 0, 0, 0, 0, 16, 0, + 0, 0, 0, 96, 48, 16, 159, 16, 0, 0, 16, 112, 128, 159, 0, + 0, 0, 128, 159, 16, 159, 16, 0, 0, 0, 175, 255, 191, 48, 0, + 0, 32, 255, 239, 159, 16, 48, 143, 143, 0, 255, 255, 223, 128, 32, + 0, 143, 255, 255, 239, 175, 96, 16, 0, 0, 175, 255, 191, 48, 0, + 0, 239, 255, 255, 255, 143, 0, 0, 0, 0, 16, 112, 128, 159, 0, + 96, 255, 239, 143, 32, 0, 0, 0, 0, 0, 0, 0, 0, 16, 0, + 143, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "fuel", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 207, 255, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 223, 0, + 0, 0, 255, 16, 0, 0, 0, 0, 16, 255, 0, 0, 0, 255, 0, + 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 255, 207, + 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 207, 255, + 0, 0, 255, 16, 0, 0, 0, 0, 16, 255, 0, 0, 0, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 128, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 16, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 255, 32, 255, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 128, 255, 128, + 0, 0, 207, 255, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "furniture", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 128, 255, 255, 255, 255, 255, 239, 16, 0, 0, 0, + 0, 0, 0, 16, 239, 255, 255, 255, 255, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 239, 16, 0, 0, + 0, 0, 16, 239, 255, 255, 255, 255, 255, 255, 255, 255, 128, 0, 0, + 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 16, 0, + 0, 0, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 64, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 191, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 255, 175, 0, 64, 175, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 255, 191, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 80, 207, 255, 239, 128, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 96, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, + 0, 0, 0, 16, 239, 255, 255, 255, 255, 255, 255, 96, 0, 0, 0, + 0, 0, 0, 48, 255, 255, 255, 255, 255, 255, 255, 159, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "garden", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 32, 223, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 223, 48, 0, 159, 255, 159, 0, 48, 223, 0, 0, 0, + 0, 0, 0, 255, 207, 64, 255, 255, 255, 64, 207, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 239, 255, 255, 255, 239, 255, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 191, 80, 0, 0, 255, 0, 0, 80, 191, 255, 0, 0, + 0, 0, 207, 255, 255, 159, 0, 255, 0, 159, 255, 255, 207, 0, 0, + 0, 0, 128, 255, 255, 255, 128, 255, 128, 255, 255, 255, 128, 0, 0, + 0, 0, 16, 239, 255, 255, 239, 255, 239, 255, 255, 239, 16, 0, 0, + 0, 0, 0, 48, 239, 255, 255, 255, 255, 255, 239, 48, 0, 0, 0, + 0, 0, 0, 0, 32, 159, 239, 255, 239, 159, 32, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "gift", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 191, 255, 223, 80, 0, 80, 223, 255, 191, 16, 0, 0, + 0, 0, 96, 175, 0, 64, 239, 64, 239, 64, 0, 175, 96, 0, 0, + 0, 0, 96, 175, 0, 0, 143, 255, 143, 0, 0, 175, 96, 0, 0, + 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 0, + 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 255, 255, 255, 128, 0, 128, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 128, 0, 128, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 128, 0, 128, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 128, 0, 128, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 128, 0, 128, 255, 255, 255, 255, 0, 0, + 0, 0, 128, 255, 255, 255, 128, 0, 128, 255, 255, 255, 128, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "golf", { + 0, 0, 0, 80, 175, 80, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 112, 175, 64, 128, 128, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 16, 128, 143, 80, 0, 0, 0, 0, + 0, 0, 0, 0, 128, 255, 128, 0, 0, 16, 223, 128, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 0, 80, 239, 175, 16, 0, 0, 0, + 0, 0, 0, 0, 128, 255, 143, 159, 239, 112, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 175, 255, 159, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 128, 255, 255, 32, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 32, 255, 255, 128, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 223, 239, 223, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 48, 255, 159, 255, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 143, 207, 128, 255, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 223, 112, 128, 255, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 16, 255, 32, 80, 239, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "grocery", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 128, 112, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 239, 255, 239, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 255, 32, 0, 0, + 0, 159, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 96, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 16, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 16, 207, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 96, 255, 0, 0, 0, + 0, 0, 80, 128, 128, 128, 128, 128, 128, 128, 207, 175, 0, 0, 0, + 0, 0, 175, 255, 255, 255, 255, 255, 255, 255, 191, 16, 0, 0, 0, + 0, 0, 0, 143, 239, 32, 0, 0, 143, 239, 32, 0, 0, 0, 0, + 0, 0, 0, 143, 239, 32, 0, 0, 143, 239, 32, 0, 0, 0, 0, + } }, + { "hairdresser", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 96, 239, 239, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 48, 16, + 239, 48, 48, 239, 0, 0, 0, 0, 0, 0, 0, 128, 239, 239, 80, + 255, 0, 0, 255, 16, 0, 0, 0, 0, 32, 207, 255, 191, 16, 0, + 239, 255, 255, 255, 239, 128, 16, 0, 112, 239, 239, 112, 0, 0, 0, + 0, 0, 0, 32, 128, 239, 239, 175, 255, 207, 32, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 96, 223, 96, 223, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 32, 128, 239, 239, 175, 255, 207, 32, 0, 0, 0, 0, + 239, 255, 255, 255, 239, 128, 16, 0, 112, 239, 239, 112, 0, 0, 0, + 255, 0, 0, 255, 16, 0, 0, 0, 0, 32, 207, 255, 191, 16, 0, + 239, 48, 48, 239, 0, 0, 0, 0, 0, 0, 0, 128, 239, 239, 80, + 96, 239, 239, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 48, 16, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "harbor", { + 0, 0, 0, 0, 0, 80, 223, 255, 223, 80, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 80, 255, 255, 255, 255, 255, 80, 0, 0, 0, 0, + 0, 0, 0, 0, 223, 255, 128, 0, 128, 255, 223, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 223, 255, 128, 0, 128, 255, 223, 0, 0, 0, 0, + 80, 80, 0, 0, 80, 255, 255, 255, 255, 255, 80, 0, 0, 80, 80, + 255, 255, 0, 0, 0, 80, 223, 255, 223, 80, 0, 0, 0, 255, 255, + 255, 255, 0, 0, 0, 0, 128, 255, 128, 0, 0, 0, 0, 255, 255, + 207, 255, 64, 0, 0, 0, 128, 255, 128, 0, 0, 0, 64, 255, 207, + 159, 255, 128, 0, 0, 0, 128, 255, 128, 0, 0, 0, 128, 255, 159, + 64, 255, 239, 16, 0, 0, 128, 255, 128, 0, 0, 16, 239, 255, 64, + 0, 191, 255, 175, 16, 0, 128, 255, 128, 0, 16, 207, 255, 191, 0, + 0, 32, 239, 255, 223, 96, 159, 255, 159, 96, 223, 255, 239, 32, 0, + 0, 0, 48, 207, 255, 255, 255, 255, 255, 255, 255, 207, 48, 0, 0, + 0, 0, 0, 0, 96, 175, 239, 255, 239, 175, 96, 0, 0, 0, 0, + } }, + { "hardware", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 112, 223, 255, 80, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 112, 255, 255, 96, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 239, 255, 143, 0, 0, 0, 80, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 255, 159, 0, 0, 96, 255, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 255, 239, 159, 143, 255, 223, 0, + 0, 0, 0, 0, 0, 0, 159, 255, 255, 255, 255, 255, 255, 112, 0, + 0, 0, 0, 0, 0, 159, 255, 255, 255, 255, 255, 239, 112, 0, 0, + 0, 0, 0, 0, 159, 255, 255, 255, 159, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 159, 255, 255, 255, 159, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 159, 255, 255, 255, 159, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 143, 255, 255, 255, 159, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 255, 255, 255, 159, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 143, 255, 143, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "hospital", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 207, 255, 207, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 207, 255, 207, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "ice-cream", { + 0, 0, 0, 0, 0, 16, 191, 255, 143, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 112, 255, 255, 255, 96, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 159, 255, 255, 255, 128, 16, 0, 0, 0, 0, + 0, 0, 0, 32, 239, 255, 255, 255, 255, 255, 239, 16, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 112, 0, 0, 0, + 0, 0, 0, 96, 255, 255, 255, 143, 255, 255, 255, 64, 0, 0, 0, + 0, 0, 0, 0, 96, 175, 80, 0, 112, 191, 80, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 16, 80, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 80, 255, 255, 255, 64, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 48, 255, 255, 255, 16, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 223, 255, 207, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 143, 255, 143, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 80, 255, 80, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 223, 16, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "information", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 128, 255, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 128, 255, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 207, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 159, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 16, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 16, 255, 255, 255, 16, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 159, 255, 255, 255, 159, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "jewelry-store", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 80, 255, 255, 255, 80, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 207, 255, 255, 255, 207, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 16, 175, 255, 175, 16, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 112, 16, 96, 16, 112, 16, 0, 0, 0, 0, + 0, 0, 0, 16, 207, 175, 32, 0, 32, 175, 207, 16, 0, 0, 0, + 0, 0, 0, 128, 175, 0, 0, 0, 0, 0, 175, 128, 0, 0, 0, + 0, 0, 0, 223, 32, 0, 0, 0, 0, 0, 32, 223, 0, 0, 0, + 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, + 0, 0, 0, 223, 32, 0, 0, 0, 0, 0, 32, 223, 0, 0, 0, + 0, 0, 0, 128, 175, 0, 0, 0, 0, 0, 175, 128, 0, 0, 0, + 0, 0, 0, 16, 207, 175, 32, 0, 32, 175, 207, 16, 0, 0, 0, + 0, 0, 0, 0, 16, 128, 223, 255, 223, 128, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "laundry", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 159, 255, 255, 255, 255, 207, 0, 0, + 0, 0, 0, 0, 0, 0, 159, 255, 32, 0, 32, 255, 255, 0, 0, + 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 207, 48, 0, 48, 207, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 223, 16, 0, 0, 0, 16, 223, 255, 255, 0, 0, + 0, 0, 255, 255, 143, 0, 0, 0, 0, 0, 143, 255, 255, 0, 0, + 0, 0, 255, 255, 143, 0, 0, 0, 0, 0, 143, 255, 255, 0, 0, + 0, 0, 255, 255, 223, 16, 0, 0, 0, 16, 223, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 207, 48, 0, 48, 207, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "library", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 16, 96, 128, 128, 16, 0, 16, 128, 128, 96, 16, 0, 0, + 0, 96, 255, 255, 255, 255, 207, 0, 207, 255, 255, 255, 255, 96, 0, + 0, 128, 255, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 128, 0, + 0, 175, 255, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 159, 0, + 0, 191, 255, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 191, 0, + 0, 191, 255, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 191, 0, + 0, 239, 255, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 239, 0, + 0, 223, 112, 48, 0, 16, 96, 0, 96, 16, 0, 48, 112, 223, 0, + 0, 0, 48, 143, 191, 159, 48, 0, 48, 159, 191, 143, 48, 0, 0, + 0, 191, 223, 143, 128, 191, 239, 128, 239, 191, 128, 143, 223, 191, 0, + 0, 96, 0, 0, 0, 16, 191, 191, 191, 16, 0, 0, 0, 96, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "lodging", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 255, 0, 128, 255, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 255, 0, 255, 255, 255, 32, 239, 255, 255, 255, 255, 255, 255, 191, 48, + 255, 0, 128, 255, 128, 128, 255, 255, 255, 255, 255, 255, 255, 255, 207, + 255, 0, 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, + 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, + 223, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 223, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "marker", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 159, 223, 255, 223, 159, 16, 0, 0, 0, 0, + 0, 0, 0, 16, 207, 255, 255, 255, 255, 255, 207, 16, 0, 0, 0, + 0, 0, 0, 159, 255, 255, 255, 255, 255, 255, 255, 159, 0, 0, 0, + 0, 0, 0, 223, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 223, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 16, 239, 255, 255, 255, 255, 255, 239, 16, 0, 0, 0, + 0, 0, 0, 0, 128, 255, 255, 255, 255, 255, 128, 0, 0, 0, 0, + 0, 0, 0, 0, 16, 223, 255, 255, 255, 223, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 64, 255, 255, 255, 64, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 143, 255, 143, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 16, 159, 16, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "mobile-phone", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 223, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 16, 0, 0, 0, 16, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 16, 0, 0, 0, 16, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "monument", { + 0, 0, 0, 0, 0, 0, 0, 143, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 112, 255, 96, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 239, 255, 239, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 128, 128, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 48, 239, 255, 255, 255, 255, 255, 207, 16, 0, 0, 0, + 0, 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + } }, + { "museum", { + 0, 0, 0, 0, 0, 16, 128, 223, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 96, 223, 255, 255, 255, 223, 96, 0, 0, 0, 0, + 0, 0, 80, 223, 255, 255, 255, 255, 255, 255, 255, 223, 64, 0, 0, + 0, 191, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 191, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 0, + 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 0, + 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 0, + 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 0, + 0, 80, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 80, 0, + 0, 223, 255, 255, 128, 255, 255, 128, 255, 255, 128, 255, 255, 223, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "music", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 48, 112, 175, 239, 0, 0, + 0, 0, 0, 0, 0, 16, 80, 159, 223, 207, 143, 80, 255, 0, 0, + 0, 0, 0, 112, 207, 239, 175, 96, 32, 0, 0, 0, 255, 0, 0, + 0, 0, 0, 255, 48, 0, 0, 0, 0, 48, 112, 175, 255, 0, 0, + 0, 0, 0, 255, 0, 16, 80, 159, 223, 207, 143, 80, 255, 0, 0, + 0, 0, 0, 255, 207, 239, 175, 96, 32, 0, 0, 0, 255, 0, 0, + 0, 0, 0, 255, 48, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, + 0, 0, 0, 255, 0, 0, 0, 0, 0, 16, 112, 112, 255, 0, 0, + 0, 0, 0, 255, 0, 0, 0, 0, 0, 191, 255, 255, 255, 0, 0, + 96, 239, 239, 255, 0, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, + 239, 255, 255, 255, 0, 0, 0, 0, 0, 191, 255, 255, 191, 0, 0, + 239, 255, 255, 239, 0, 0, 0, 0, 0, 16, 112, 112, 16, 0, 0, + 96, 239, 239, 96, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "nightclub", { + 0, 0, 0, 0, 112, 239, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 191, 207, 0, 143, 191, 48, 0, 175, 80, 0, 0, + 0, 0, 0, 0, 191, 191, 64, 255, 255, 191, 0, 255, 128, 0, 0, + 0, 0, 0, 0, 191, 223, 16, 207, 255, 96, 64, 255, 80, 0, 0, + 0, 48, 48, 0, 80, 255, 159, 0, 0, 48, 207, 239, 16, 0, 0, + 0, 143, 143, 0, 0, 159, 255, 239, 223, 255, 239, 48, 0, 0, 0, + 223, 255, 255, 223, 0, 0, 223, 255, 255, 207, 16, 0, 0, 0, 0, + 0, 143, 143, 0, 0, 0, 207, 255, 255, 128, 0, 0, 112, 112, 0, + 0, 48, 48, 0, 0, 48, 255, 255, 255, 128, 0, 112, 207, 207, 112, + 0, 0, 0, 0, 0, 207, 239, 64, 255, 128, 0, 112, 207, 207, 112, + 0, 0, 0, 0, 112, 255, 80, 0, 207, 191, 0, 0, 112, 112, 0, + 0, 0, 0, 0, 191, 207, 0, 0, 191, 191, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 175, 223, 0, 0, 191, 207, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 128, 255, 0, 0, 128, 255, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 112, 239, 0, 0, 112, 239, 0, 0, 0, 0, 0, + } }, + { "optician", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 16, 223, 239, 48, 0, 0, 0, 0, 0, 48, 239, 223, 16, 0, + 0, 128, 128, 48, 207, 0, 0, 0, 0, 0, 207, 48, 128, 128, 0, + 16, 223, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 223, 16, + 128, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 128, + 239, 255, 255, 255, 255, 255, 96, 0, 96, 255, 255, 255, 255, 255, 239, + 239, 255, 16, 0, 0, 16, 255, 191, 255, 16, 0, 0, 16, 255, 239, + 0, 255, 0, 0, 0, 0, 255, 64, 255, 0, 0, 0, 0, 255, 0, + 0, 207, 96, 0, 0, 96, 207, 0, 207, 96, 0, 0, 96, 207, 0, + 0, 48, 207, 255, 255, 207, 48, 0, 48, 207, 255, 255, 207, 48, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "park", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 175, 239, 48, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 175, 239, 255, 255, 239, 239, 64, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 255, 175, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 96, 0, + 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 0, 128, 255, 255, 255, 239, 255, 143, 128, 255, 255, 255, 16, 0, + 0, 0, 0, 128, 255, 128, 64, 255, 128, 0, 175, 255, 159, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 255, 143, 143, 64, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 255, 207, 16, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 255, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 255, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 80, 223, 255, 223, 80, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "parking", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 223, 80, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 80, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 128, 255, 223, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 128, 255, 223, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 80, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 255, 255, 255, 223, 80, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "pharmacy", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 239, 175, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 175, 255, 239, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 159, 255, 175, 48, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 159, 255, 207, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, 0, + 0, 0, 0, 96, 255, 255, 255, 0, 255, 255, 255, 143, 0, 0, 0, + 0, 0, 0, 0, 239, 255, 255, 0, 255, 255, 255, 80, 0, 0, 0, + 0, 0, 0, 0, 159, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 239, 255, 255, 0, 255, 255, 255, 80, 0, 0, 0, + 0, 0, 0, 96, 255, 255, 255, 0, 255, 255, 255, 143, 0, 0, 0, + 0, 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "picnic-site", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, 0, + 0, 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, 0, + 0, 0, 0, 0, 0, 223, 175, 0, 175, 223, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 48, 255, 96, 0, 96, 255, 48, 0, 0, 0, 0, + 0, 80, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 80, 0, + 0, 80, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 80, 0, + 0, 0, 0, 32, 255, 112, 0, 0, 0, 112, 255, 32, 0, 0, 0, + 0, 0, 0, 112, 255, 32, 0, 0, 0, 32, 255, 112, 0, 0, 0, + 0, 0, 0, 207, 207, 0, 0, 0, 0, 0, 207, 207, 0, 0, 0, + 0, 0, 0, 239, 112, 0, 0, 0, 0, 0, 112, 239, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "pitch", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 207, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 207, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 64, 191, 255, 255, 207, 112, 16, 0, 0, 0, + 0, 0, 0, 0, 159, 255, 255, 255, 64, 64, 159, 255, 0, 0, 0, + 0, 0, 223, 255, 255, 96, 191, 255, 191, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 255, 255, 32, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 159, 255, 255, 112, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 159, 239, 80, 191, 207, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 255, 32, 0, 64, 255, 255, 255, 223, 0, 0, + 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 223, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "place-of-worship", { + 0, 0, 0, 0, 0, 0, 48, 191, 16, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 48, 239, 255, 207, 16, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 128, 255, 255, 255, 128, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 128, 255, 255, 255, 128, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 112, 128, 128, 128, 80, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 159, 255, 255, 255, 255, 255, 96, 0, 0, 0, 0, + 0, 80, 80, 0, 128, 128, 128, 128, 128, 128, 128, 0, 80, 80, 0, + 0, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 0, + 0, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 0, + 0, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 0, + 0, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 0, + 0, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 0, + 0, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 0, 255, 255, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "playground", { + 0, 0, 0, 143, 255, 128, 0, 0, 64, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 0, 0, 64, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 159, 255, 128, 0, 0, 64, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 48, 128, 128, 175, 191, 223, 223, 0, 0, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 80, 64, 96, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 207, 255, 255, 64, 0, 64, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 143, 255, 255, 128, 0, 64, 64, 112, 64, 0, 0, 0, + 0, 0, 0, 80, 255, 255, 239, 207, 255, 255, 255, 239, 16, 0, 0, + 0, 0, 0, 32, 255, 255, 255, 255, 255, 207, 223, 255, 128, 0, 0, + 0, 0, 0, 0, 112, 128, 80, 48, 64, 0, 32, 255, 239, 16, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 159, 255, 128, 0, + 0, 0, 0, 0, 0, 64, 112, 159, 223, 223, 0, 32, 255, 239, 0, + 0, 0, 0, 0, 239, 207, 159, 112, 64, 0, 0, 0, 80, 80, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "police", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 64, 255, 255, 255, 255, 255, 64, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 128, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, + 0, 207, 207, 0, 0, 0, 223, 255, 255, 255, 223, 0, 0, 0, 0, + 0, 255, 255, 0, 0, 0, 128, 255, 255, 255, 128, 0, 0, 0, 0, + 0, 255, 255, 0, 0, 0, 0, 64, 128, 64, 0, 0, 0, 0, 0, + 0, 255, 255, 96, 143, 191, 255, 255, 255, 255, 128, 16, 0, 0, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 159, 16, 207, 0, 0, 0, + 0, 207, 239, 175, 128, 255, 255, 255, 207, 16, 207, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 255, 255, 223, 16, 159, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 255, 239, 48, 112, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 255, 80, 96, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 96, 48, 239, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "post", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 112, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 112, 0, + 0, 16, 175, 255, 255, 255, 255, 255, 255, 255, 255, 255, 175, 16, 0, + 0, 32, 0, 80, 239, 255, 255, 255, 255, 255, 239, 80, 0, 32, 0, + 0, 128, 207, 48, 16, 175, 255, 255, 255, 175, 16, 48, 207, 128, 0, + 0, 128, 255, 255, 128, 0, 80, 223, 80, 0, 128, 255, 255, 128, 0, + 0, 128, 255, 255, 255, 207, 48, 0, 48, 207, 255, 255, 255, 128, 0, + 0, 128, 255, 255, 255, 255, 255, 159, 255, 255, 255, 255, 255, 128, 0, + 0, 128, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 128, 0, + 0, 80, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 80, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "rail", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 207, 255, 255, 191, 128, 128, 128, 191, 255, 255, 207, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 16, 0, 0, 0, 255, 0, 0, 0, 16, 255, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, + 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, + 0, 0, 255, 16, 0, 0, 0, 255, 0, 0, 0, 16, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 48, 48, 255, 255, 255, 48, 48, 255, 255, 0, 0, + 0, 0, 255, 255, 48, 48, 255, 255, 255, 48, 48, 255, 255, 0, 0, + 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 16, 207, 0, 0, 0, 0, 0, 207, 16, 0, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 207, 32, 0, 0, 0, 0, 0, 32, 223, 0, 0, 0, + } }, + { "rail-metro", { + 0, 0, 0, 0, 64, 255, 255, 255, 255, 255, 64, 0, 0, 0, 0, + 0, 0, 0, 0, 159, 255, 143, 128, 143, 255, 159, 0, 0, 0, 0, + 0, 0, 0, 0, 239, 191, 0, 0, 0, 191, 223, 0, 0, 0, 0, + 0, 0, 0, 48, 255, 128, 0, 0, 0, 128, 255, 48, 0, 0, 0, + 0, 0, 0, 112, 255, 80, 0, 0, 0, 80, 255, 112, 0, 0, 0, + 0, 0, 0, 191, 255, 32, 0, 0, 0, 32, 255, 175, 0, 0, 0, + 0, 0, 0, 255, 255, 32, 0, 0, 0, 32, 255, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 255, 48, 48, 191, 128, 191, 48, 48, 255, 0, 0, 0, + 0, 0, 0, 255, 48, 48, 255, 255, 255, 48, 48, 255, 0, 0, 0, + 0, 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 16, 255, 112, 0, 0, 0, 112, 255, 16, 0, 0, 0, + 0, 0, 0, 112, 255, 255, 255, 255, 255, 255, 255, 112, 0, 0, 0, + 0, 0, 0, 207, 175, 0, 0, 0, 0, 0, 175, 207, 0, 0, 0, + } }, + { "recycling", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 16, 159, 223, 191, 16, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 128, 48, 0, 128, 191, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 207, 143, 143, 0, 0, 0, + 0, 0, 0, 112, 112, 0, 0, 0, 16, 175, 255, 223, 0, 0, 0, + 0, 64, 207, 255, 128, 0, 0, 0, 32, 159, 255, 255, 0, 0, 0, + 0, 96, 239, 255, 128, 0, 0, 0, 0, 0, 64, 191, 0, 0, 0, + 0, 32, 223, 143, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 159, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 96, 0, + 0, 239, 16, 0, 0, 0, 0, 0, 16, 48, 0, 0, 0, 191, 0, + 0, 223, 48, 0, 0, 0, 0, 96, 223, 128, 0, 0, 96, 175, 0, + 0, 48, 175, 143, 0, 0, 128, 255, 255, 255, 255, 255, 175, 16, 0, + 0, 0, 0, 0, 0, 0, 0, 96, 239, 128, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 16, 48, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "religious-buddhist", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 223, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 32, 32, 159, 223, 255, 223, 159, 32, 32, 0, 0, 0, + 0, 0, 32, 223, 223, 128, 32, 255, 32, 128, 223, 223, 32, 0, 0, + 0, 0, 32, 223, 223, 48, 0, 255, 0, 48, 223, 223, 32, 0, 0, + 0, 0, 159, 128, 48, 223, 48, 255, 48, 223, 48, 128, 159, 0, 0, + 0, 0, 223, 32, 0, 48, 223, 255, 223, 48, 0, 32, 223, 0, 0, + 0, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 223, 0, + 0, 0, 223, 32, 0, 48, 223, 255, 223, 48, 0, 32, 223, 0, 0, + 0, 0, 159, 128, 48, 223, 48, 255, 48, 223, 48, 128, 159, 0, 0, + 0, 0, 32, 223, 223, 48, 0, 255, 0, 48, 223, 223, 32, 0, 0, + 0, 0, 32, 223, 223, 128, 32, 255, 32, 128, 223, 223, 32, 0, 0, + 0, 0, 0, 32, 32, 159, 223, 255, 223, 159, 32, 32, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 223, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "religious-christian", { + 0, 0, 0, 0, 0, 0, 207, 255, 207, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + } }, + { "religious-jewish", { + 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 32, 223, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 159, 255, 159, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 64, 255, 255, 255, 64, 0, 0, 0, 0, 0, + 0, 175, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 175, 0, + 0, 16, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 223, 16, 0, + 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, + 0, 0, 0, 159, 255, 255, 255, 255, 255, 255, 255, 159, 0, 0, 0, + 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, + 0, 16, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 223, 16, 0, + 0, 175, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 175, 0, + 0, 0, 0, 0, 0, 64, 255, 255, 255, 64, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 159, 255, 159, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 32, 223, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 0, 0, 0, 0, + } }, + { "religious-muslim", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 64, 175, 239, 255, 239, 175, 64, 0, 0, 0, 0, + 0, 0, 0, 159, 255, 255, 255, 191, 128, 80, 128, 112, 0, 0, 0, + 0, 0, 159, 255, 255, 239, 64, 0, 0, 0, 0, 0, 16, 0, 0, + 0, 64, 255, 255, 255, 64, 0, 0, 0, 0, 16, 16, 0, 0, 0, + 0, 175, 255, 255, 175, 0, 0, 0, 0, 0, 112, 112, 0, 0, 0, + 0, 239, 255, 255, 96, 0, 0, 0, 80, 128, 223, 223, 128, 80, 0, + 0, 255, 255, 255, 64, 0, 0, 0, 16, 207, 255, 255, 207, 16, 0, + 0, 239, 255, 255, 96, 0, 0, 0, 0, 112, 255, 255, 112, 0, 0, + 0, 175, 255, 255, 175, 0, 0, 0, 0, 159, 96, 96, 159, 0, 0, + 0, 64, 255, 255, 255, 64, 0, 0, 0, 48, 0, 0, 48, 0, 0, + 0, 0, 159, 255, 255, 239, 64, 0, 0, 0, 0, 0, 16, 0, 0, + 0, 0, 0, 159, 255, 255, 255, 191, 128, 80, 128, 112, 0, 0, 0, + 0, 0, 0, 0, 64, 175, 239, 255, 239, 175, 64, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "restaurant", { + 0, 0, 0, 143, 64, 64, 143, 0, 0, 0, 96, 239, 0, 0, 0, + 0, 0, 0, 191, 64, 64, 191, 0, 0, 80, 255, 255, 0, 0, 0, + 0, 0, 0, 191, 112, 112, 191, 0, 0, 191, 255, 255, 0, 0, 0, + 0, 0, 32, 191, 128, 128, 191, 32, 0, 239, 255, 255, 0, 0, 0, + 0, 0, 80, 255, 207, 207, 255, 80, 0, 255, 255, 255, 0, 0, 0, + 0, 0, 128, 255, 255, 255, 255, 112, 0, 255, 255, 255, 0, 0, 0, + 0, 0, 16, 159, 255, 255, 143, 0, 0, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 207, 191, 0, 0, 0, 255, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 191, 191, 0, 0, 0, 64, 191, 255, 0, 0, 0, + 0, 0, 0, 0, 191, 191, 0, 0, 0, 0, 143, 255, 0, 0, 0, + 0, 0, 0, 0, 207, 207, 0, 0, 0, 0, 191, 255, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 191, 255, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 223, 255, 0, 0, 0, + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 255, 255, 0, 0, 0, + 0, 0, 0, 0, 207, 207, 0, 0, 0, 0, 207, 207, 0, 0, 0, + } }, + { "school", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 112, 223, 159, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 191, 255, 223, 0, 255, 207, 48, 0, 0, 0, 0, 0, + 0, 0, 0, 32, 96, 80, 0, 207, 255, 207, 0, 0, 0, 0, 0, + 0, 0, 80, 207, 143, 32, 0, 48, 207, 255, 0, 0, 0, 0, 0, + 0, 0, 143, 255, 255, 16, 112, 143, 96, 0, 96, 159, 96, 0, 0, + 0, 0, 207, 255, 191, 48, 255, 255, 255, 255, 255, 255, 255, 128, 0, + 0, 32, 255, 255, 128, 112, 255, 255, 255, 255, 255, 255, 255, 223, 0, + 0, 96, 255, 255, 64, 191, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 159, 255, 239, 0, 207, 255, 255, 255, 255, 255, 255, 255, 207, 0, + 0, 239, 255, 175, 0, 128, 255, 255, 255, 255, 255, 255, 255, 128, 0, + 0, 207, 255, 80, 0, 16, 207, 255, 255, 255, 255, 255, 207, 16, 0, + 0, 143, 96, 0, 0, 0, 16, 191, 255, 191, 255, 191, 16, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "shoe", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 239, 64, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 239, 159, 0, 0, 255, 239, 16, 0, 0, 0, 0, 0, 0, 0, + 0, 255, 255, 255, 255, 255, 255, 191, 0, 0, 0, 0, 0, 0, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 175, 16, 0, 0, 0, 0, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 239, 159, 80, 0, 0, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 96, 0, + 0, 0, 0, 0, 0, 0, 0, 48, 159, 239, 255, 255, 255, 255, 0, + 0, 255, 255, 255, 255, 255, 255, 175, 48, 0, 0, 0, 0, 0, 0, + 0, 239, 255, 255, 255, 239, 16, 112, 223, 255, 255, 255, 255, 239, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "shop", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 96, 223, 255, 255, 255, 223, 96, 0, 0, 0, 0, + 0, 0, 0, 16, 239, 64, 0, 0, 0, 48, 239, 16, 0, 0, 0, + 0, 0, 0, 64, 191, 0, 0, 0, 0, 0, 191, 64, 0, 0, 0, + 0, 0, 0, 112, 143, 0, 0, 0, 0, 0, 143, 112, 0, 0, 0, + 0, 191, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 191, 0, + 0, 143, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 143, 0, + 0, 64, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 64, 0, + 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 0, 0, + 0, 0, 159, 255, 255, 255, 255, 255, 255, 255, 255, 255, 159, 0, 0, + 0, 0, 96, 255, 255, 255, 255, 255, 255, 255, 255, 255, 96, 0, 0, + 0, 0, 16, 255, 255, 255, 255, 255, 255, 255, 255, 255, 16, 0, 0, + 0, 0, 0, 128, 255, 255, 255, 255, 255, 255, 255, 128, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "stadium", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 191, 96, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 255, 223, 112, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 223, 96, 16, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 96, 175, 223, 255, 255, 255, 223, 175, 96, 0, 0, 0, + 0, 0, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 96, 175, 255, 255, 255, 255, 255, 175, 96, 255, 0, 0, + 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, + 0, 0, 255, 16, 0, 255, 0, 0, 0, 255, 0, 16, 255, 0, 0, + 0, 0, 255, 207, 96, 255, 0, 0, 0, 255, 96, 207, 255, 0, 0, + 0, 0, 80, 223, 255, 255, 255, 255, 255, 255, 255, 223, 80, 0, 0, + 0, 0, 0, 0, 48, 96, 128, 128, 128, 96, 48, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "swimming", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 64, 191, 239, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 64, 191, 255, 255, 128, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 175, 32, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 175, 255, 96, 0, 16, 207, 255, 96, 0, + 0, 0, 0, 0, 0, 0, 80, 255, 239, 48, 128, 255, 255, 255, 0, + 0, 0, 0, 0, 64, 191, 255, 255, 255, 207, 96, 255, 255, 207, 0, + 0, 0, 0, 143, 255, 255, 239, 159, 239, 255, 159, 80, 128, 16, 0, + 0, 0, 0, 16, 159, 175, 32, 0, 32, 175, 96, 0, 0, 0, 0, + 0, 112, 223, 96, 0, 0, 96, 223, 112, 0, 16, 159, 191, 64, 0, + 207, 239, 159, 255, 191, 191, 255, 159, 239, 207, 207, 207, 191, 255, 191, + 175, 32, 0, 32, 191, 191, 32, 0, 32, 175, 159, 16, 0, 64, 191, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "taxi", { + 0, 0, 0, 0, 64, 128, 64, 0, 64, 128, 64, 0, 0, 0, 0, + 0, 0, 0, 0, 128, 255, 128, 0, 128, 255, 128, 0, 0, 0, 0, + 0, 0, 64, 128, 128, 128, 128, 128, 128, 128, 128, 128, 64, 0, 0, + 0, 0, 128, 255, 128, 0, 128, 255, 128, 0, 128, 255, 128, 0, 0, + 0, 0, 64, 128, 64, 0, 64, 128, 64, 0, 64, 128, 64, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 207, 255, 255, 255, 255, 255, 207, 0, 0, 0, 0, + 0, 0, 0, 112, 191, 0, 0, 0, 0, 0, 191, 112, 0, 0, 0, + 0, 0, 48, 239, 48, 0, 0, 0, 0, 0, 48, 239, 48, 0, 0, + 0, 0, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, + 0, 0, 255, 32, 80, 191, 255, 255, 255, 191, 80, 48, 255, 0, 0, + 0, 0, 255, 128, 128, 191, 255, 255, 255, 191, 128, 128, 255, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 239, 255, 112, 0, 0, 0, 0, 0, 112, 255, 239, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "theatre", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 207, 223, 128, 16, 16, 112, 223, 207, 0, 0, 0, 0, 0, 0, + 0, 255, 255, 255, 255, 239, 143, 143, 239, 0, 0, 0, 0, 0, 0, + 0, 255, 48, 48, 255, 96, 0, 0, 16, 0, 0, 0, 0, 0, 0, + 0, 255, 48, 48, 255, 0, 207, 223, 128, 16, 16, 112, 223, 207, 0, + 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 255, 255, 223, 143, 0, 255, 48, 48, 255, 255, 48, 48, 255, 0, + 0, 255, 239, 16, 0, 0, 255, 48, 48, 255, 255, 48, 48, 255, 0, + 0, 223, 159, 96, 207, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 128, 255, 255, 255, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 143, 239, 255, 0, 255, 143, 0, 0, 0, 0, 143, 255, 0, + 0, 0, 0, 0, 0, 0, 207, 239, 16, 0, 0, 16, 239, 207, 0, + 0, 0, 0, 0, 0, 0, 80, 255, 223, 143, 143, 223, 255, 80, 0, + 0, 0, 0, 0, 0, 0, 0, 80, 207, 255, 255, 207, 80, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "toilet", { + 0, 0, 0, 128, 255, 128, 0, 0, 0, 0, 128, 255, 128, 0, 0, + 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 0, 0, + 0, 0, 0, 128, 255, 128, 0, 0, 0, 0, 128, 255, 128, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 143, 255, 255, 255, 143, 0, 0, 239, 255, 255, 255, 239, 0, + 0, 32, 255, 255, 255, 255, 255, 32, 0, 255, 255, 255, 255, 255, 0, + 0, 191, 207, 255, 255, 255, 207, 191, 0, 255, 255, 255, 255, 255, 0, + 64, 223, 207, 255, 255, 255, 191, 223, 64, 255, 255, 255, 255, 255, 0, + 48, 80, 255, 255, 255, 255, 255, 80, 48, 255, 255, 255, 255, 255, 0, + 0, 112, 255, 255, 255, 255, 255, 112, 0, 223, 255, 128, 255, 223, 0, + 0, 207, 255, 255, 255, 255, 255, 207, 0, 0, 255, 0, 255, 0, 0, + 0, 0, 0, 255, 0, 255, 0, 0, 0, 0, 255, 0, 255, 0, 0, + 0, 0, 0, 255, 0, 255, 0, 0, 0, 0, 255, 0, 255, 0, 0, + 0, 0, 0, 223, 0, 223, 0, 0, 0, 0, 223, 0, 223, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "town-hall", { + 0, 0, 0, 0, 0, 0, 80, 223, 80, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 239, 128, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 207, 0, 207, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 159, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 96, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, + 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 0, + 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 0, + 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 0, + 0, 80, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 255, 80, 0, + 0, 239, 255, 255, 128, 255, 255, 128, 255, 255, 128, 255, 255, 239, 0, + 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "veterinary", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 80, 159, 16, 0, 16, 159, 96, 0, 0, 0, 0, + 0, 0, 0, 0, 191, 255, 159, 0, 159, 255, 191, 0, 0, 0, 0, + 0, 0, 0, 0, 159, 255, 191, 0, 191, 255, 159, 0, 0, 0, 0, + 0, 48, 175, 16, 16, 159, 96, 0, 96, 159, 16, 16, 175, 48, 0, + 0, 191, 255, 175, 0, 0, 0, 0, 0, 0, 0, 159, 255, 191, 0, + 0, 159, 255, 191, 0, 48, 223, 255, 223, 48, 0, 191, 255, 175, 0, + 0, 16, 175, 48, 16, 239, 255, 255, 255, 239, 16, 48, 175, 16, 0, + 0, 0, 0, 0, 96, 255, 255, 255, 255, 255, 96, 0, 0, 0, 0, + 0, 0, 0, 32, 191, 255, 255, 255, 255, 255, 175, 32, 0, 0, 0, + 0, 0, 96, 239, 255, 255, 255, 255, 255, 255, 255, 239, 96, 0, 0, + 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 0, 0, + 0, 0, 223, 255, 255, 255, 255, 191, 255, 255, 255, 255, 239, 0, 0, + 0, 0, 64, 223, 223, 96, 0, 0, 0, 96, 223, 239, 96, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "viewpoint", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 48, 143, 96, 0, 96, 143, 48, 0, 0, 0, 0, + 0, 0, 0, 0, 96, 255, 143, 0, 143, 255, 96, 0, 0, 0, 0, + 0, 48, 223, 16, 0, 223, 191, 0, 191, 223, 0, 16, 223, 48, 0, + 0, 175, 255, 207, 0, 128, 223, 0, 223, 128, 0, 191, 255, 175, 0, + 0, 0, 128, 255, 143, 32, 255, 0, 255, 32, 143, 255, 128, 0, 0, + 0, 0, 0, 80, 239, 80, 175, 64, 175, 80, 239, 64, 0, 0, 0, + 0, 0, 0, 0, 32, 143, 0, 0, 0, 143, 32, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 128, 255, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 128, 255, 128, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "waste-basket", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 239, 255, 255, 255, 239, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0, 0, + 0, 0, 223, 255, 255, 255, 255, 255, 255, 255, 255, 255, 223, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 96, 255, 255, 255, 255, 255, 255, 255, 255, 255, 96, 0, 0, + 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, + 0, 0, 16, 255, 255, 255, 255, 255, 255, 255, 255, 255, 16, 0, 0, + 0, 0, 0, 239, 255, 255, 255, 255, 255, 255, 255, 239, 0, 0, 0, + 0, 0, 0, 191, 255, 255, 255, 255, 255, 255, 255, 191, 0, 0, 0, + 0, 0, 0, 143, 255, 255, 255, 255, 255, 255, 255, 143, 0, 0, 0, + 0, 0, 0, 112, 255, 255, 255, 255, 255, 255, 255, 112, 0, 0, 0, + 0, 0, 0, 64, 255, 255, 255, 255, 255, 255, 255, 64, 0, 0, 0, + 0, 0, 0, 16, 255, 255, 255, 255, 255, 255, 255, 16, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, + { "zoo", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 48, 128, 112, 16, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 80, 255, 255, 255, 207, 16, 0, + 0, 0, 0, 0, 0, 0, 0, 48, 239, 255, 255, 255, 255, 207, 16, + 0, 0, 16, 191, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 207, + 0, 16, 207, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, + 16, 159, 191, 255, 255, 255, 255, 255, 255, 255, 255, 255, 239, 0, 0, + 207, 16, 159, 255, 255, 191, 159, 239, 255, 255, 255, 223, 64, 0, 0, + 207, 0, 255, 159, 255, 48, 0, 0, 112, 255, 255, 0, 0, 0, 0, + 0, 64, 239, 16, 255, 0, 0, 0, 64, 255, 255, 0, 0, 0, 0, + 0, 48, 223, 16, 223, 48, 0, 0, 16, 239, 255, 112, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + } }, +}; + +constexpr int POI_ICON_COUNT = sizeof(POI_ICON_MASKS) / sizeof(POI_ICON_MASKS[0]); + +} diff --git a/Apps/Maps/main/Source/render/PoiTypes.cpp b/Apps/Maps/main/Source/render/PoiTypes.cpp new file mode 100644 index 0000000..9a1fad9 --- /dev/null +++ b/Apps/Maps/main/Source/render/PoiTypes.cpp @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "PoiTypes.h" + +#include + +namespace maps { + +namespace { + +constexpr uint8_t Y = 1; +constexpr uint8_t N = 0; + +#define MAPS_POI_NAME(name, file) file, +const char* const ICON_NAMES[] = { MAPS_POI_ICONS(MAPS_POI_NAME) }; +#undef MAPS_POI_NAME + +// A subclass entry (S) wins over a class entry (C); the class entries cover every class that the tiles use. +#define S(key, icon, label, group, zoom, unnamed) { key, false, PoiIcon::icon, label, PoiGroup::group, zoom, unnamed == Y } +#define C(key, icon, label, group, zoom, unnamed) { key, true, PoiIcon::icon, label, PoiGroup::group, zoom, unnamed == Y } + +// Entry n of the table is type id n + 1. Never reorder: ids are stored in prepared tiles (raise the format version). +constexpr PoiType TYPES[] = { + // Subclasses + S("bus_stop", Bus, "Bus stop", Transport, 15, Y), + S("bus_station", Bus, "Bus station", Transport, 15, N), + S("tram_stop", Rail, "Tram stop", Transport, 15, Y), + S("station", Rail, "Railway station", Transport, 14, N), + S("halt", Rail, "Railway halt", Transport, 15, N), + S("subway", RailMetro, "Metro station", Transport, 14, N), + S("subway_entrance", RailMetro, "Metro entrance", Transport, 16, N), + S("bicycle_rental", BicycleShare, "Bicycle rental", Transport, 15, Y), + S("charging_station", ChargingStation, "Charging station", Transport, 15, Y), + S("car_repair", CarRepair, "Car repair", Transport, 15, N), + S("car_parts", CarRepair, "Car parts", Shop, 15, N), + S("taxi", Taxi, "Taxi", Transport, 15, N), + S("ferry_terminal", Ferry, "Ferry", Transport, 15, N), + S("supermarket", Grocery, "Supermarket", Shop, 15, N), + S("convenience", Convenience, "Convenience store", Shop, 15, N), + S("kiosk", Convenience, "Kiosk", Shop, 15, N), + S("bakery", Bakery, "Bakery", Food, 15, N), + S("butcher", Grocery, "Butcher", Food, 15, N), + S("greengrocer", Grocery, "Greengrocer", Food, 15, N), + S("deli", Grocery, "Deli", Food, 15, N), + S("confectionery", Confectionery, "Sweets", Food, 15, N), + S("chocolate", Confectionery, "Chocolate", Food, 15, N), + S("marketplace", Grocery, "Market", Shop, 15, N), + S("department_store", Shop, "Department store", Shop, 15, N), + S("mall", Shop, "Shopping centre", Shop, 15, N), + S("clothes", ClothingStore, "Clothes", Shop, 15, N), + S("bag", ClothingStore, "Bags", Shop, 15, N), + S("shoes", Shoe, "Shoes", Shop, 15, N), + S("jewelry", Jewelry, "Jewellery", Shop, 15, N), + S("beauty", Hairdresser, "Beauty", Shop, 15, N), + S("hairdresser", Hairdresser, "Hairdresser", Shop, 15, N), + S("mobile_phone", MobilePhone, "Phones", Shop, 15, N), + S("florist", Florist, "Florist", Shop, 15, N), + S("optician", Optician, "Optician", Health, 15, N), + S("gift", Gift, "Gifts", Shop, 15, N), + S("furniture", Furniture, "Furniture", Shop, 15, N), + S("hardware", Hardware, "Hardware", Shop, 15, N), + S("doityourself", Hardware, "DIY store", Shop, 15, N), + S("books", Library, "Bookshop", Shop, 15, N), + S("chemist", Pharmacy, "Drugstore", Shop, 15, N), + S("pub", Beer, "Pub", Food, 15, N), + S("biergarten", Beer, "Beer garden", Food, 15, N), + S("nightclub", Nightclub, "Nightclub", Food, 15, N), + S("hotel", Lodging, "Hotel", Lodging, 15, N), + S("hostel", Lodging, "Hostel", Lodging, 15, N), + S("guest_house", Lodging, "Guest house", Lodging, 15, N), + S("motel", Lodging, "Motel", Lodging, 15, N), + S("camp_site", Campsite, "Campsite", Lodging, 15, N), + S("clinic", Hospital, "Clinic", Health, 15, N), + S("nursing_home", Hospital, "Nursing home", Health, 15, N), + S("kindergarten", School, "Kindergarten", Education, 15, N), + S("university", College, "University", Education, 14, N), + S("post_office", Post, "Post office", Public, 15, N), + S("post_box", Post, "Post box", Public, 16, Y), + S("parcel_locker", Post, "Parcel locker", Public, 16, Y), + S("artwork", ArtGallery, "Artwork", Culture, 16, N), + S("gallery", ArtGallery, "Gallery", Culture, 15, N), + S("arts_centre", ArtGallery, "Arts centre", Culture, 15, N), + S("viewpoint", Viewpoint, "Viewpoint", Culture, 15, N), + S("community_centre", TownHall, "Community centre", Public, 15, N), + S("courthouse", TownHall, "Court", Public, 15, N), + S("government", TownHall, "Government office", Public, 15, N), + S("diplomatic", Embassy, "Embassy", Public, 15, N), + S("christian", Christian, "Church", Worship, 15, N), + S("jewish", Jewish, "Synagogue", Worship, 15, N), + S("muslim", Muslim, "Mosque", Worship, 15, N), + S("buddhist", Buddhist, "Temple", Worship, 15, N), + S("board", Information, "Information board", Other, 16, Y), + S("guidepost", Information, "Guidepost", Other, 16, Y), + S("map", Information, "Map", Other, 16, Y), + S("route_marker", Information, "Route marker", Other, 16, Y), + S("terminal", Information, "Information terminal", Other, 16, Y), + S("swimming_pool", Swimming, "Swimming pool", Leisure, 15, N), + S("sports_centre", Fitness, "Sports centre", Leisure, 15, N), + S("water_park", Swimming, "Water park", Leisure, 15, N), + S("theme_park", AmusementPark, "Theme park", Leisure, 14, N), + // Classes + C("shop", Shop, "Shop", Shop, 15, N), + C("town_hall", TownHall, "Town hall", Public, 15, N), + C("golf", Golf, "Golf", Leisure, 15, N), + C("fast_food", FastFood, "Fast food", Food, 15, N), + C("park", Park, "Park", Leisure, 15, N), + C("bus", Bus, "Bus stop", Transport, 15, Y), + C("railway", Rail, "Railway", Transport, 14, N), + C("aerialway", Aerialway, "Aerialway", Transport, 15, N), + C("entrance", Entrance, "Entrance", Transport, 16, N), + C("campsite", Campsite, "Campsite", Lodging, 15, N), + C("laundry", Laundry, "Laundry", Shop, 15, N), + C("grocery", Grocery, "Grocery", Shop, 15, N), + C("library", Library, "Library", Education, 15, N), + C("college", College, "College", Education, 15, N), + C("lodging", Lodging, "Lodging", Lodging, 15, N), + C("ice_cream", IceCream, "Ice cream", Food, 15, N), + C("post", Post, "Post", Public, 15, N), + C("cafe", Cafe, "Cafe", Food, 15, N), + C("school", School, "School", Education, 15, N), + C("alcohol_shop", AlcoholShop, "Alcohol", Shop, 15, N), + C("bar", Bar, "Bar", Food, 15, N), + C("harbor", Harbor, "Harbour", Transport, 15, N), + C("car", Car, "Car", Transport, 15, N), + C("hospital", Hospital, "Hospital", Health, 14, N), + C("cemetery", Cemetery, "Cemetery", Worship, 15, N), + C("attraction", Attraction, "Attraction", Culture, 14, N), + C("beer", Beer, "Pub", Food, 15, N), + C("music", Music, "Music", Shop, 15, N), + C("stadium", Stadium, "Stadium", Leisure, 15, N), + C("art_gallery", ArtGallery, "Art gallery", Culture, 15, N), + C("clothing_store", ClothingStore, "Clothes", Shop, 15, N), + C("swimming", Swimming, "Swimming", Leisure, 15, N), + C("castle", Castle, "Castle", Culture, 14, N), + C("atm", Bank, "ATM", Money, 15, Y), + C("fuel", Fuel, "Fuel", Transport, 15, N), + C("restaurant", Restaurant, "Restaurant", Food, 15, N), + C("pharmacy", Pharmacy, "Pharmacy", Health, 15, N), + C("bank", Bank, "Bank", Money, 15, N), + C("place_of_worship", PlaceOfWorship, "Place of worship", Worship, 15, N), + C("museum", Museum, "Museum", Culture, 14, N), + C("theatre", Theatre, "Theatre", Culture, 15, N), + C("cinema", Cinema, "Cinema", Culture, 15, N), + C("police", Police, "Police", Public, 15, N), + C("fire_station", FireStation, "Fire station", Public, 15, N), + C("toilets", Toilet, "Toilets", Public, 16, Y), + C("drinking_water", DrinkingWater, "Drinking water", Public, 16, Y), + C("playground", Playground, "Playground", Leisure, 15, Y), + C("parking", Parking, "Parking", Transport, 15, Y), + C("bicycle", Bicycle, "Bicycle shop", Shop, 15, N), + C("bicycle_rental", BicycleShare, "Bicycle rental", Transport, 15, Y), + C("information", Information, "Information", Other, 15, Y), + C("zoo", Zoo, "Zoo", Leisure, 14, N), + C("aquarium", Aquarium, "Aquarium", Leisure, 15, N), + C("dentist", Dentist, "Dentist", Health, 15, N), + C("doctors", Doctor, "Doctor", Health, 15, N), + C("veterinary", Veterinary, "Veterinary", Health, 15, N), + C("hairdresser", Hairdresser, "Hairdresser", Shop, 15, N), + C("garden", Garden, "Garden", Leisure, 15, N), + C("dog_park", DogPark, "Dog park", Leisure, 15, N), + C("picnic_site", PicnicSite, "Picnic site", Leisure, 15, N), + C("pitch", Pitch, "Sports pitch", Leisure, 15, N), + C("sports_centre", Fitness, "Sports centre", Leisure, 15, N), + C("swimming_pool", Swimming, "Swimming pool", Leisure, 15, N), + C("butcher", Grocery, "Butcher", Food, 15, N), + C("ferry_terminal", Ferry, "Ferry", Transport, 15, N), + C("theme_park", AmusementPark, "Theme park", Leisure, 14, N), + C("water_park", Swimming, "Water park", Leisure, 15, N), + C("waste_basket", WasteBasket, "Waste basket", Other, 16, N), + C("recycling", Recycling, "Recycling", Other, 16, N), + C("monument", Monument, "Monument", Culture, 15, N), +}; + +#undef S +#undef C + +constexpr int TYPE_COUNT = static_cast(sizeof(TYPES) / sizeof(TYPES[0])); +static_assert(TYPE_COUNT <= 255, "type ids are stored in a byte"); + +constexpr PoiType UNKNOWN = { "", false, PoiIcon::Marker, "", PoiGroup::Other, 255, false }; + +bool matches(const char* key, const char* text, size_t length) { + return strlen(key) == length && memcmp(key, text, length) == 0; +} + +} // namespace + +const char* poiIconName(PoiIcon icon) { + const auto index = static_cast(icon); + return index < static_cast(PoiIcon::Count) ? ICON_NAMES[index] : ICON_NAMES[0]; +} + +uint8_t poiTypeFor(const char* classText, size_t classLength, const char* subclassText, size_t subclassLength) { + if (subclassText != nullptr && subclassLength > 0) { + for (int i = 0; i < TYPE_COUNT; i++) { + if (!TYPES[i].isClass && matches(TYPES[i].key, subclassText, subclassLength)) { + return static_cast(i + 1); + } + } + } + if (classText != nullptr && classLength > 0) { + for (int i = 0; i < TYPE_COUNT; i++) { + if (TYPES[i].isClass && matches(TYPES[i].key, classText, classLength)) { + return static_cast(i + 1); + } + } + } + return 0; +} + +const PoiType& poiType(uint8_t id) { + return id >= 1 && id <= TYPE_COUNT ? TYPES[id - 1] : UNKNOWN; +} + +int poiTypeCount() { + return TYPE_COUNT; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/PoiTypes.h b/Apps/Maps/main/Source/render/PoiTypes.h new file mode 100644 index 0000000..546e3e2 --- /dev/null +++ b/Apps/Maps/main/Source/render/PoiTypes.h @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace maps { + +/** The map icons for points of interest, from the Maki set (https://github.com/mapbox/maki). */ +#define MAPS_POI_ICONS(X) \ + X(Marker, "marker") X(Cafe, "cafe") X(Restaurant, "restaurant") X(FastFood, "fast-food") X(Bar, "bar") \ + X(Beer, "beer") X(Bus, "bus") X(Rail, "rail") X(RailMetro, "rail-metro") X(Bicycle, "bicycle") \ + X(BicycleShare, "bicycle-share") X(Car, "car") X(CarRepair, "car-repair") X(Fuel, "fuel") \ + X(ChargingStation, "charging-station") X(Parking, "parking") X(Shop, "shop") X(Grocery, "grocery") \ + X(Bakery, "bakery") X(Convenience, "convenience") X(Pharmacy, "pharmacy") X(Hospital, "hospital") \ + X(Doctor, "doctor") X(Dentist, "dentist") X(Veterinary, "veterinary") X(Bank, "bank") X(Lodging, "lodging") \ + X(Campsite, "campsite") X(Museum, "museum") X(Theatre, "theatre") X(Cinema, "cinema") \ + X(ArtGallery, "art-gallery") X(Castle, "castle") X(Monument, "monument") X(Attraction, "attraction") \ + X(Zoo, "zoo") X(Park, "park") X(Playground, "playground") X(Swimming, "swimming") X(Stadium, "stadium") \ + X(Golf, "golf") X(Library, "library") X(College, "college") X(School, "school") X(TownHall, "town-hall") \ + X(Police, "police") X(FireStation, "fire-station") X(Post, "post") X(Toilet, "toilet") \ + X(DrinkingWater, "drinking-water") X(Information, "information") X(Christian, "religious-christian") \ + X(Jewish, "religious-jewish") X(Muslim, "religious-muslim") X(Buddhist, "religious-buddhist") \ + X(PlaceOfWorship, "place-of-worship") X(Cemetery, "cemetery") X(IceCream, "ice-cream") \ + X(AlcoholShop, "alcohol-shop") X(Music, "music") X(ClothingStore, "clothing-store") X(Laundry, "laundry") \ + X(Harbor, "harbor") X(Aerialway, "aerialway") X(Entrance, "entrance") X(WasteBasket, "waste-basket") \ + X(Pitch, "pitch") X(Hairdresser, "hairdresser") X(Jewelry, "jewelry-store") X(Optician, "optician") \ + X(Gift, "gift") X(Furniture, "furniture") X(MobilePhone, "mobile-phone") X(Hardware, "hardware") \ + X(Confectionery, "confectionery") X(Garden, "garden") X(DogPark, "dog-park") X(PicnicSite, "picnic-site") \ + X(Viewpoint, "viewpoint") X(Embassy, "embassy") X(Nightclub, "nightclub") X(Fitness, "fitness-centre") \ + X(Ferry, "ferry") X(Taxi, "taxi") X(Shoe, "shoe") X(Florist, "florist") X(AmusementPark, "amusement-park") \ + X(Aquarium, "aquarium") X(Recycling, "recycling") + +enum class PoiIcon : uint8_t { +#define MAPS_POI_ICON_ENUM(name, file) name, + MAPS_POI_ICONS(MAPS_POI_ICON_ENUM) +#undef MAPS_POI_ICON_ENUM + Count +}; + +/** The Maki icon file name without ".svg", "marker" for anything out of range. */ +const char* poiIconName(PoiIcon icon); + +/** What the sheet and the icon colour group a type under. */ +enum class PoiGroup : uint8_t { Food, Shop, Transport, Health, Money, Lodging, Culture, Leisure, Education, Public, Worship, Other }; + +struct PoiType { + /** An OpenMapTiles poi subclass, or class when isClass is set. */ + const char* key; + /** Matches the class, used when no subclass entry matches. */ + bool isClass; + PoiIcon icon; + /** Plain English words for the sheet. */ + const char* label; + PoiGroup group; + /** The map zoom from which the type is shown: 14 for important, 15 for most, 16 for small things. */ + uint8_t minZoom; + /** Worth showing on the map without a name (a toilet, a bus stop). */ + bool unnamedOk; +}; + +/** + * The type id of a poi feature: the first subclass entry that matches the subclass text, else the class entry + * that matches the class text. 0 means unknown, never shown. Texts need no terminator, nullptr is fine for none. + */ +uint8_t poiTypeFor(const char* classText, size_t classLength, const char* subclassText, size_t subclassLength); + +/** The type of an id from 1 to poiTypeCount(). Id 0 and anything above give an empty "unknown" type. */ +const PoiType& poiType(uint8_t id); + +/** How many types there are (at most 255): the valid ids are 1 to this. */ +int poiTypeCount(); + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/PreparedLabels.cpp b/Apps/Maps/main/Source/render/PreparedLabels.cpp new file mode 100644 index 0000000..66db2e7 --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedLabels.cpp @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "PreparedLabels.h" + +#include "PreparedTile.h" + +namespace maps { + +namespace { + +bool readVarint(const uint8_t*& data, const uint8_t* end, uint64_t& value) { + value = 0; + for (int shift = 0; shift < 64 && data < end; shift += 7) { + const uint8_t byte = *data++; + value |= static_cast(byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + return true; + } + } + return false; +} + +int32_t unzigzag(uint64_t value) { + return static_cast((value >> 1) ^ (~(value & 1) + 1)); +} + +constexpr uint64_t MAX_U16 = 0xFFFF; +constexpr uint64_t MAX_RANK = 0x7FFFFFFF; +constexpr uint64_t MAX_POINTS_VALUE = 0x7FFFFFFF; + +} // namespace + +void LabelReader::begin(const uint8_t* bytes, size_t size, int count) { + data = bytes; + length = size; + offset = 0; + remaining = count; +} + +bool LabelReader::next(LabelRecord& out) { + if (remaining <= 0) { + return false; + } + const uint8_t* cursor = data + offset; + const uint8_t* end = data + length; + if (cursor >= end) { + remaining = 0; + return false; + } + const uint8_t kind = *cursor++; + if (kind > static_cast(LabelKind::HouseNumber) || cursor >= end) { + remaining = 0; + return false; + } + const auto labelClass = static_cast(*cursor++); + uint64_t rank; + uint64_t nameLength; + if (!readVarint(cursor, end, rank) || rank > MAX_RANK + 1 || !readVarint(cursor, end, nameLength) || nameLength > MAX_U16 || + nameLength > static_cast(end - cursor)) { + remaining = 0; + return false; + } + const char* name = reinterpret_cast(cursor); + cursor += nameLength; + uint64_t latinLength; + if (!readVarint(cursor, end, latinLength) || latinLength > MAX_U16 || + latinLength > static_cast(end - cursor)) { + remaining = 0; + return false; + } + const char* nameLatin = latinLength > 0 ? reinterpret_cast(cursor) : nullptr; + cursor += latinLength; + uint64_t pointCount; + if (!readVarint(cursor, end, pointCount) || pointCount > MAX_POINTS_VALUE) { + remaining = 0; + return false; + } + int stored = 0; + int32_t x = 0; + int32_t y = 0; + for (uint64_t i = 0; i < pointCount; i++) { + uint64_t dx; + uint64_t dy; + if (!readVarint(cursor, end, dx) || !readVarint(cursor, end, dy)) { + remaining = 0; + return false; + } + x += unzigzag(dx); + y += unzigzag(dy); + if (stored < MAX_POINTS) { + pointBuffer[stored * 2] = x; + pointBuffer[stored * 2 + 1] = y; + stored++; + } + } + offset = static_cast(cursor - data); + remaining--; + out.kind = static_cast(kind); + out.labelClass = labelClass; + out.rank = rank == 0 ? -1 : static_cast(rank - 1); + out.name = name; + out.nameLength = static_cast(nameLength); + out.nameLatin = nameLatin; + out.nameLatinLength = static_cast(latinLength); + out.points = pointBuffer; + out.pointCount = stored; + return true; +} + +bool readLabelCells(const uint8_t* index, uint32_t mainSize, uint32_t numberSize, bool numbers, LabelCells& out) { + auto u32 = [&](size_t offset) { + return static_cast(index[offset]) | (static_cast(index[offset + 1]) << 8) | + (static_cast(index[offset + 2]) << 16) | (static_cast(index[offset + 3]) << 24); + }; + out.generalCount = u32(0); + out.generalBytes = u32(4); + if (out.generalBytes > mainSize) { + return false; + } + for (int cell = 0; cell < LABEL_CELLS; cell++) { + out.poiOffset[cell] = u32(8 + cell * 8); + out.poiCount[cell] = u32(12 + cell * 8); + out.numberOffset[cell] = u32(8 + LABEL_CELLS * 8 + cell * 8) + mainSize; + out.numberCount[cell] = numbers ? u32(12 + LABEL_CELLS * 8 + cell * 8) : 0; + if (out.poiOffset[cell] > mainSize || (numbers && out.numberOffset[cell] > mainSize + numberSize)) { + return false; + } + } + return true; +} + +bool findLabelSection(const uint8_t* body, size_t bodySize, const uint8_t*& section, size_t& sectionSize, int& count) { + const uint8_t* cursor = body; + const uint8_t* end = body + bodySize; + while (cursor < end && *cursor != 0xFF) { + const uint8_t id = *cursor++; + uint64_t labelCount; + uint64_t length; + if (!readVarint(cursor, end, labelCount) || !readVarint(cursor, end, length) || + length > static_cast(end - cursor)) { + return false; + } + if (id == PREPARED_LABEL_SECTION) { + if (labelCount > MAX_POINTS_VALUE) { + return false; + } + section = cursor; + sectionSize = static_cast(length); + count = static_cast(labelCount); + return true; + } + cursor += length; + } + return false; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/PreparedLabels.h b/Apps/Maps/main/Source/render/PreparedLabels.h new file mode 100644 index 0000000..a9530f4 --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedLabels.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../mvt/Schema.h" + +#include +#include + +namespace maps { + +/** + * Poi labels (format 4): labelClass holds the type id of PoiTypes.h, not a Value. HouseNumber labels: the name is the + * house number, labelClass is 0 and rank -1. + */ +enum class LabelKind : uint8_t { Place, Road, Water, Waterway, Peak, Poi, HouseNumber }; + +struct LabelRecord { + LabelKind kind; + Value labelClass; + /** -1 when absent */ + int32_t rank; + const char* name; + uint16_t nameLength; + /** nullptr when the record has none */ + const char* nameLatin; + uint16_t nameLatinLength; + /** x, y pairs in the 2048 extent, valid until the next call of next() */ + const int32_t* points; + int pointCount; +}; + +/** Walks the labels of a label section (its bytes after the section header). */ +class LabelReader { +public: + static constexpr int MAX_POINTS = 256; + void begin(const uint8_t* bytes, size_t size, int count); + /** False at the end or on damaged bytes. Lines with more points than MAX_POINTS are cut to MAX_POINTS. */ + bool next(LabelRecord& out); + +private: + const uint8_t* data = nullptr; + size_t length = 0; + size_t offset = 0; + int remaining = 0; + int32_t pointBuffer[2 * MAX_POINTS] {}; +}; + +/** + * Where the POI and house number records of a label file lie: they are sorted into an 8 x 8 grid of cells over the tile + * (LABEL_CELL_UNITS each), so placement reads only the cells near the screen. The other labels (lines, places) come + * first, unsorted. Offsets count from the start of the records; a cell is count records from its offset on. + */ +constexpr int LABEL_GRID = 8; +constexpr int LABEL_CELLS = LABEL_GRID * LABEL_GRID; +constexpr int LABEL_CELL_UNITS = 2048 / LABEL_GRID; +/** The index block of a .prl file, after its header: u32 general count, u32 general bytes, then for the POIs and for + * the house numbers 64 x (u32 offset in its part, u32 count). */ +constexpr size_t LABEL_INDEX_SIZE = 8 + 2 * LABEL_CELLS * 8; + +struct LabelCells { + uint32_t generalCount; + uint32_t generalBytes; + uint32_t poiOffset[LABEL_CELLS]; + uint32_t poiCount[LABEL_CELLS]; + uint32_t numberOffset[LABEL_CELLS]; + uint32_t numberCount[LABEL_CELLS]; +}; + +/** The cell of a point in tile units (outside points go to the nearest cell). */ +inline int labelCell(int32_t x, int32_t y) { + const int column = x < 0 ? 0 : (x >= 2048 ? LABEL_GRID - 1 : x / LABEL_CELL_UNITS); + const int row = y < 0 ? 0 : (y >= 2048 ? LABEL_GRID - 1 : y / LABEL_CELL_UNITS); + return row * LABEL_GRID + column; +} + +/** + * Reads the index block of a .prl file for records laid out as part A (mainSize bytes) then, with numbers, part B. + * False when it does not fit those sizes. + */ +bool readLabelCells(const uint8_t* index, uint32_t mainSize, uint32_t numberSize, bool numbers, LabelCells& out); + +/** Finds the label section in an inflated prepared body. False when there is none (also for v2 tiles). */ +bool findLabelSection(const uint8_t* body, size_t bodySize, const uint8_t*& section, size_t& sectionSize, int& count); + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/PreparedScanner.cpp b/Apps/Maps/main/Source/render/PreparedScanner.cpp new file mode 100644 index 0000000..1534997 --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedScanner.cpp @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "PreparedScanner.h" + +#include "../platform/Platform.h" +#include "PreparedTile.h" + +#include +#include +#include + +namespace maps { + +namespace { + +struct ColumnList { + int* columns; + int count; + int capacity; +}; + +bool isNumber(const char* text) { + if (*text == '\0') { + return false; + } + for (; *text != '\0'; text++) { + if (*text < '0' || *text > '9') { + return false; + } + } + return true; +} + +bool addColumn(const char* name, bool isDirectory, void* context) { + auto* list = static_cast(context); + if (isDirectory && isNumber(name) && list->count < list->capacity) { + list->columns[list->count++] = atoi(name); + } + return true; +} + +struct FileVisit { + const TileStore* store; + int region; + int zoom; + int x; + PreparedScanner::StaleCallback callback; + void* context; + bool stopped; + /** Originals seen in this folder so far, the first skip of them were looked at in earlier steps. */ + int seen; + int skip; + int checks; +}; + +/** Headers read per step: each is a file open on the card, which holds it for the tiles on screen. */ +constexpr int CHECKS_PER_STEP = 4; + +bool visitFile(const char* name, bool isDirectory, void* context) { + auto* visit = static_cast(context); + const size_t length = strlen(name); + // FAT may report short names in upper case + if (isDirectory || length < 5 || (strcmp(name + length - 4, ".pbf") != 0 && strcmp(name + length - 4, ".PBF") != 0)) { + return true; + } + char number[16]; + if (length - 4 >= sizeof(number)) { + return true; + } + memcpy(number, name, length - 4); + number[length - 4] = '\0'; + if (!isNumber(number)) { + return true; + } + if (visit->seen++ < visit->skip) { + return true; + } + if (visit->checks >= CHECKS_PER_STEP) { + // The rest of the folder comes in the next step + visit->seen--; + visit->stopped = true; + return false; + } + visit->checks++; + const TileKey tile { visit->zoom, visit->x, atoi(number) }; + const TileStore& store = *visit->store; + char path[PATH_MAX_LENGTH]; + uint32_t size = 0; + if (!tilePath(path, sizeof(path), store.root(), store.regionName(visit->region), tile) || !fileSize(path, size) || + !preparedPath(path, sizeof(path), store.root(), store.regionName(visit->region), tile)) { + return true; + } + bool split = false; + if (PreparedScanner::partsNeeded(path, tile, size, &split) == 0) { + return true; + } + if (!visit->callback(tile, visit->region, split, visit->context)) { + // The queue is full: this tile is looked at again next time + visit->seen--; + visit->stopped = true; + return false; + } + return true; +} + +} // namespace + +PreparedScanner::~PreparedScanner() { + largeFree(columns); +} + +void PreparedScanner::restart() { + region = 0; + zoom = 0; + column = 0; + columnCount = -1; + fileSkip = 0; + complete = false; +} + +bool PreparedScanner::isCurrent(const char* preparedPath, const TileKey& tile, uint32_t sourceSize) { + return partsNeeded(preparedPath, tile, sourceSize) == 0; +} + +uint8_t PreparedScanner::partsNeeded(const char* preparedPath, const TileKey& tile, uint32_t sourceSize, bool* split) { + if (split != nullptr) { + *split = false; + } + FILE* file = fopen(preparedPath, "rb"); + if (file == nullptr) { + return preparedPartsNeeded(nullptr, tile, sourceSize); + } + uint8_t bytes[PREPARED_HEADER_SIZE]; + const bool read = fread(bytes, 1, sizeof(bytes), file) == sizeof(bytes); + fclose(file); + PreparedHeader header {}; + if (!read || !readPreparedHeader(bytes, sizeof(bytes), header)) { + return preparedPartsNeeded(nullptr, tile, sourceSize); + } + if (split != nullptr) { + *split = header.version < PREPARED_SPLIT_VERSION && isPreparedReadable(header, tile, sourceSize); + } + return preparedPartsNeeded(&header, tile, sourceSize); +} + +bool PreparedScanner::listColumns(const TileStore& store) { + if (columns == nullptr) { + columns = static_cast(largeAlloc(MAX_COLUMNS * sizeof(int))); + if (columns == nullptr) { + return false; + } + } + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/%d", store.root(), store.regionName(region), zoom); + ColumnList list { columns, 0, MAX_COLUMNS }; + listDirectory(path, addColumn, &list); + columnCount = list.count; + column = 0; + return true; +} + +bool PreparedScanner::step(const TileStore& store, StaleCallback callback, void* context) { + while (!complete) { + if (region >= store.regionCount()) { + complete = true; + break; + } + if (zoom > MAX_SOURCE_ZOOM) { + region++; + zoom = 0; + columnCount = -1; + continue; + } + if (columnCount < 0 && !listColumns(store)) { + return false; + } + if (column >= columnCount) { + zoom++; + columnCount = -1; + continue; + } + char path[PATH_MAX_LENGTH]; + snprintf(path, sizeof(path), "%s/maps/%s/%d/%d", store.root(), store.regionName(region), zoom, columns[column]); + FileVisit visit { &store, region, zoom, columns[column], callback, context, false, 0, fileSkip, 0 }; + listDirectory(path, visitFile, &visit); + // A stopped folder goes on next time after the originals already looked at + if (visit.stopped) { + fileSkip = visit.seen; + } else { + column++; + fileSkip = 0; + } + return true; + } + return false; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/PreparedScanner.h b/Apps/Maps/main/Source/render/PreparedScanner.h new file mode 100644 index 0000000..1c6a80e --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedScanner.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../tiles/TileStore.h" + +#include + +namespace maps { + +/** + * Walks the region folders a little at a time and reports original tiles whose prepared copy is missing or was + * made by an older format version, so idle time converts the tiles already on the card. + */ +class PreparedScanner { +public: + /** + * Called for a tile that needs converting; split is true for a tile prepared by a version with labels inside, which + * is split into its new files. Returning false stops the step, it is looked at again next time. + */ + using StaleCallback = bool (*)(const TileKey& tile, int region, bool split, void* context); + + ~PreparedScanner(); + + /** Starts over from the first region, after tiles arrived or the format changed. */ + void restart(); + bool isComplete() const { return complete; } + + /** Looks at one folder of tiles. Returns false once every region was looked at. */ + bool step(const TileStore& store, StaleCallback callback, void* context); + + /** Whether the prepared copy at path, and the files made with it, belong to this original and are current. */ + static bool isCurrent(const char* preparedPath, const TileKey& tile, uint32_t sourceSize); + /** + * The parts (PART_* of PreparedTile.h) a tile needs, from the header of its prepared copy at path. split tells + * whether that copy is an older one with labels inside. + */ + static uint8_t partsNeeded(const char* preparedPath, const TileKey& tile, uint32_t sourceSize, bool* split = nullptr); + +private: + static constexpr int MAX_COLUMNS = 4096; + + bool listColumns(const TileStore& store); + + int region = 0; + int zoom = 0; + int column = 0; + int columnCount = -1; + /** Originals of the current folder already looked at. */ + int fileSkip = 0; + int* columns = nullptr; + bool complete = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/PreparedTile.cpp b/Apps/Maps/main/Source/render/PreparedTile.cpp new file mode 100644 index 0000000..479299e --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedTile.cpp @@ -0,0 +1,938 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "PreparedTile.h" + +#include "../platform/Platform.h" +#include "../tiles/TileStore.h" +#include "PoiTypes.h" +#include "PreparedLabels.h" + +#include + +#include +#include + +namespace maps { + +namespace { + +constexpr char MAGIC[4] = { 'T', 'M', 'P', 'T' }; +constexpr char LABEL_MAGIC[4] = { 'T', 'M', 'P', 'L' }; +constexpr size_t RING_START_CAPACITY = 1024; +/** Longer rings are stored without simplification. */ +constexpr size_t RING_MAX_POINTS = 65536; +/** Labels beyond this many bytes are dropped, they are for the screen around the current tile only. */ +constexpr size_t LABEL_MAX_BYTES = 48 * 1024; +/** The same for the poi labels and for the house number labels, each (a dense city tile has thousands). */ +constexpr size_t POI_LABEL_MAX_BYTES = 80 * 1024; +constexpr size_t NUMBER_LABEL_MAX_BYTES = 40 * 1024; +/** Poi and house number texts are cut to this many bytes, at a character boundary. */ +constexpr size_t POI_TEXT_MAX_BYTES = 63; +constexpr uint8_t FLAG_MARITIME = 1 << 2; +constexpr uint8_t FLAG_RAMP = 1 << 3; +constexpr uint8_t FLAG_SUBCLASS = 1 << 4; +constexpr uint8_t FLAG_BRUNNEL = 1 << 5; +constexpr uint8_t FLAG_ADMIN = 1 << 6; + +uint16_t readU16(const uint8_t* bytes) { + return static_cast(bytes[0] | (bytes[1] << 8)); +} + +uint32_t readU32(const uint8_t* bytes) { + return static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); +} + +int32_t roundToInt(float value) { + return static_cast(std::floor(value + 0.5f)); +} + +/** The section a layer's features go to, -1 for layers that are not drawn. */ +int sectionOf(Layer layer) { + for (int i = 0; i < static_cast(Stage::Count); i++) { + const auto stage = static_cast(i); + if (stage == preparedSection(stage) && stageLayer(stage) == layer) { + return i; + } + } + return -1; +} + +/** The label kind of a layer, -1 for layers without labels. */ +int labelKindOf(Layer layer) { + switch (layer) { + case Layer::Place: + return static_cast(LabelKind::Place); + case Layer::TransportationName: + return static_cast(LabelKind::Road); + case Layer::WaterName: + return static_cast(LabelKind::Water); + case Layer::Waterway: + return static_cast(LabelKind::Waterway); + case Layer::MountainPeak: + return static_cast(LabelKind::Peak); + case Layer::Poi: + return static_cast(LabelKind::Poi); + case Layer::Housenumber: + return static_cast(LabelKind::HouseNumber); + default: + return -1; + } +} + +/** The length of text cut to at most maxBytes without splitting a UTF-8 character. */ +uint16_t cutUtf8(const char* text, size_t length, size_t maxBytes) { + if (length <= maxBytes) { + return static_cast(length); + } + while (maxBytes > 0 && (static_cast(text[maxBytes]) & 0xC0) == 0x80) { + maxBytes--; + } + return static_cast(maxBytes); +} + +size_t encodeVarint(uint8_t* out, uint64_t value) { + size_t count = 0; + while (value >= 0x80) { + out[count++] = static_cast(value | 0x80); + value >>= 7; + } + out[count++] = static_cast(value); + return count; +} + +int deflateCallback(const void* buffer, int length, void* user) { + auto* out = static_cast(user); + out->putBytes(buffer, static_cast(length)); + return out->failed() ? 0 : 1; +} + +} // namespace + +bool readPreparedHeader(const uint8_t* data, size_t size, PreparedHeader& header) { + if (data == nullptr || size < PREPARED_HEADER_SIZE || memcmp(data, MAGIC, 4) != 0) { + return false; + } + header.version = readU16(data + 4); + const uint16_t flags = readU16(data + 6); + header.flags = flags & 0xFF; + header.labelVersion = header.version >= PREPARED_SPLIT_VERSION ? data[9] : 0; + header.graphVersion = header.version >= PREPARED_SPLIT_VERSION ? static_cast(flags >> 8) : 0; + header.tile.z = data[8]; + header.extent = readU16(data + 10); + header.tile.x = static_cast(readU32(data + 12)); + header.tile.y = static_cast(readU32(data + 16)); + header.sourceSize = readU32(data + 20); + header.bodySize = readU32(data + 24); + header.storedSize = readU32(data + 28); + memcpy(header.sourceVersion, data + 32, PREPARED_VERSION_LENGTH); + header.sourceVersion[PREPARED_VERSION_LENGTH] = '\0'; + return true; +} + +bool isPreparedCurrent(const PreparedHeader& header, const TileKey& tile, uint32_t sourceSize) { + return preparedPartsNeeded(&header, tile, sourceSize) == 0; +} + +uint8_t preparedPartsNeeded(const PreparedHeader* header, const TileKey& tile, uint32_t sourceSize) { + const uint8_t graph = tile.z == GRAPH_ZOOM && PREPARED_GRAPH_FORMAT_VERSION != 0 ? PART_GRAPH : 0; + if (header == nullptr || !(header->tile == tile) || header->sourceSize != sourceSize || header->extent != PREPARED_EXTENT) { + return PART_GEOMETRY | PART_LABELS | graph; + } + uint8_t parts = 0; + if (header->version != PREPARED_FORMAT_VERSION) { + parts |= PART_GEOMETRY; + } + if (header->version < PREPARED_SPLIT_VERSION || header->labelVersion != PREPARED_LABEL_FORMAT_VERSION) { + parts |= PART_LABELS; + } + if (graph != 0 && (header->version < PREPARED_SPLIT_VERSION || header->graphVersion != PREPARED_GRAPH_FORMAT_VERSION)) { + parts |= PART_GRAPH; + } + return parts; +} + +bool readPreparedLabelHeader(const uint8_t* data, size_t size, PreparedLabelHeader& header) { + if (data == nullptr || size < PREPARED_LABEL_HEADER_SIZE || memcmp(data, LABEL_MAGIC, 4) != 0) { + return false; + } + header.version = readU16(data + 4); + header.flags = readU16(data + 6); + header.tile.z = data[8]; + header.tile.x = static_cast(readU32(data + 12)); + header.tile.y = static_cast(readU32(data + 16)); + header.sourceSize = readU32(data + 20); + header.mainCount = readU32(data + 24); + header.mainSize = readU32(data + 28); + header.mainStored = readU32(data + 32); + header.numberCount = readU32(data + 36); + header.numberSize = readU32(data + 40); + header.numberStored = readU32(data + 44); + memcpy(header.sourceVersion, data + 48, PREPARED_VERSION_LENGTH); + header.sourceVersion[PREPARED_VERSION_LENGTH] = '\0'; + return true; +} + +bool isPreparedLabelCurrent(const PreparedLabelHeader& header, const TileKey& tile, uint32_t sourceSize) { + return header.version == PREPARED_LABEL_FORMAT_VERSION && header.tile == tile && header.sourceSize == sourceSize; +} + +bool isPreparedReadable(const PreparedHeader& header, const TileKey& tile, uint32_t sourceSize) { + return header.version >= PREPARED_MIN_READABLE_VERSION && header.version <= PREPARED_FORMAT_VERSION && + header.tile == tile && header.sourceSize == sourceSize && header.extent == PREPARED_EXTENT; +} + +// --------------------------------------------------------------------------------------------------------------- + +ByteWriter::~ByteWriter() { + largeFree(data); +} + +void ByteWriter::release() { + largeFree(data); + data = nullptr; + capacity = 0; + clear(); +} + +bool ByteWriter::reserveExact(size_t size) { + if (size <= capacity) { + return true; + } + auto* grown = static_cast(largeAlloc(size)); + if (grown == nullptr) { + error = true; + return false; + } + if (length > 0) { + memcpy(grown, data, length); + } + largeFree(data); + data = grown; + capacity = size; + return true; +} + +bool ByteWriter::reserve(size_t size) { + if (size <= capacity) { + return true; + } + size_t next = capacity < 4096 ? 4096 : capacity; + while (next < size) { + next *= 2; + } + auto* grown = static_cast(largeAlloc(next)); + if (grown == nullptr) { + error = true; + return false; + } + if (length > 0) { + memcpy(grown, data, length); + } + largeFree(data); + data = grown; + capacity = next; + return true; +} + +void ByteWriter::put(uint8_t value) { + if (length < capacity || reserve(length + 1)) { + data[length++] = value; + } +} + +void ByteWriter::putBytes(const void* bytes, size_t count) { + if (count > 0 && reserve(length + count)) { + memcpy(data + length, bytes, count); + length += count; + } +} + +void ByteWriter::putVarint(uint64_t value) { + while (value >= 0x80) { + put(static_cast(value | 0x80)); + value >>= 7; + } + put(static_cast(value)); +} + +void ByteWriter::putU16(uint16_t value) { + put(static_cast(value)); + put(static_cast(value >> 8)); +} + +void ByteWriter::putU32(uint32_t value) { + for (int i = 0; i < 4; i++) { + put(static_cast(value >> (8 * i))); + } +} + +void ByteWriter::setU32(size_t offset, uint32_t value) { + if (offset + 4 <= length) { + for (int i = 0; i < 4; i++) { + data[offset + i] = static_cast(value >> (8 * i)); + } + } +} + +// --------------------------------------------------------------------------------------------------------------- + +void TileConverter::trim(size_t keepBytes) { + for (auto& writer : sections) { + if (writer.allocated() > keepBytes) { + writer.release(); + } + } + if (extraLabels.allocated() > keepBytes) { + extraLabels.release(); + } + if (numberLabels.allocated() > keepBytes) { + numberLabels.release(); + } + if (poiRecords.allocated() > keepBytes) { + poiRecords.release(); + } + if (numberRecords.allocated() > keepBytes) { + numberRecords.release(); + } + if (labels.allocated() > keepBytes) { + labels.release(); + } +} + +TileConverter::~TileConverter() { + largeFree(ringPoints); + largeFree(keep); + largeFree(stack); +} + +bool TileConverter::growRing(size_t points) { + if (points <= ringCapacity) { + return true; + } + if (points > RING_MAX_POINTS) { + return false; + } + size_t next = ringCapacity == 0 ? RING_START_CAPACITY : ringCapacity; + while (next < points) { + next *= 2; + } + auto* grownPoints = static_cast(largeAlloc(next * 2 * sizeof(float))); + auto* grownKeep = static_cast(largeAlloc(next)); + auto* grownStack = static_cast(largeAlloc(next * 2 * sizeof(uint32_t))); + if (grownPoints == nullptr || grownKeep == nullptr || grownStack == nullptr) { + largeFree(grownPoints); + largeFree(grownKeep); + largeFree(grownStack); + return false; + } + if (ringLength > 0) { + memcpy(grownPoints, ringPoints, ringLength * 2 * sizeof(float)); + } + largeFree(ringPoints); + largeFree(keep); + largeFree(stack); + ringPoints = grownPoints; + keep = grownKeep; + stack = grownStack; + ringCapacity = next; + return true; +} + +bool TileConverter::run(MvtDecoder& tileDecoder, ByteInput* input, const TileKey& sourceTile, bool wantGeometry, bool wantLabels) { + decoder = &tileDecoder; + keepGeometry = wantGeometry; + keepLabels = wantLabels; + // The decoder belongs to a render lane: its pools go back to their normal size however the conversion ends + struct ShrinkPools { + MvtDecoder& pools; + ~ShrinkPools() { pools.shrinkPools(); } + } shrinkPools { tileDecoder }; + tile = sourceTile; + counters = {}; + // A tile is drawn at its own zoom level and magnified for the levels above it while no closer tile exists + minZoom = tile.z; + maxZoom = tile.z + TileStore::MAX_OVERZOOM < MAX_VIEW_ZOOM ? tile.z + TileStore::MAX_OVERZOOM : MAX_VIEW_ZOOM; + // Units per pixel at z16, where the drop and simplification thresholds are measured + pixelsPerUnitAt16 = tile.z <= 16 ? std::ldexp(static_cast(TILE_SIZE) / PREPARED_EXTENT, 16 - tile.z) : 1.0f; + for (int i = 0; i < static_cast(Stage::Count); i++) { + sections[i].clear(); + sectionCounts[i] = 0; + } + labels.clear(); + extraLabels.clear(); + numberLabels.clear(); + poiRecords.clear(); + numberRecords.clear(); + poiBytes = 0; + labelCount = 0; + numberCount = 0; + numberStored = 0; + failureText = ""; + if (!decoder->scan(input, true)) { + failureText = "reading the tile"; + return false; + } + Layer layers[static_cast(Stage::Count) + 7]; + int layerCount = 0; + for (int i = 0; i < static_cast(Stage::Count); i++) { + const auto stage = static_cast(i); + if (stage == preparedSection(stage)) { + layers[layerCount++] = stageLayer(stage); + } + } + layers[layerCount++] = Layer::Place; + layers[layerCount++] = Layer::TransportationName; + layers[layerCount++] = Layer::WaterName; + layers[layerCount++] = Layer::Waterway; + layers[layerCount++] = Layer::MountainPeak; + layers[layerCount++] = Layer::Poi; + layers[layerCount++] = Layer::Housenumber; + // The sections are separate buffers, so one pass in file order collects all of them + if (!decoder->sweep(layers, layerCount, *this)) { + failureText = "decoding, or no memory for the features"; + return false; + } + for (int i = 0; i < static_cast(Stage::Count); i++) { + if (sections[i].failed()) { + failureText = "no memory for the features"; + return false; + } + } + if (labels.failed() || extraLabels.failed() || numberLabels.failed()) { + failureText = "no memory for the labels"; + return false; + } + return true; +} + + +namespace { + +/** Writes a body raw, or as a zlib stream when there is a compressor. */ +class BodyWriter { +public: + BodyWriter(ByteWriter& out, tdefl_compressor* compressor) : out(out), compressor(compressor) {} + // 128 probes per search and a zlib header, so InflateReader recognises the stream + bool begin() { + return compressor == nullptr || tdefl_init(compressor, deflateCallback, &out, 128 | TDEFL_WRITE_ZLIB_HEADER) == TDEFL_STATUS_OKAY; + } + bool emit(const void* bytes, size_t count) { + if (count == 0) { + return true; + } + if (compressor == nullptr) { + out.putBytes(bytes, count); + return !out.failed(); + } + return tdefl_compress_buffer(compressor, bytes, count, TDEFL_NO_FLUSH) == TDEFL_STATUS_OKAY; + } + bool finish() { + return compressor == nullptr || tdefl_compress_buffer(compressor, nullptr, 0, TDEFL_FINISH) == TDEFL_STATUS_DONE; + } + +private: + ByteWriter& out; + tdefl_compressor* compressor; +}; + +void putVersion(ByteWriter& out, const char* sourceVersion) { + // Zero padded, without a terminator when the version fills the field + char version[PREPARED_VERSION_LENGTH] = {}; + if (sourceVersion != nullptr) { + const size_t length = strlen(sourceVersion); + memcpy(version, sourceVersion, length < PREPARED_VERSION_LENGTH ? length : PREPARED_VERSION_LENGTH); + } + out.putBytes(version, PREPARED_VERSION_LENGTH); +} + +} // namespace + +bool TileConverter::convert(MvtDecoder& tileDecoder, ByteInput* input, const TileKey& sourceTile, uint32_t sourceSize, + const char* sourceVersion, bool deflate, ByteWriter* geometryOut, ByteWriter* labelOut, ConvertStats* stats) { + const uint32_t start = nowMs(); + if (!run(tileDecoder, input, sourceTile, geometryOut != nullptr, labelOut != nullptr)) { + return false; + } + size_t bodySize = 0; + if (labelOut != nullptr && !writeLabelFile(sourceVersion, sourceSize, deflate, *labelOut)) { + return false; + } + if (geometryOut != nullptr && !writeGeometry(sourceVersion, sourceSize, deflate, false, *geometryOut, bodySize)) { + return false; + } + if (stats != nullptr) { + *stats = counters; + stats->totalMs = nowMs() - start; + stats->bodyBytes = static_cast(bodySize); + stats->storedBytes = geometryOut != nullptr ? static_cast(geometryOut->size() - PREPARED_HEADER_SIZE) : 0; + stats->labelBytes = labelOut != nullptr ? static_cast(labels.size() + extraLabels.size() + numberLabels.size()) : 0; + stats->labelStoredBytes = labelOut != nullptr ? static_cast(labelOut->size()) : 0; + stats->numberStoredBytes = numberStored; + } + return true; +} + +bool TileConverter::convertCombined(MvtDecoder& tileDecoder, ByteInput* input, const TileKey& sourceTile, uint32_t sourceSize, + const char* sourceVersion, bool deflate, ByteWriter& out, ConvertStats* stats) { + const uint32_t start = nowMs(); + size_t bodySize = 0; + if (!run(tileDecoder, input, sourceTile, true, true) || !writeGeometry(sourceVersion, sourceSize, deflate, true, out, bodySize)) { + return false; + } + if (stats != nullptr) { + *stats = counters; + stats->totalMs = nowMs() - start; + stats->bodyBytes = static_cast(bodySize); + stats->storedBytes = static_cast(out.size() - PREPARED_HEADER_SIZE); + } + return true; +} + +bool TileConverter::writeLabelFile(const char* sourceVersion, uint32_t sourceSize, bool deflate, ByteWriter& out) { + out.clear(); + out.putBytes(LABEL_MAGIC, 4); + out.putU16(PREPARED_LABEL_FORMAT_VERSION); + out.putU16(deflate ? PREPARED_FLAG_DEFLATED : 0); + out.put(static_cast(tile.z)); + out.put(0); + out.putU16(0); + out.putU32(static_cast(tile.x)); + out.putU32(static_cast(tile.y)); + out.putU32(sourceSize); + out.putU32(labelCount); + out.putU32(static_cast(labels.size() + extraLabels.size())); + out.putU32(0); + out.putU32(numberCount); + out.putU32(static_cast(numberLabels.size())); + out.putU32(0); + putVersion(out, sourceVersion); + // The cell index, filled in as the sorted records are written + const size_t indexAt = out.size(); + out.putU32(labelCount - static_cast(poiRecords.size() / 8)); + out.putU32(static_cast(labels.size())); + for (size_t i = 8; i < LABEL_INDEX_SIZE; i += 4) { + out.putU32(0); + } + tdefl_compressor* compressor = deflate ? static_cast(largeAlloc(sizeof(tdefl_compressor))) : nullptr; + if (deflate && compressor == nullptr) { + failureText = "no 320 KB block of memory for the compressor"; + return false; + } + // Records of one cell together, in the order they came, the cells in order + auto emitSorted = [&](BodyWriter& writer, const ByteWriter& bytes, const ByteWriter& records, uint32_t start, size_t table) { + const auto* entries = records.bytes(); + const size_t entryCount = records.size() / 8; + uint32_t offset = start; + for (int cell = 0; cell < LABEL_CELLS; cell++) { + uint32_t count = 0; + const uint32_t cellStart = offset; + for (size_t i = 0; i < entryCount; i++) { + const uint8_t* entry = entries + i * 8; + const uint32_t packed = static_cast(entry[4]) | (static_cast(entry[5]) << 8) | + (static_cast(entry[6]) << 16) | (static_cast(entry[7]) << 24); + if (static_cast(packed >> 24) != cell) { + continue; + } + const uint32_t at = static_cast(entry[0]) | (static_cast(entry[1]) << 8) | + (static_cast(entry[2]) << 16) | (static_cast(entry[3]) << 24); + const uint32_t length = packed & 0xFFFFFF; + if (!writer.emit(bytes.bytes() + at, length)) { + return false; + } + offset += length; + count++; + } + out.setU32(indexAt + table + static_cast(cell) * 8, cellStart); + out.setU32(indexAt + table + static_cast(cell) * 8 + 4, count); + } + return true; + }; + // Two separate streams, so a view without house numbers reads and inflates only the first + BodyWriter main(out, compressor); + bool ok = main.begin() && main.emit(labels.bytes(), labels.size()) && + emitSorted(main, extraLabels, poiRecords, static_cast(labels.size()), 8) && main.finish(); + const size_t mainEnd = out.size(); + BodyWriter numbers(out, compressor); + ok = ok && (numberLabels.size() == 0 || + (numbers.begin() && emitSorted(numbers, numberLabels, numberRecords, 0, 8 + LABEL_CELLS * 8) && numbers.finish())); + largeFree(compressor); + if (!ok || out.failed()) { + failureText = "no memory for the label file"; + return false; + } + out.setU32(32, static_cast(mainEnd - PREPARED_LABEL_HEADER_SIZE - LABEL_INDEX_SIZE)); + numberStored = static_cast(out.size() - mainEnd); + out.setU32(44, numberStored); + return true; +} + +bool TileConverter::writeGeometry(const char* sourceVersion, uint32_t sourceSize, bool deflate, bool withLabels, ByteWriter& out, + size_t& bodySize) { + // The sections go straight into the output: one combined copy of them would double the memory a large tile needs + bodySize = 1; + uint8_t head[1 + 2 * 10]; + for (int i = 0; i < static_cast(Stage::Count); i++) { + if (sectionCounts[i] != 0) { + bodySize += encodeVarint(head, sectionCounts[i]) + encodeVarint(head, sections[i].size()) + 1 + sections[i].size(); + } + } + const uint32_t combinedCount = labelCount + numberCount; + const size_t labelBytes = labels.size() + extraLabels.size() + numberLabels.size(); + if (withLabels && combinedCount != 0) { + bodySize += encodeVarint(head, combinedCount) + encodeVarint(head, labelBytes) + 1 + labelBytes; + } + + out.clear(); + out.putBytes(MAGIC, 4); + out.putU16(withLabels ? PREPARED_SPLIT_VERSION - 1 : PREPARED_FORMAT_VERSION); + // The companion versions this tile was made with (version 5 on) + const auto graphVersion = static_cast(withLabels || tile.z != GRAPH_ZOOM ? 0 : PREPARED_GRAPH_FORMAT_VERSION); + out.putU16(static_cast((deflate ? PREPARED_FLAG_DEFLATED : 0) | (graphVersion << 8))); + out.put(static_cast(tile.z)); + out.put(withLabels ? 0 : PREPARED_LABEL_FORMAT_VERSION); + out.putU16(PREPARED_EXTENT); + out.putU32(static_cast(tile.x)); + out.putU32(static_cast(tile.y)); + out.putU32(sourceSize); + out.putU32(static_cast(bodySize)); + out.putU32(0); + putVersion(out, sourceVersion); + tdefl_compressor* compressor = deflate ? static_cast(largeAlloc(sizeof(tdefl_compressor))) : nullptr; + if (deflate && compressor == nullptr) { + failureText = "no 320 KB block of memory for the compressor"; + return false; + } + BodyWriter body(out, compressor); + bool ok = body.begin(); + for (int i = 0; i < static_cast(Stage::Count) && ok; i++) { + if (sectionCounts[i] == 0) { + continue; + } + size_t headSize = 0; + head[headSize++] = static_cast(i); + headSize += encodeVarint(head + headSize, sectionCounts[i]); + headSize += encodeVarint(head + headSize, sections[i].size()); + ok = body.emit(head, headSize) && body.emit(sections[i].bytes(), sections[i].size()); + } + if (ok && withLabels && combinedCount != 0) { + size_t headSize = 0; + head[headSize++] = PREPARED_LABEL_SECTION; + headSize += encodeVarint(head + headSize, combinedCount); + headSize += encodeVarint(head + headSize, labelBytes); + ok = body.emit(head, headSize) && body.emit(labels.bytes(), labels.size()) && body.emit(extraLabels.bytes(), extraLabels.size()) && + body.emit(numberLabels.bytes(), numberLabels.size()); + } + const uint8_t endMark = 0xFF; + ok = ok && body.emit(&endMark, 1) && body.finish(); + largeFree(compressor); + if (!ok || out.failed()) { + failureText = "no memory for the prepared tile"; + return false; + } + out.setU32(28, static_cast(out.size() - PREPARED_HEADER_SIZE)); + return true; +} + +bool TileConverter::beginFeature(const FeatureInfo& info) { + counters.featuresIn++; + section = sectionOf(info.layer); + const int kind = labelKindOf(info.layer); + bool hasLabel = kind >= 0 && info.name != nullptr && info.nameLength > 0; + uint8_t poiTypeId = 0; + if (info.layer == Layer::Poi || info.layer == Layer::Housenumber) { + hasLabel = false; + if (info.layer == Layer::Poi) { + poiTypeId = poiTypeFor(info.classText, info.classTextLength, info.subclassText, info.subclassTextLength); + if (poiTypeId != 0) { + const PoiType& type = poiType(poiTypeId); + // A tile of zoom z serves z up to z + 5, so types that start at 16 are kept from the tiles of 15 on + const bool named = info.name != nullptr && info.nameLength > 0; + // Tiles of the highest data zoom serve every zoom above it, so they keep the small types too + hasLabel = (tile.z >= MAX_SOURCE_ZOOM || tile.z >= static_cast(type.minZoom) - 1) && (named || type.unnamedOk); + } + } else { + // House numbers only come from the highest data zoom, they are meant for the closest views + hasLabel = tile.z >= MAX_SOURCE_ZOOM && info.houseNumber != nullptr && info.houseNumberLength > 0 && + info.geometryType == GEOMETRY_POINT; + } + } + // Kept when the style draws it in one of the section's stages at one of the zoom levels the tile is used for + const Palette& palette = colorPalette(); + bool drawn = false; + if (section >= 0) { + for (int zoom = minZoom; zoom <= maxZoom && !drawn; zoom++) { + for (int i = 0; i < static_cast(Stage::Count) && !drawn; i++) { + const auto stage = static_cast(i); + DrawStyle style {}; + drawn = static_cast(preparedSection(stage)) == section && styleFor(info, stage, zoom, palette, style); + } + } + } + drawn = drawn && keepGeometry; + hasLabel = hasLabel && keepLabels; + if (!drawn && !hasLabel) { + return false; + } + feature = info; + if (hasLabel && info.layer == Layer::Poi) { + feature.featureClass = static_cast(poiTypeId); + feature.nameLength = info.name != nullptr ? cutUtf8(info.name, info.nameLength, POI_TEXT_MAX_BYTES) : 0; + feature.nameLatinLength = info.nameLatin != nullptr ? cutUtf8(info.nameLatin, info.nameLatinLength, POI_TEXT_MAX_BYTES) : 0; + } else if (hasLabel && info.layer == Layer::Housenumber) { + feature.featureClass = Value::None; + feature.rank = -1; + feature.name = info.houseNumber; + feature.nameLength = cutUtf8(info.houseNumber, info.houseNumberLength, POI_TEXT_MAX_BYTES); + feature.nameLatin = nullptr; + feature.nameLatinLength = 0; + } + drawFeature = drawn; + labelFeature = hasLabel; + labelKind = static_cast(kind); + rings.clear(); + ringCount = 0; + lastX = 0; + lastY = 0; + minX = 1e30f; + minY = 1e30f; + maxX = -1e30f; + maxY = -1e30f; + unitsPerSourceUnit = static_cast(PREPARED_EXTENT) / static_cast(decoder->extent(info.layer)); + return true; +} + +void TileConverter::beginRing() { + ringLength = 0; + ringOverflow = false; +} + +void TileConverter::points(const TilePoint* chunk, int count) { + counters.pointsIn += static_cast(count); + if (!growRing(ringLength + static_cast(count))) { + // Kept unsimplified in the buffer it has, the rest of a huge ring is lost + ringOverflow = true; + count = static_cast(ringCapacity - ringLength); + } + for (int i = 0; i < count; i++) { + const float x = static_cast(chunk[i].x) * unitsPerSourceUnit; + const float y = static_cast(chunk[i].y) * unitsPerSourceUnit; + // The decoder repeats the last point of a chunk at the start of the next one + if (ringLength > 0 && ringPoints[ringLength * 2 - 2] == x && ringPoints[ringLength * 2 - 1] == y) { + continue; + } + ringPoints[ringLength * 2] = x; + ringPoints[ringLength * 2 + 1] = y; + ringLength++; + } +} + +int TileConverter::simplifyRing(int count, float tolerance) { + // Douglas-Peucker with an explicit stack, keeping the end points + for (int i = 0; i < count; i++) { + keep[i] = 0; + } + keep[0] = 1; + keep[count - 1] = 1; + int depth = 0; + stack[depth++] = 0; + stack[depth++] = static_cast(count - 1); + const float toleranceSquared = tolerance * tolerance; + while (depth > 0) { + const auto last = static_cast(stack[--depth]); + const auto first = static_cast(stack[--depth]); + if (last - first < 2) { + continue; + } + const float ax = ringPoints[first * 2]; + const float ay = ringPoints[first * 2 + 1]; + const float dx = ringPoints[last * 2] - ax; + const float dy = ringPoints[last * 2 + 1] - ay; + const float lengthSquared = dx * dx + dy * dy; + float worst = -1.0f; + int worstIndex = -1; + for (int i = first + 1; i < last; i++) { + const float px = ringPoints[i * 2] - ax; + const float py = ringPoints[i * 2 + 1] - ay; + float distanceSquared; + if (lengthSquared <= 0.0f) { + distanceSquared = px * px + py * py; + } else { + const float cross = px * dy - py * dx; + distanceSquared = cross * cross / lengthSquared; + } + if (distanceSquared > worst) { + worst = distanceSquared; + worstIndex = i; + } + } + if (worst > toleranceSquared) { + keep[worstIndex] = 1; + stack[depth++] = static_cast(first); + stack[depth++] = static_cast(worstIndex); + stack[depth++] = static_cast(worstIndex); + stack[depth++] = static_cast(last); + } + } + int kept = 0; + for (int i = 0; i < count; i++) { + if (keep[i] != 0) { + ringPoints[kept * 2] = ringPoints[i * 2]; + ringPoints[kept * 2 + 1] = ringPoints[i * 2 + 1]; + kept++; + } + } + return kept; +} + +void TileConverter::endRing(bool closed) { + if (ringLength == 0) { + return; + } + // A pixel at z18 (a quarter at z16), about half a unit, so lines stay smooth when magnified up to z19 + const float tolerance = 0.25f / pixelsPerUnitAt16; + int count = static_cast(ringLength); + if (count > 2 && !ringOverflow) { + count = simplifyRing(count, tolerance); + } + // Quantised in place, dropping points that land on the previous one + auto* quantised = reinterpret_cast(ringPoints); + int kept = 0; + for (int i = 0; i < count; i++) { + const float x = ringPoints[i * 2]; + const float y = ringPoints[i * 2 + 1]; + const int32_t qx = roundToInt(x); + const int32_t qy = roundToInt(y); + if (kept > 0 && quantised[kept * 2 - 2] == qx && quantised[kept * 2 - 1] == qy) { + continue; + } + minX = std::fmin(minX, x); + minY = std::fmin(minY, y); + maxX = std::fmax(maxX, x); + maxY = std::fmax(maxY, y); + quantised[kept * 2] = qx; + quantised[kept * 2 + 1] = qy; + kept++; + } + if (closed && kept > 1 && quantised[0] == quantised[kept * 2 - 2] && quantised[1] == quantised[kept * 2 - 1]) { + kept--; + } + if (labelFeature) { + const bool single = labelKind == static_cast(LabelKind::Poi) || labelKind == static_cast(LabelKind::HouseNumber); + const int minimum = feature.geometryType == GEOMETRY_POINT || single ? 1 : 2; + if (kept >= minimum) { + // Poi and house number labels are one point, the first of an outline. These layers have a wide buffer + // around the tile: points outside belong to the neighbour, which writes them itself. + const bool inside = quantised[0] >= 0 && quantised[0] < PREPARED_EXTENT && quantised[1] >= 0 && quantised[1] < PREPARED_EXTENT; + if (!single || inside) { + writeLabel(quantised, single ? 1 : kept); + } + if (single && feature.geometryType != GEOMETRY_POINT) { + labelFeature = false; + } + } + } + if (!drawFeature) { + return; + } + if (kept < (closed ? 3 : 2)) { + return; + } + rings.putVarint((static_cast(kept) << 1) | (closed ? 1 : 0)); + for (int i = 0; i < kept; i++) { + rings.putZigzag(quantised[i * 2] - lastX); + rings.putZigzag(quantised[i * 2 + 1] - lastY); + lastX = quantised[i * 2]; + lastY = quantised[i * 2 + 1]; + } + counters.pointsKept += static_cast(kept); + ringCount++; +} + +void TileConverter::writeLabel(const int32_t* points, int count) { + const bool poi = labelKind == static_cast(LabelKind::Poi); + const bool number = labelKind == static_cast(LabelKind::HouseNumber); + if ((!poi && !number && labels.size() >= LABEL_MAX_BYTES) || (poi && poiBytes >= POI_LABEL_MAX_BYTES) || + (number && numberLabels.size() >= NUMBER_LABEL_MAX_BYTES)) { + return; + } + ByteWriter& target = number ? numberLabels : poi ? extraLabels : labels; + const size_t before = target.size(); + target.put(labelKind); + target.put(static_cast(feature.featureClass)); + target.putVarint(feature.rank >= 0 ? static_cast(feature.rank) + 1 : 0); + target.putVarint(feature.nameLength); + if (feature.nameLength > 0) { + target.putBytes(feature.name, feature.nameLength); + } + const bool sameLatin = feature.nameLatin != nullptr && feature.nameLatinLength > 0 && + feature.nameLatinLength == feature.nameLength && memcmp(feature.name, feature.nameLatin, feature.nameLength) == 0; + if (feature.nameLatin != nullptr && feature.nameLatinLength > 0 && !sameLatin) { + target.putVarint(feature.nameLatinLength); + target.putBytes(feature.nameLatin, feature.nameLatinLength); + } else { + target.putVarint(0); + } + target.putVarint(static_cast(count)); + int32_t x = 0; + int32_t y = 0; + for (int i = 0; i < count; i++) { + target.putZigzag(points[i * 2] - x); + target.putZigzag(points[i * 2 + 1] - y); + x = points[i * 2]; + y = points[i * 2 + 1]; + } + if (poi || number) { + ByteWriter& records = poi ? poiRecords : numberRecords; + records.putU32(static_cast(before)); + records.putU32(static_cast(target.size() - before) | (static_cast(labelCell(points[0], points[1])) << 24)); + } + if (poi) { + poiBytes += target.size() - before; + } + if (number) { + numberCount++; + } else { + labelCount++; + } +} + +void TileConverter::endFeature() { + // Features smaller than a pixel at z16 are dropped + if (ringCount == 0 || ((maxX - minX) * pixelsPerUnitAt16 < 1.0f && (maxY - minY) * pixelsPerUnitAt16 < 1.0f)) { + return; + } + ByteWriter& out = sections[section]; + uint8_t flags = feature.geometryType & 3; + if (feature.maritime) { + flags |= FLAG_MARITIME; + } + if (feature.ramp) { + flags |= FLAG_RAMP; + } + if (feature.subclass != Value::None) { + flags |= FLAG_SUBCLASS; + } + if (feature.brunnel != Value::None) { + flags |= FLAG_BRUNNEL; + } + if (feature.adminLevel >= 0) { + flags |= FLAG_ADMIN; + } + out.put(flags); + out.putVarint(static_cast(feature.featureClass)); + if (feature.subclass != Value::None) { + out.putVarint(static_cast(feature.subclass)); + } + if (feature.brunnel != Value::None) { + out.putVarint(static_cast(feature.brunnel)); + } + if (feature.adminLevel >= 0) { + out.put(static_cast(feature.adminLevel)); + } + out.putVarint(ringCount); + out.putBytes(rings.bytes(), rings.size()); + sectionCounts[section]++; + counters.featuresKept++; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/PreparedTile.h b/Apps/Maps/main/Source/render/PreparedTile.h new file mode 100644 index 0000000..110aa97 --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedTile.h @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../mvt/MvtDecoder.h" +#include "../tiles/TileMath.h" +#include "Style.h" + +#include +#include + +namespace maps { + +/** + * Prepared tiles: a render-ready copy of a vector tile, stored next to the untouched original as .prt. + * It keeps only the features the style can draw at the zoom levels the tile is used for, with only the + * attributes the style reads, coordinates quantised to PREPARED_EXTENT units per tile, grouped by draw stage. + * Raising PREPARED_FORMAT_VERSION makes the app regenerate every prepared tile from the originals. Raise it + * when the style draws features it used to skip, or when the order of the Value list in Schema.h changes, since + * classes are stored as Value numbers. + * + * Layout (little endian): a 64-byte header, then the body, raw or as a zlib stream. + * header: "TMPT", u16 version, u16 flags, u8 z, u8 0, u16 extent, u32 x, u32 y, u32 source size, + * u32 body size, u32 stored body size, char source version[32] + * body: sections, each u8 stage, varint feature count, varint byte length, features; then, when the tile + * has labels, a label section (u8 PREPARED_LABEL_SECTION, varint label count, varint byte length, + * labels); then 0xFF. The RoadCasing section is drawn again for the RoadFill stage. + * feature: u8 flags (bits 0-1 geometry type, 2 maritime, 3 ramp, 4 has subclass, 5 has brunnel, + * 6 has admin level), varint class, [varint subclass], [varint brunnel], [u8 admin level], + * varint ring count, rings: varint (point count << 1 | closed), zigzag varint deltas x, y. + * Deltas continue across the rings of a feature and start at 0, 0. + * label: u8 kind, u8 class, varint rank + 1 (0 when absent), varint name length, name bytes, + * varint latin name length (0 when absent or equal to name), latin name bytes, + * varint point count, zigzag varint deltas x, y. Deltas start at 0, 0 for each label. + * Kinds 0-4 are place, road, water, waterway and peak. Version 4 adds kind 5, poi (class = type id from + * PoiTypes.h, rank + 1, name at most 63 bytes, empty for types shown without a name, one point), and + * kind 6, house number (class 0, no rank, the number as the name, no latin name, one point). Both come + * after the other labels. Version 3 tiles have neither and are still readable. + * + * Version 5 (M5.2) splits a tile into three files next to the original, each with its own format version, so a + * change to one makes only that one again: + * .prt geometry only, the layout above without the label section. Header byte 9 is the version of the label + * file, the high byte of the flags the version of the graph file, both made from the same original + * (0: not made). A tile whose companions are older is current for drawing but queued for them. + * .prl labels, POIs and house numbers (see PreparedLabelHeader). Read only when labels are placed. + * .prg the routing graph of a z14 tile (RouteGraph.h). Read only when routing. + * Versions 2-4 still draw, with their labels, until the tile is split in idle time. + */ +constexpr uint16_t PREPARED_FORMAT_VERSION = 5; +/** The first version without labels inside: its labels are in the .prl file. */ +constexpr uint16_t PREPARED_SPLIT_VERSION = 5; +constexpr uint16_t PREPARED_MIN_READABLE_VERSION = 2; +constexpr uint8_t PREPARED_LABEL_SECTION = 10; +// 2 px per unit at z18 from z14 data. 1024 looked stepped at z17-z19; 4096 made deflated tiles 35 % larger +// (docs/MEASUREMENTS.md, M3.2). +constexpr int PREPARED_EXTENT = 2048; +constexpr size_t PREPARED_HEADER_SIZE = 64; +constexpr size_t PREPARED_VERSION_LENGTH = 32; +constexpr uint16_t PREPARED_FLAG_DEFLATED = 1; + +/** + * The label file .prl (little endian): an 80-byte header, the cell index (LABEL_INDEX_SIZE bytes, PreparedLabels.h), + * then part A (labels, then POIs sorted by cell) and part B (house numbers sorted by cell), each raw or as its own zlib + * stream, so a view below the house number zoom reads only the header, the index and part A. The records are those of + * the label section above (PreparedLabels.h). + * header: "TMPL", u16 version, u16 flags (1 deflated), u8 z, u8 0, u16 0, u32 x, u32 y, u32 source size, + * u32 A count, u32 A size, u32 A stored size, u32 B count, u32 B size, u32 B stored size, + * char source version[32] + */ +constexpr uint8_t PREPARED_LABEL_FORMAT_VERSION = 2; +constexpr size_t PREPARED_LABEL_HEADER_SIZE = 80; +/** The version of the routing graph file (route/RouteGraph.h), 0 while the app makes none. */ +constexpr uint8_t PREPARED_GRAPH_FORMAT_VERSION = 1; +/** Only tiles of this zoom have a routing graph. */ +constexpr int GRAPH_ZOOM = 14; + +struct PreparedLabelHeader { + uint16_t version; + uint16_t flags; + TileKey tile; + uint32_t sourceSize; + uint32_t mainCount; + uint32_t mainSize; + uint32_t mainStored; + uint32_t numberCount; + uint32_t numberSize; + uint32_t numberStored; + char sourceVersion[PREPARED_VERSION_LENGTH + 1]; +}; + +bool readPreparedLabelHeader(const uint8_t* data, size_t size, PreparedLabelHeader& header); +bool isPreparedLabelCurrent(const PreparedLabelHeader& header, const TileKey& tile, uint32_t sourceSize); + +/** What a conversion makes: the files of a tile that are missing or older than this app's formats. */ +enum PreparedPart : uint8_t { + PART_GEOMETRY = 1, + PART_LABELS = 2, + PART_GRAPH = 4, +}; + +struct PreparedHeader { + uint16_t version; + uint16_t flags; + /** Version 5 on: the versions of the .prl and .prg files made with it (0: none). */ + uint8_t labelVersion; + uint8_t graphVersion; + TileKey tile; + int extent; + uint32_t sourceSize; + uint32_t bodySize; + uint32_t storedSize; + char sourceVersion[PREPARED_VERSION_LENGTH + 1]; +}; + +bool readPreparedHeader(const uint8_t* data, size_t size, PreparedHeader& header); + +/** Whether a prepared tile and its companion files were made by this app's format versions from this original. */ +bool isPreparedCurrent(const PreparedHeader& header, const TileKey& tile, uint32_t sourceSize); + +/** Whether a prepared tile can still be drawn, even when it was made by an older format version. */ +bool isPreparedReadable(const PreparedHeader& header, const TileKey& tile, uint32_t sourceSize); + +/** The parts a tile needs, from the header of its .prt (nullptr when there is none or it is unreadable). */ +uint8_t preparedPartsNeeded(const PreparedHeader* header, const TileKey& tile, uint32_t sourceSize); + +/** A byte buffer that grows as needed. */ +class ByteWriter { +public: + ByteWriter() = default; + ~ByteWriter(); + ByteWriter(const ByteWriter&) = delete; + ByteWriter& operator=(const ByteWriter&) = delete; + + void clear() { + length = 0; + error = false; + } + /** Clears and frees the buffer. */ + void release(); + bool reserve(size_t size); + /** Room for exactly size bytes, no doubling: for a download whose length is known. */ + bool reserveExact(size_t size); + void put(uint8_t value); + void putBytes(const void* bytes, size_t count); + void putVarint(uint64_t value); + void putZigzag(int32_t value) { putVarint((static_cast(value) << 1) ^ static_cast(value >> 31)); } + void putU16(uint16_t value); + void putU32(uint32_t value); + void setU32(size_t offset, uint32_t value); + + const uint8_t* bytes() const { return data; } + uint8_t* bytes() { return data; } + size_t size() const { return length; } + size_t allocated() const { return capacity; } + bool failed() const { return error; } + +private: + uint8_t* data = nullptr; + size_t capacity = 0; + size_t length = 0; + bool error = false; +}; + +struct ConvertStats { + uint32_t totalMs; + uint32_t featuresIn; + uint32_t featuresKept; + uint32_t pointsIn; + uint32_t pointsKept; + uint32_t bodyBytes; + uint32_t storedBytes; + uint32_t labelBytes; + uint32_t labelStoredBytes; + uint32_t numberStoredBytes; +}; + +/** + * Converts a vector tile into a prepared tile. It uses the decoder of a render lane, so a conversion needs no + * second decompressor. + */ +class TileConverter final : private GeometrySink { +public: + TileConverter() = default; + ~TileConverter() override; + TileConverter(const TileConverter&) = delete; + TileConverter& operator=(const TileConverter&) = delete; + + /** + * Writes the prepared files of the vector tile in input: the geometry (.prt) to geometryOut and the labels (.prl) + * to labelOut; either may be nullptr when that file is current. sourceSize is the size of the original file, + * sourceVersion the tile set version it came from. + */ + bool convert(MvtDecoder& decoder, ByteInput* input, const TileKey& tile, uint32_t sourceSize, const char* sourceVersion, + bool deflate, ByteWriter* geometryOut, ByteWriter* labelOut, ConvertStats* stats); + /** The old single-file layout (version 4, labels inside), for tests and the comparison tool only. */ + bool convertCombined(MvtDecoder& decoder, ByteInput* input, const TileKey& tile, uint32_t sourceSize, const char* sourceVersion, + bool deflate, ByteWriter& out, ConvertStats* stats); + /** Why the last convert() failed. */ + const char* failure() const { return failureText; } + /** Frees section buffers larger than keepBytes, which a large low-zoom tile leaves behind. */ + void trim(size_t keepBytes); + +private: + bool beginFeature(const FeatureInfo& info) override; + void beginRing() override; + void points(const TilePoint* points, int count) override; + void endRing(bool closed) override; + void endFeature() override; + + bool growRing(size_t points); + int simplifyRing(int count, float tolerance); + void writeLabel(const int32_t* points, int count); + bool run(MvtDecoder& decoder, ByteInput* input, const TileKey& tile, bool wantGeometry, bool wantLabels); + bool writeGeometry(const char* sourceVersion, uint32_t sourceSize, bool deflate, bool withLabels, ByteWriter& out, size_t& bodySize); + bool writeLabelFile(const char* sourceVersion, uint32_t sourceSize, bool deflate, ByteWriter& out); + + MvtDecoder* decoder = nullptr; + const char* failureText = ""; + TileKey tile {}; + int minZoom = 0; + int maxZoom = 0; + float unitsPerSourceUnit = 1.0f; + float pixelsPerUnitAt16 = 1.0f; + ByteWriter sections[static_cast(Stage::Count)]; + uint32_t sectionCounts[static_cast(Stage::Count)] {}; + ByteWriter rings; + ByteWriter labels; + /** The poi labels, written apart so that they cannot crowd out the others in labels. */ + ByteWriter extraLabels; + /** House numbers: their own part of the label file. */ + ByteWriter numberLabels; + /** Per POI and house number record: u32 offset, u32 length | cell << 24, to write them sorted by cell. */ + ByteWriter poiRecords; + ByteWriter numberRecords; + size_t poiBytes = 0; + uint32_t labelCount = 0; + uint32_t numberCount = 0; + bool keepGeometry = true; + bool keepLabels = true; + uint32_t numberStored = 0; + int section = 0; + FeatureInfo feature {}; + uint32_t ringCount = 0; + bool drawFeature = false; + bool labelFeature = false; + uint8_t labelKind = 0; + int32_t lastX = 0; + int32_t lastY = 0; + float minX = 0.0f; + float minY = 0.0f; + float maxX = 0.0f; + float maxY = 0.0f; + float* ringPoints = nullptr; + uint8_t* keep = nullptr; + uint32_t* stack = nullptr; + size_t ringCapacity = 0; + size_t ringLength = 0; + bool ringOverflow = false; + ConvertStats counters {}; +}; + +/** The section a stage draws from: road fills reuse the road casing section. */ +inline Stage preparedSection(Stage stage) { + return stage == Stage::RoadFill ? Stage::RoadCasing : stage; +} + +/** Compresses data into a zlib stream. */ + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/Raster.cpp b/Apps/Maps/main/Source/render/Raster.cpp new file mode 100644 index 0000000..ebc9593 --- /dev/null +++ b/Apps/Maps/main/Source/render/Raster.cpp @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Raster.h" + +#include "../platform/Platform.h" +#include "Style.h" + +#include +#include +#include +#include + +namespace maps { + +namespace { + +constexpr float PI_F = 3.14159265f; +constexpr int MAX_DOT_SEGMENTS = 32; + +/** Integral of the "left of x" fraction for a row crossing that spans left..right horizontally. */ +inline float coverageIntegral(float x, float left, float right) { + if (x <= left) { + return 0.0f; + } + if (x >= right) { + return 0.5f * (right - left) + (x - right); + } + const float t = x - left; + return t * t / (2.0f * (right - left)); +} + +} // namespace + +uint16_t blendRgb565(uint16_t foreground, uint16_t background, uint32_t alpha32) { + const uint32_t fg = (foreground | (static_cast(foreground) << 16)) & 0x07E0F81FU; + const uint32_t bg = (background | (static_cast(background) << 16)) & 0x07E0F81FU; + const uint32_t result = ((((fg - bg) * alpha32) >> 5) + bg) & 0x07E0F81FU; + return static_cast(result | (result >> 16)); +} + +Raster::~Raster() { + largeFree(cells); + largeFree(rowFirst); + largeFree(rowLast); +} + +void Raster::resetRows() { + for (int row = 0; row < rasterHeight; row++) { + rowFirst[row] = static_cast(stride); + rowLast[row] = -1; + } +} + +bool Raster::init(int width, int height) { + if (cells != nullptr && width == rasterWidth && height == rasterHeight) { + return true; + } + largeFree(cells); + largeFree(rowFirst); + largeFree(rowLast); + rasterWidth = width; + rasterHeight = height; + stride = width + 2; + cells = static_cast(largeAlloc(static_cast(stride) * height * sizeof(float))); + rowFirst = static_cast(fastAlloc(static_cast(height) * sizeof(int16_t))); + rowLast = static_cast(fastAlloc(static_cast(height) * sizeof(int16_t))); + if (cells == nullptr || rowFirst == nullptr || rowLast == nullptr) { + return false; + } + memset(cells, 0, static_cast(stride) * height * sizeof(float)); + resetRows(); + minRow = rasterHeight; + maxRow = -1; + minColumn = stride; + maxColumn = 0; + return true; +} + +void Raster::markRows(int top, int bottom) { + minRow = std::min(minRow, top); + maxRow = std::max(maxRow, bottom); +} + +void Raster::addLine(float x0, float y0, float x1, float y1) { + const auto height = static_cast(rasterHeight); + if (y0 == y1 || (y0 <= 0.0f && y1 <= 0.0f) || (y0 >= height && y1 >= height)) { + return; + } + if (!std::isfinite(x0) || !std::isfinite(y0) || !std::isfinite(x1) || !std::isfinite(y1)) { + return; + } + const float slope = (x1 - x0) / (y1 - y0); + if (y0 < 0.0f) { + x0 += (0.0f - y0) * slope; + y0 = 0.0f; + } else if (y0 > height) { + x0 += (height - y0) * slope; + y0 = height; + } + if (y1 < 0.0f) { + x1 += (0.0f - y1) * slope; + y1 = 0.0f; + } else if (y1 > height) { + x1 += (height - y1) * slope; + y1 = height; + } + addClippedLine(x0, y0, x1, y1); +} + +void Raster::addClippedLine(float x0, float y0, float x1, float y1) { + const auto width = static_cast(rasterWidth); + // Splits the edge where it crosses x = 0 and x = width. Parts left of the raster become vertical edges + // at x = 0, parts right of it do not affect any visible pixel. + float splits[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; + int count = 1; + if (x0 != x1) { + for (const float edge : { 0.0f, width }) { + const float t = (edge - x0) / (x1 - x0); + if (t > 0.0f && t < 1.0f) { + splits[count++] = t; + } + } + } + splits[count] = 1.0f; + if (count == 3 && splits[1] > splits[2]) { + std::swap(splits[1], splits[2]); + } + for (int i = 0; i < count; i++) { + const float ta = splits[i]; + const float tb = splits[i + 1]; + float ax = x0 + (x1 - x0) * ta; + const float ay = y0 + (y1 - y0) * ta; + float bx = x0 + (x1 - x0) * tb; + const float by = y0 + (y1 - y0) * tb; + const float middle = 0.5f * (ax + bx); + if (middle >= width) { + // Rows left of a skipped edge keep their coverage up to the right border + const int top = std::max(0, static_cast(std::min(ay, by))); + const int bottom = std::min(rasterHeight - 1, static_cast(std::ceil(std::max(ay, by))) - 1); + for (int row = top; row <= bottom; row++) { + rowLast[row] = static_cast(stride - 1); + } + maxColumn = stride - 1; + continue; + } + if (middle <= 0.0f) { + ax = 0.0f; + bx = 0.0f; + } + accumulate(std::clamp(ax, 0.0f, width), ay, std::clamp(bx, 0.0f, width), by); + } +} + +void Raster::accumulateAliased(float x0, float y0, float x1, float y1) { + float direction = 1.0f; + if (y0 > y1) { + std::swap(x0, x1); + std::swap(y0, y1); + direction = -1.0f; + } + // The rows whose centre lies inside the edge's span + const int firstRow = std::max(0, static_cast(std::ceil(y0 - 0.5f))); + const int lastRow = std::min(rasterHeight - 1, static_cast(std::ceil(y1 - 0.5f)) - 1); + if (firstRow > lastRow) { + return; + } + markRows(firstRow, lastRow); + const float slope = (x1 - x0) / (y1 - y0); + for (int row = firstRow; row <= lastRow; row++) { + const float x = x0 + (static_cast(row) + 0.5f - y0) * slope; + // Pixels whose centre is right of the edge are inside + const int column = std::clamp(static_cast(std::floor(x + 0.5f)), 0, stride - 1); + cells[row * stride + column] += direction; + minColumn = std::min(minColumn, column); + maxColumn = std::max(maxColumn, column); + if (column < rowFirst[row]) { + rowFirst[row] = static_cast(column); + } + if (column > rowLast[row]) { + rowLast[row] = static_cast(column); + } + } +} + +void Raster::accumulate(float x0, float y0, float x1, float y1) { + if (y0 == y1) { + return; + } + if (!antialias) { + accumulateAliased(x0, y0, x1, y1); + return; + } + float direction = 1.0f; + if (y0 > y1) { + std::swap(x0, x1); + std::swap(y0, y1); + direction = -1.0f; + } + const float slope = (x1 - x0) / (y1 - y0); + const int firstRow = static_cast(y0); + const int lastRow = std::min(rasterHeight - 1, static_cast(std::ceil(y1)) - 1); + if (firstRow > lastRow) { + return; + } + markRows(firstRow, lastRow); + for (int row = firstRow; row <= lastRow; row++) { + const float top = std::max(y0, static_cast(row)); + const float bottom = std::min(y1, static_cast(row + 1)); + const float dy = bottom - top; + if (dy <= 0.0f) { + continue; + } + const float xa = x0 + (top - y0) * slope; + const float xb = x0 + (bottom - y0) * slope; + const float left = std::min(xa, xb); + const float right = std::max(xa, xb); + const float area = dy * direction; + float* line = cells + row * stride; + const int firstColumn = static_cast(left); + const int lastColumn = std::min(stride - 1, static_cast(right) + 1); + minColumn = std::min(minColumn, firstColumn); + maxColumn = std::max(maxColumn, lastColumn); + if (firstColumn < rowFirst[row]) { + rowFirst[row] = static_cast(firstColumn); + } + if (lastColumn > rowLast[row]) { + rowLast[row] = static_cast(lastColumn); + } + if (right - left < 1e-4f) { + const float fraction = 1.0f - (left - static_cast(firstColumn)); + line[firstColumn] += area * fraction; + if (firstColumn + 1 < stride) { + line[firstColumn + 1] += area * (1.0f - fraction); + } + continue; + } + float previous = 0.0f; + for (int column = firstColumn; column <= lastColumn; column++) { + const auto c = static_cast(column); + const float cover = coverageIntegral(c + 1.0f, left, right) - coverageIntegral(c, left, right); + line[column] += area * (cover - previous); + previous = cover; + } + } +} + +void Raster::addSegment(float x0, float y0, float x1, float y1, float halfWidth) { + const float dx = x1 - x0; + const float dy = y1 - y0; + const float length = std::sqrt(dx * dx + dy * dy); + if (length < 1e-4f) { + return; + } + const float extent = halfWidth + 1.0f; + const auto width = static_cast(rasterWidth); + const auto height = static_cast(rasterHeight); + if ((x0 < -extent && x1 < -extent) || (x0 > width + extent && x1 > width + extent) || + (y0 < -extent && y1 < -extent) || (y0 > height + extent && y1 > height + extent)) { + return; + } + const float nx = -dy / length * halfWidth; + const float ny = dx / length * halfWidth; + addLine(x0 + nx, y0 + ny, x1 + nx, y1 + ny); + addLine(x1 + nx, y1 + ny, x1 - nx, y1 - ny); + addLine(x1 - nx, y1 - ny, x0 - nx, y0 - ny); + addLine(x0 - nx, y0 - ny, x0 + nx, y0 + ny); +} + +void Raster::addDot(float x, float y, float radius) { + const float extent = radius + 1.0f; + if (x < -extent || y < -extent || x > static_cast(rasterWidth) + extent || y > static_cast(rasterHeight) + extent) { + return; + } + const int segments = std::clamp(static_cast(radius * 2.0f) + 4, 6, MAX_DOT_SEGMENTS); + float previousX = x + radius; + float previousY = y; + for (int i = 1; i <= segments; i++) { + // Clockwise in screen coordinates, the same winding as the segment quads + const float angle = -2.0f * PI_F * static_cast(i) / static_cast(segments); + const float nextX = x + radius * std::cos(angle); + const float nextY = y + radius * std::sin(angle); + addLine(previousX, previousY, nextX, nextY); + previousX = nextX; + previousY = nextY; + } +} + +void Raster::fill(uint16_t* pixels, int pixelStride, uint16_t color, uint8_t opacity, uint8_t pattern, int originX, int originY) { + if (isEmpty()) { + return; + } + const bool patterned = pattern != PATTERN_NONE; + const float scale = static_cast(opacity) * (32.0f / 255.0f); + for (int row = minRow; row <= maxRow; row++) { + const int first = rowFirst[row]; + int last = rowLast[row]; + if (last < first) { + continue; + } + // A row whose edges are all left of the raster is covered up to the right border + float* line = cells + row * stride; + uint16_t* out = pixels + row * pixelStride; + float sum = 0.0f; + for (int column = first; column <= last; column++) { + sum += line[column]; + line[column] = 0.0f; + if (column >= rasterWidth) { + continue; + } + const float coverage = std::min(1.0f, std::fabs(sum)); + if (patterned) { + // No anti-aliasing: a partly covered pixel is either in or out + if (coverage >= 0.5f && patternBit(pattern, originX + column, originY + row)) { + out[column] = color; + } + } else { + const auto alpha = static_cast(coverage * scale + 0.5f); + if (alpha >= 32) { + out[column] = color; + } else if (alpha > 0) { + out[column] = blendRgb565(color, out[column], alpha); + } + } + if (column == last && last < rasterWidth - 1 && std::fabs(sum) > 1e-3f) { + last = rasterWidth - 1; + } + } + rowFirst[row] = static_cast(stride); + rowLast[row] = -1; + } + minRow = rasterHeight; + maxRow = -1; + minColumn = stride; + maxColumn = 0; +} + +void Raster::clear() { + if (cells != nullptr && !isEmpty()) { + for (int row = minRow; row <= maxRow; row++) { + memset(cells + row * stride, 0, static_cast(stride) * sizeof(float)); + } + resetRows(); + } + minRow = rasterHeight; + maxRow = -1; + minColumn = stride; + maxColumn = 0; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/Raster.h b/Apps/Maps/main/Source/render/Raster.h new file mode 100644 index 0000000..dc3006c --- /dev/null +++ b/Apps/Maps/main/Source/render/Raster.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace maps { + +/** + * Anti-aliased rasteriser with exact area coverage. Edges add signed area deltas to an accumulation buffer, + * and fill() turns the running sum of each row into coverage, so one shape made of many polygons, quads and + * dots is blended only once. Coverage is min(1, |winding|), which gives nonzero filling. + * The buffer is allocated once in init(), shapes allocate nothing. + */ +class Raster { +public: + Raster() = default; + ~Raster(); + Raster(const Raster&) = delete; + Raster& operator=(const Raster&) = delete; + + bool init(int width, int height); + + void addLine(float x0, float y0, float x1, float y1); + + /** A stroke segment as a quad, wound like the dots so overlaps never cancel. */ + void addSegment(float x0, float y0, float x1, float y1, float halfWidth); + /** A round join or cap. */ + void addDot(float x, float y, float radius); + + /** + * Without anti-aliasing each edge adds one step per row, at the pixel centres: cheaper, for many small + * shapes where the soft edge does not show. Change it only while the buffer is empty. + */ + void setAntialias(bool enabled) { antialias = enabled; } + + /** + * Blends the accumulated shape into pixels with the given colour and clears the buffer. With a pattern + * (see Style.h) only covered pixels that belong to it are set, in solid colour, which is what 1-bit + * displays need. originX and originY place the pattern on the whole map, so tiles line up. + */ + void fill(uint16_t* pixels, int stride, uint16_t color, uint8_t opacity, uint8_t pattern = 0, int originX = 0, int originY = 0); + void clear(); + + bool isEmpty() const { return minRow > maxRow; } + int width() const { return rasterWidth; } + int height() const { return rasterHeight; } + +private: + void addClippedLine(float x0, float y0, float x1, float y1); + void accumulate(float x0, float y0, float x1, float y1); + void accumulateAliased(float x0, float y0, float x1, float y1); + void markRows(int top, int bottom); + void resetRows(); + + float* cells = nullptr; + /** First and last touched column of each row, so fill() skips the untouched part of a row. */ + int16_t* rowFirst = nullptr; + int16_t* rowLast = nullptr; + int rasterWidth = 0; + int rasterHeight = 0; + int stride = 0; + int minRow = 0; + int maxRow = -1; + int minColumn = 0; + int maxColumn = 0; + bool antialias = true; +}; + +uint16_t blendRgb565(uint16_t foreground, uint16_t background, uint32_t alpha32); + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/Style.cpp b/Apps/Maps/main/Source/render/Style.cpp new file mode 100644 index 0000000..73fd01e --- /dev/null +++ b/Apps/Maps/main/Source/render/Style.cpp @@ -0,0 +1,677 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Style.h" + +#include +#include + +namespace maps { + +namespace { + +constexpr uint16_t rgb(uint32_t hex) { + return static_cast(((hex >> 8) & 0xF800) | ((hex >> 5) & 0x07E0) | ((hex >> 3) & 0x001F)); +} + +constexpr Palette COLOR_PALETTE = { + .background = rgb(0xF2EFE9), + .placeholder = rgb(0xE4E1DB), + .water = rgb(0xAAD3DF), + .wood = rgb(0xADD19E), + .grass = rgb(0xCDEBB0), + .farmland = rgb(0xEEF0D5), + .wetland = rgb(0xD6E8D2), + .sand = rgb(0xF5E9C6), + .ice = rgb(0xDDECEC), + .rock = rgb(0xE2DED8), + .residential = rgb(0xE0DFDF), + .commercial = rgb(0xF2DAD9), + .industrial = rgb(0xEBDBE8), + .cemetery = rgb(0xAACBAF), + .hospital = rgb(0xFFF0F0), + .school = rgb(0xFFFFE5), + .pitch = rgb(0xAAE0CB), + .military = rgb(0xF3E2E1), + .park = rgb(0xC8FACC), + .building = rgb(0xD9D0C9), + .aerodrome = rgb(0xE9E7E2), + .runway = rgb(0xBBBBCC), + .boundary = rgb(0x8D618B), + .motorway = rgb(0xE892A2), + .motorwayCasing = rgb(0xC24E6B), + .trunk = rgb(0xF9B29C), + .trunkCasing = rgb(0xC84E2F), + .primary = rgb(0xFCD6A4), + .primaryCasing = rgb(0xA06B00), + .secondary = rgb(0xF7FABF), + .secondaryCasing = rgb(0x707D05), + .minor = rgb(0xFFFFFF), + .minorCasing = rgb(0xB8B4AE), + .path = rgb(0xC08A5A), + .rail = rgb(0x707070), + .pier = rgb(0xF2EFE9), + .text = rgb(0x202020), + .halo = rgb(0xFFFFFF), + .marker = rgb(0x1A73E8), + .binary = false, +}; + +constexpr Palette MONOCHROME_PALETTE = { + .background = rgb(0xFFFFFF), + .placeholder = rgb(0xEEEEEE), + .water = rgb(0xA0A0A0), + .wood = rgb(0xC8C8C8), + .grass = rgb(0xE0E0E0), + .farmland = rgb(0xFFFFFF), + .wetland = rgb(0xD8D8D8), + .sand = rgb(0xF0F0F0), + .ice = rgb(0xF0F0F0), + .rock = rgb(0xE8E8E8), + .residential = rgb(0xF4F4F4), + .commercial = rgb(0xF0F0F0), + .industrial = rgb(0xECECEC), + .cemetery = rgb(0xD0D0D0), + .hospital = rgb(0xF4F4F4), + .school = rgb(0xF4F4F4), + .pitch = rgb(0xE0E0E0), + .military = rgb(0xECECEC), + .park = rgb(0xDCDCDC), + .building = rgb(0xB8B8B8), + .aerodrome = rgb(0xF0F0F0), + .runway = rgb(0x909090), + .boundary = rgb(0x404040), + .motorway = rgb(0xFFFFFF), + .motorwayCasing = rgb(0x000000), + .trunk = rgb(0xFFFFFF), + .trunkCasing = rgb(0x000000), + .primary = rgb(0xFFFFFF), + .primaryCasing = rgb(0x000000), + .secondary = rgb(0xFFFFFF), + .secondaryCasing = rgb(0x202020), + .minor = rgb(0xFFFFFF), + .minorCasing = rgb(0x404040), + .path = rgb(0x303030), + .rail = rgb(0x000000), + .pier = rgb(0xFFFFFF), + .text = rgb(0x000000), + .halo = rgb(0xFFFFFF), + .marker = rgb(0x000000), + .binary = false, +}; + +/** Every colour is black or white; patterns decide how much black an area gets. */ +constexpr Palette BINARY_PALETTE = { + .background = rgb(0xFFFFFF), + .placeholder = rgb(0xFFFFFF), + .water = rgb(0x000000), + .wood = rgb(0x000000), + .grass = rgb(0x000000), + .farmland = rgb(0x000000), + .wetland = rgb(0x000000), + .sand = rgb(0x000000), + .ice = rgb(0x000000), + .rock = rgb(0x000000), + .residential = rgb(0x000000), + .commercial = rgb(0x000000), + .industrial = rgb(0x000000), + .cemetery = rgb(0x000000), + .hospital = rgb(0x000000), + .school = rgb(0x000000), + .pitch = rgb(0x000000), + .military = rgb(0x000000), + .park = rgb(0x000000), + .building = rgb(0x000000), + .aerodrome = rgb(0xFFFFFF), + .runway = rgb(0x000000), + .boundary = rgb(0x000000), + .motorway = rgb(0x000000), + .motorwayCasing = rgb(0xFFFFFF), + .trunk = rgb(0x000000), + .trunkCasing = rgb(0xFFFFFF), + .primary = rgb(0x000000), + .primaryCasing = rgb(0xFFFFFF), + .secondary = rgb(0x000000), + .secondaryCasing = rgb(0xFFFFFF), + .minor = rgb(0x000000), + .minorCasing = rgb(0xFFFFFF), + .path = rgb(0x000000), + .rail = rgb(0x000000), + .pier = rgb(0x000000), + .text = rgb(0x000000), + .halo = rgb(0xFFFFFF), + .marker = rgb(0x000000), + .binary = true, +}; + +enum class RoadKind : uint8_t { + None, + Motorway, + Trunk, + Primary, + Secondary, + Tertiary, + Minor, + Service, + Path, + Rail, + Pier +}; + +struct RoadSpec { + float widthAt14; + int8_t minZoom; + int8_t casingMinZoom; +}; + +constexpr RoadSpec ROAD_SPECS[] = { + { 0.0f, 99, 99 }, // None + { 4.5f, 0, 10 }, // Motorway + { 4.0f, 5, 11 }, // Trunk + { 3.5f, 8, 12 }, // Primary + { 3.0f, 9, 12 }, // Secondary + { 2.6f, 11, 13 }, // Tertiary + { 1.8f, 13, 14 }, // Minor + { 1.1f, 14, 15 }, // Service + { 0.8f, 14, 99 }, // Path + { 1.0f, 10, 99 }, // Rail + { 1.6f, 14, 99 }, // Pier +}; + +RoadKind roadKind(Value featureClass) { + switch (featureClass) { + case Value::motorway: + return RoadKind::Motorway; + case Value::trunk: + return RoadKind::Trunk; + case Value::primary: + return RoadKind::Primary; + case Value::secondary: + return RoadKind::Secondary; + case Value::tertiary: + return RoadKind::Tertiary; + case Value::minor: + case Value::raceway: + case Value::busway: + return RoadKind::Minor; + case Value::service: + return RoadKind::Service; + case Value::path: + case Value::track: + return RoadKind::Path; + case Value::rail: + case Value::transit: + return RoadKind::Rail; + case Value::pier: + return RoadKind::Pier; + default: + return RoadKind::None; + } +} + +/** Line widths grow slower than the map below z14 and close to the map above it. */ +float zoomScale(int zoom) { + const float steps = static_cast(zoom - 14); + return std::pow(2.0f, steps * (zoom < 14 ? 0.55f : 0.85f)); +} + +bool roadStyle(const FeatureInfo& info, Stage stage, int zoom, const Palette& palette, DrawStyle& style) { + const RoadKind kind = roadKind(info.featureClass); + if (kind == RoadKind::None) { + return false; + } + const RoadSpec& spec = ROAD_SPECS[static_cast(kind)]; + if (zoom < spec.minZoom) { + return false; + } + if (info.geometryType == GEOMETRY_POLYGON) { + if (kind != RoadKind::Pier || stage != Stage::RoadFill) { + return false; + } + style = { palette.pier, 255, 0.0f, PATTERN_NONE }; + return true; + } + if (info.geometryType != GEOMETRY_LINE) { + return false; + } + const bool tunnel = info.brunnel == Value::tunnel; + float width = std::fmax(0.6f, spec.widthAt14 * zoomScale(zoom)); + if (kind == RoadKind::Path) { + width = std::fmin(1.4f, width); + } + uint16_t fill; + uint16_t casing; + switch (kind) { + case RoadKind::Motorway: + fill = palette.motorway; + casing = palette.motorwayCasing; + break; + case RoadKind::Trunk: + fill = palette.trunk; + casing = palette.trunkCasing; + break; + case RoadKind::Primary: + fill = palette.primary; + casing = palette.primaryCasing; + break; + case RoadKind::Secondary: + fill = palette.secondary; + casing = palette.secondaryCasing; + break; + case RoadKind::Path: + fill = palette.path; + casing = palette.path; + break; + case RoadKind::Rail: + fill = palette.rail; + casing = palette.rail; + break; + case RoadKind::Pier: + fill = palette.pier; + casing = palette.minorCasing; + break; + default: + fill = palette.minor; + casing = palette.minorCasing; + break; + } + if (stage == Stage::RoadCasing) { + if (tunnel || zoom < spec.casingMinZoom) { + return false; + } + style = { casing, 255, width + std::fmax(1.0f, width * 0.35f), PATTERN_NONE }; + return true; + } + // Roads without a casing at this zoom level use the casing colour, so white roads stay visible + const bool cased = zoom >= spec.casingMinZoom && kind != RoadKind::Path && kind != RoadKind::Rail; + uint8_t opacity = tunnel ? 110 : 255; + if (kind == RoadKind::Path) { + opacity = zoom < 15 ? 120 : 190; + } + style = { cased || kind == RoadKind::Path || kind == RoadKind::Rail ? fill : casing, opacity, width, PATTERN_NONE }; + return true; +} + +bool waterwayStyle(const FeatureInfo& info, int zoom, const Palette& palette, DrawStyle& style) { + if (info.geometryType != GEOMETRY_LINE) { + return false; + } + float width; + int minZoom; + switch (info.featureClass) { + case Value::river: + width = 2.2f; + minZoom = 8; + break; + case Value::canal: + width = 1.8f; + minZoom = 11; + break; + case Value::stream: + width = 0.9f; + minZoom = 13; + break; + default: + width = 0.7f; + minZoom = 14; + break; + } + if (zoom < minZoom) { + return false; + } + style = { palette.water, static_cast(info.brunnel == Value::tunnel ? 90 : 255), std::fmax(0.6f, width * zoomScale(zoom)), PATTERN_NONE }; + return true; +} + +bool areaColor(const FeatureInfo& info, const Palette& palette, uint16_t& color, uint8_t& opacity) { + opacity = 255; + switch (info.layer) { + case Layer::Water: + color = palette.water; + return true; + case Layer::Park: + color = palette.park; + opacity = 140; + return true; + case Layer::Building: + color = palette.building; + return true; + case Layer::Landcover: + switch (info.featureClass) { + case Value::wood: + color = palette.wood; + return true; + case Value::grass: + color = info.subclass == Value::park || info.subclass == Value::garden ? palette.park : palette.grass; + return true; + case Value::farmland: + color = palette.farmland; + return true; + case Value::wetland: + color = palette.wetland; + return true; + case Value::sand: + color = palette.sand; + return true; + case Value::ice: + color = palette.ice; + return true; + case Value::rock: + color = palette.rock; + return true; + default: + return false; + } + case Layer::Landuse: + switch (info.featureClass) { + case Value::residential: + case Value::suburb: + case Value::quarter: + case Value::neighbourhood: + color = palette.residential; + opacity = 150; + return true; + case Value::commercial: + case Value::retail: + color = palette.commercial; + return true; + case Value::industrial: + case Value::garages: + case Value::railway: + case Value::dam: + color = palette.industrial; + return true; + case Value::cemetery: + color = palette.cemetery; + return true; + case Value::hospital: + color = palette.hospital; + return true; + case Value::school: + case Value::university: + case Value::college: + case Value::kindergarten: + color = palette.school; + return true; + case Value::pitch: + case Value::stadium: + case Value::playground: + case Value::track: + case Value::theme_park: + case Value::zoo: + color = palette.pitch; + return true; + case Value::military: + color = palette.military; + return true; + default: + return false; + } + default: + return false; + } +} + +bool binaryWaterwayStyle(const FeatureInfo& info, int zoom, DrawStyle& style) { + if (info.geometryType != GEOMETRY_LINE) { + return false; + } + float width; + int minZoom; + switch (info.featureClass) { + case Value::river: + width = 2.2f; + minZoom = 8; + break; + case Value::canal: + width = 1.8f; + minZoom = 11; + break; + case Value::stream: + width = 0.9f; + minZoom = 13; + break; + default: + width = 0.7f; + minZoom = 14; + break; + } + if (zoom < minZoom) { + return false; + } + width = std::fmax(1.0f, width * zoomScale(zoom)); + // A river has to stay clearly wider than a stream at 1 bit + if (info.featureClass == Value::river) { + width = std::fmax(1.5f, width); + } + style = { rgb(0x000000), 255, width, PATTERN_NONE }; + return true; +} + +bool binaryRoadStyle(const FeatureInfo& info, Stage stage, int zoom, DrawStyle& style) { + const RoadKind kind = roadKind(info.featureClass); + if (kind == RoadKind::None) { + return false; + } + const RoadSpec& spec = ROAD_SPECS[static_cast(kind)]; + if (zoom < spec.minZoom) { + return false; + } + if (info.geometryType == GEOMETRY_POLYGON) { + if (kind != RoadKind::Pier || stage != Stage::RoadFill) { + return false; + } + style = { rgb(0x000000), 255, 0.0f, PATTERN_NONE }; + return true; + } + if (info.geometryType != GEOMETRY_LINE) { + return false; + } + // Paths and rails are already thin, a white casing would erase them + if (kind == RoadKind::Path || kind == RoadKind::Rail) { + if (stage == Stage::RoadCasing) { + return false; + } + style = { rgb(0x000000), 255, 1.0f, PATTERN_NONE }; + return true; + } + const float width = std::fmax(0.6f, spec.widthAt14 * zoomScale(zoom)); + if (stage == Stage::RoadCasing) { + if (info.brunnel == Value::tunnel || zoom < spec.casingMinZoom) { + return false; + } + // One pixel wider than in colour, so the black fill never touches a neighbouring pattern + style = { rgb(0xFFFFFF), 255, width + std::fmax(1.0f, width * 0.35f) + 1.0f, PATTERN_NONE }; + return true; + } + style = { rgb(0x000000), 255, std::fmax(1.0f, width), PATTERN_NONE }; + return true; +} + +/** The 1-bit style: no greys, no anti-aliasing, and pattern fills to suggest area colours. */ +bool binaryStyle(const FeatureInfo& info, Stage stage, int zoom, DrawStyle& style) { + switch (stage) { + case Stage::Water: + if (info.geometryType != GEOMETRY_POLYGON) { + return false; + } + style = { rgb(0x000000), 255, 0.0f, PATTERN_LINES }; + return true; + case Stage::Landcover: + if (info.geometryType != GEOMETRY_POLYGON) { + return false; + } + switch (info.featureClass) { + case Value::wood: + style = { rgb(0x000000), 255, 0.0f, PATTERN_DOTS }; + return true; + case Value::wetland: + style = { rgb(0x000000), 255, 0.0f, PATTERN_LINES }; + return true; + default: + // Grass, farmland, sand, ice and rock stay white, like the background + return false; + } + case Stage::Park: + if (info.geometryType != GEOMETRY_POLYGON) { + return false; + } + style = { rgb(0x000000), 255, 0.0f, PATTERN_DIAGONAL }; + return true; + case Stage::Landuse: + // Residential, commercial, industrial and the like are left white + return false; + case Stage::Waterway: + return binaryWaterwayStyle(info, zoom, style); + case Stage::Aeroway: + // The aerodrome itself is white; only runways and taxiways are drawn + if (info.geometryType == GEOMETRY_LINE && (info.featureClass == Value::runway || info.featureClass == Value::taxiway)) { + if (zoom < 11) { + return false; + } + const float width = info.featureClass == Value::runway ? 6.0f : 2.0f; + style = { rgb(0x000000), 255, std::fmax(0.8f, width * zoomScale(zoom)), PATTERN_NONE }; + return true; + } + return false; + case Stage::Boundary: + if (info.geometryType != GEOMETRY_LINE || info.maritime || info.adminLevel < 0 || info.adminLevel > 4) { + return false; + } + style = { rgb(0x000000), 255, 1.0f, PATTERN_NONE }; + return true; + case Stage::Building: + // Below z15 an outline would merge the buildings into a black block + if (info.geometryType != GEOMETRY_POLYGON || zoom < 15) { + return false; + } + style = { rgb(0x000000), 255, 1.0f, PATTERN_NONE }; + return true; + case Stage::RoadCasing: + case Stage::RoadFill: + return binaryRoadStyle(info, stage, zoom, style); + default: + return false; + } +} + +} // namespace + +Layer stageLayer(Stage stage) { + switch (stage) { + case Stage::Landcover: + return Layer::Landcover; + case Stage::Landuse: + return Layer::Landuse; + case Stage::Park: + return Layer::Park; + case Stage::Water: + return Layer::Water; + case Stage::Waterway: + return Layer::Waterway; + case Stage::Aeroway: + return Layer::Aeroway; + case Stage::Boundary: + return Layer::Boundary; + case Stage::Building: + return Layer::Building; + case Stage::RoadCasing: + case Stage::RoadFill: + return Layer::Transportation; + default: + return Layer::Unknown; + } +} + +bool patternBit(uint8_t pattern, int x, int y) { + // Periods of 4 and 8 divide the 256-pixel tile, so patterns join across tiles; negative coordinates stay in range + const int px = ((x % 8) + 8) % 8; + const int py = ((y % 8) + 8) % 8; + switch (pattern) { + case PATTERN_DOTS: + return px % 4 == 0 && py % 4 == 0; + case PATTERN_LINES: + // Dashed lines, 1 in 8 black: water reads as water without turning grey on a small screen + return py % 4 == 0 && px % 2 == 0; + case PATTERN_DIAGONAL: + return (px + py) % 8 == 0; + case PATTERN_DOTS_OFFSET: + return px % 4 == 2 && py % 4 == 2; + default: + return false; + } +} + +void styleCacheId(const Palette& palette, char* out, size_t size) { + char kind = 'c'; + if (palette.binary) { + kind = 'b'; + } else if (&palette == &monochromePalette()) { + kind = 'm'; + } + snprintf(out, size, "%c%u", kind, static_cast(STYLE_VERSION)); +} + +const Palette& colorPalette() { + return COLOR_PALETTE; +} + +const Palette& monochromePalette() { + return MONOCHROME_PALETTE; +} + +const Palette& binaryPalette() { + return BINARY_PALETTE; +} + +bool styleFor(const FeatureInfo& info, Stage stage, int zoom, const Palette& palette, DrawStyle& style) { + if (palette.binary) { + return binaryStyle(info, stage, zoom, style); + } + switch (stage) { + case Stage::Landcover: + case Stage::Landuse: + case Stage::Park: + case Stage::Water: + case Stage::Building: { + if (info.geometryType != GEOMETRY_POLYGON) { + return false; + } + if (stage == Stage::Building && zoom < 13) { + return false; + } + uint16_t color; + uint8_t opacity; + if (!areaColor(info, palette, color, opacity)) { + return false; + } + style = { color, opacity, 0.0f, PATTERN_NONE }; + return true; + } + case Stage::Waterway: + return waterwayStyle(info, zoom, palette, style); + case Stage::Aeroway: + if (info.geometryType == GEOMETRY_POLYGON) { + const bool paved = info.featureClass == Value::runway || info.featureClass == Value::taxiway || info.featureClass == Value::apron; + style = { paved ? palette.runway : palette.aerodrome, 255, 0.0f, PATTERN_NONE }; + return info.featureClass != Value::None; + } + if (info.geometryType == GEOMETRY_LINE && (info.featureClass == Value::runway || info.featureClass == Value::taxiway)) { + const float width = info.featureClass == Value::runway ? 6.0f : 2.0f; + style = { palette.runway, 255, std::fmax(0.8f, width * zoomScale(zoom)), PATTERN_NONE }; + return zoom >= 11; + } + return false; + case Stage::Boundary: + if (info.geometryType != GEOMETRY_LINE || info.maritime || info.adminLevel < 0 || info.adminLevel > 4) { + return false; + } + style = { palette.boundary, 170, info.adminLevel <= 2 ? 1.4f : 0.9f, PATTERN_NONE }; + return true; + case Stage::RoadCasing: + case Stage::RoadFill: + return roadStyle(info, stage, zoom, palette, style); + default: + return false; + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/Style.h b/Apps/Maps/main/Source/render/Style.h new file mode 100644 index 0000000..d81501b --- /dev/null +++ b/Apps/Maps/main/Source/render/Style.h @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../mvt/Schema.h" + +#include +#include + +namespace maps { + +/** Draw stages in painting order. Each stage draws one kind of feature of one layer. */ +enum class Stage : uint8_t { + Landcover, + Landuse, + Park, + Water, + Waterway, + Aeroway, + Boundary, + Building, + RoadCasing, + RoadFill, + Count +}; + +Layer stageLayer(Stage stage); + +/** Changes whenever the style changes, so tiles rendered with an older style are rendered again. */ +constexpr uint32_t STYLE_VERSION = 4; + +/** Pattern numbers for DrawStyle::pattern, all with period 4 so they join across tile edges. */ +constexpr uint8_t PATTERN_NONE = 0; +constexpr uint8_t PATTERN_DOTS = 1; +constexpr uint8_t PATTERN_LINES = 2; +constexpr uint8_t PATTERN_DIAGONAL = 3; +constexpr uint8_t PATTERN_DOTS_OFFSET = 4; + +/** True where a pixel belongs to the pattern, in the coordinates of the whole map so tiles stay aligned. */ +bool patternBit(uint8_t pattern, int x, int y); + +struct Palette { + uint16_t background; + uint16_t placeholder; + uint16_t water; + uint16_t wood; + uint16_t grass; + uint16_t farmland; + uint16_t wetland; + uint16_t sand; + uint16_t ice; + uint16_t rock; + uint16_t residential; + uint16_t commercial; + uint16_t industrial; + uint16_t cemetery; + uint16_t hospital; + uint16_t school; + uint16_t pitch; + uint16_t military; + uint16_t park; + uint16_t building; + uint16_t aerodrome; + uint16_t runway; + uint16_t boundary; + uint16_t motorway; + uint16_t motorwayCasing; + uint16_t trunk; + uint16_t trunkCasing; + uint16_t primary; + uint16_t primaryCasing; + uint16_t secondary; + uint16_t secondaryCasing; + uint16_t minor; + uint16_t minorCasing; + uint16_t path; + uint16_t rail; + uint16_t pier; + uint16_t text; + uint16_t halo; + uint16_t marker; + /** Draw only pure black and white, using patterns instead of greys. */ + bool binary; +}; + +const Palette& colorPalette(); +/** High contrast palette for monochrome and grayscale displays. */ +const Palette& monochromePalette(); +/** Pure black and white for 1-bit displays, where anti-aliased greys would turn invisible. */ +const Palette& binaryPalette(); + +/** A short, 8.3-safe name for the style and palette, the SD card cache folder of their rendered tiles. */ +void styleCacheId(const Palette& palette, char* out, size_t size); + +struct DrawStyle { + uint16_t color; + uint8_t opacity; + /** Filled polygon when 0, otherwise a stroke of this width in pixels. */ + float lineWidth; + /** PATTERN_* number for pattern fills, PATTERN_NONE for a solid fill. */ + uint8_t pattern; +}; + +/** + * Picks how a feature is drawn in a stage. zoom is the zoom level of the rendered tile, which can be above the + * zoom level of the source data. Returns false when the feature is not drawn in this stage. + */ +bool styleFor(const FeatureInfo& info, Stage stage, int zoom, const Palette& palette, DrawStyle& style); + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/TileCache.cpp b/Apps/Maps/main/Source/render/TileCache.cpp new file mode 100644 index 0000000..45f29d1 --- /dev/null +++ b/Apps/Maps/main/Source/render/TileCache.cpp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "TileCache.h" + +#include "../platform/Platform.h" +#include "../tiles/TileMath.h" + +namespace maps { + +TileCache::~TileCache() { + for (int i = 0; i < count; i++) { + largeFree(slots[i].pixels); + } + largeFree(slots); +} + +int TileCache::init(int maxSlots) { + slots = static_cast(largeAlloc(sizeof(Slot) * maxSlots)); + if (slots == nullptr) { + return 0; + } + count = 0; + for (int i = 0; i < maxSlots; i++) { + auto* pixels = static_cast(largeAlloc(TILE_BYTES)); + if (pixels == nullptr) { + break; + } + slots[count++] = { { -1, 0, 0 }, 0, false, false, false, 0, pixels }; + } + return count; +} + +const uint16_t* TileCache::find(const TileKey& tile) { + for (int i = 0; i < count; i++) { + if (slots[i].valid && slots[i].key == tile) { + slots[i].lastUse = ++useCounter; + return slots[i].pixels; + } + } + return nullptr; +} + +int TileCache::sourceZoomOf(const TileKey& tile) const { + for (int i = 0; i < count; i++) { + if (slots[i].valid && slots[i].key == tile) { + return slots[i].sourceZoom; + } + } + return -1; +} + +const uint16_t* TileCache::peek(const TileKey& tile) const { + for (int i = 0; i < count; i++) { + if (slots[i].valid && slots[i].key == tile) { + return slots[i].pixels; + } + } + return nullptr; +} + +bool TileCache::contains(const TileKey& tile) const { + for (int i = 0; i < count; i++) { + if (slots[i].valid && !slots[i].stale && slots[i].key == tile) { + return true; + } + } + return false; +} + +uint16_t* TileCache::reserve(const TileKey& tile) { + if (count == 0) { + return nullptr; + } + int victim = -1; + for (int i = 0; i < count; i++) { + if (slots[i].reserved || slots[i].pixels == nullptr) { + continue; + } + if (!slots[i].valid) { + victim = i; + break; + } + if (victim < 0 || slots[i].lastUse < slots[victim].lastUse) { + victim = i; + } + } + if (victim < 0) { + return nullptr; + } + slots[victim].valid = false; + slots[victim].reserved = true; + slots[victim].key = tile; + return slots[victim].pixels; +} + +void TileCache::commit(const TileKey& tile, int sourceZoom) { + int committed = -1; + for (int i = 0; i < count && committed < 0; i++) { + if (slots[i].reserved && slots[i].key == tile) { + slots[i].valid = true; + slots[i].stale = false; + slots[i].sourceZoom = static_cast(sourceZoom); + slots[i].reserved = false; + slots[i].lastUse = ++useCounter; + committed = i; + } + } + // The stale copy it replaces goes + for (int i = 0; i < count && committed >= 0; i++) { + if (i != committed && slots[i].valid && slots[i].key == tile) { + slots[i].valid = false; + } + } +} + +void TileCache::release(const TileKey& tile) { + for (int i = 0; i < count; i++) { + if (slots[i].reserved && slots[i].key == tile) { + slots[i].reserved = false; + return; + } + } +} + +void TileCache::dropMagnified() { + for (int i = 0; i < count; i++) { + const int best = slots[i].key.z < MAX_SOURCE_ZOOM ? slots[i].key.z : MAX_SOURCE_ZOOM; + if (slots[i].valid && slots[i].sourceZoom < best) { + slots[i].stale = true; + } + } +} + +void TileCache::dropMagnified(const TileKey* arrived, int count) { + for (int i = 0; i < count; i++) { + const TileKey& tile = arrived[i]; + for (int j = 0; j < this->count; j++) { + Slot& slot = slots[j]; + if (slot.valid && tile.z >= slot.sourceZoom && tile.z <= slot.key.z && ancestorOf(slot.key, tile.z) == tile) { + slot.stale = true; + } + } + } +} + +bool TileCache::freeSlot() { + int victim = -1; + for (int i = 0; i < count; i++) { + if (slots[i].reserved || slots[i].pixels == nullptr) { + continue; + } + if (!slots[i].valid) { + victim = i; + break; + } + if (victim < 0 || slots[i].lastUse < slots[victim].lastUse) { + victim = i; + } + } + if (victim < 0) { + return false; + } + largeFree(slots[victim].pixels); + slots[victim].pixels = nullptr; + slots[victim].valid = false; + freed++; + return true; +} + +int TileCache::regrow() { + for (int i = 0; i < count && freed > 0; i++) { + if (slots[i].pixels != nullptr) { + continue; + } + auto* pixels = static_cast(largeAlloc(TILE_BYTES)); + if (pixels == nullptr) { + break; + } + slots[i] = { { -1, 0, 0 }, 0, false, false, false, 0, pixels }; + freed--; + } + return freed; +} + +void TileCache::clear() { + for (int i = 0; i < count; i++) { + if (!slots[i].reserved) { + slots[i].valid = false; + } + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/TileCache.h b/Apps/Maps/main/Source/render/TileCache.h new file mode 100644 index 0000000..f11ace2 --- /dev/null +++ b/Apps/Maps/main/Source/render/TileCache.h @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../tiles/TileMath.h" + +#include + +namespace maps { + +/** Rendered tiles in a fixed number of slots, the least recently used slot is reused first. */ +class TileCache { +public: + static constexpr int TILE_BYTES = TILE_SIZE * TILE_SIZE * 2; + + ~TileCache(); + + /** Allocates up to maxSlots tiles, fewer when memory runs out. Returns the number of slots. */ + int init(int maxSlots); + + /** The pixels of a cached tile, marking it as recently used, or nullptr. Stale tiles are found too. */ + const uint16_t* find(const TileKey& tile); + /** Whether a current (not stale) copy of the tile is cached, so it needs no drawing. */ + bool contains(const TileKey& tile) const; + /** The pixels of a cached tile without marking it as used. */ + const uint16_t* peek(const TileKey& tile) const; + /** The zoom level of the vector tile a cached tile was drawn from, -1 when it is not cached. */ + int sourceZoomOf(const TileKey& tile) const; + + /** A slot for a new tile, which is not findable until commit() and is not reused until commit() or release(). */ + uint16_t* reserve(const TileKey& tile); + /** sourceZoom is the zoom level of the vector tile the pixels were drawn from. Replaces a stale copy. */ + void commit(const TileKey& tile, int sourceZoom); + void release(const TileKey& tile); + + void clear(); + /** + * Marks tiles drawn magnified from a lower zoom level as stale, after better data may have arrived. They stay + * on screen until they are drawn again, so the map does not blink while a download stores tiles. + */ + void dropMagnified(); + /** + * The same, only for tiles that one of the arrived vector tiles covers at a better zoom level than they were drawn + * from, or that were drawn from an arrived tile that replaced an older version (an update). + */ + void dropMagnified(const TileKey* arrived, int count); + /** The number of tiles it can hold now: slots given back with freeSlot() do not count. */ + int slotCount() const { return count - freed; } + /** + * Gives the memory of one slot back to the heap, the least recently used one that no tile is being drawn into, + * for a download that needs a large block. Returns false when no slot can go. + */ + bool freeSlot(); + /** Takes memory for the slots given back again, as far as there is memory. Returns how many are still missing. */ + int regrow(); + /** How many slots were given back. */ + int freedSlots() const { return freed; } + +private: + struct Slot { + TileKey key; + uint32_t lastUse; + bool valid; + bool stale; + bool reserved; + int8_t sourceZoom; + uint16_t* pixels; + }; + + Slot* slots = nullptr; + int count = 0; + int freed = 0; + uint32_t useCounter = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/TileCodec.cpp b/Apps/Maps/main/Source/render/TileCodec.cpp new file mode 100644 index 0000000..ceb0f5a --- /dev/null +++ b/Apps/Maps/main/Source/render/TileCodec.cpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "TileCodec.h" + +namespace maps { + +namespace { + +constexpr uint16_t RUN_FLAG = 0x8000; +constexpr size_t MAX_TOKEN = 0x7FFF; +constexpr size_t MIN_RUN = 3; + +} // namespace + +size_t encodeTile(const uint16_t* pixels, size_t count, uint16_t* out, size_t outCapacity) { + size_t used = 0; + size_t position = 0; + while (position < count) { + size_t run = 1; + while (position + run < count && run < MAX_TOKEN && pixels[position + run] == pixels[position]) { + run++; + } + if (run >= MIN_RUN) { + if (used + 2 > outCapacity) { + return 0; + } + out[used++] = static_cast(RUN_FLAG | run); + out[used++] = pixels[position]; + position += run; + continue; + } + // Literal pixels up to the next run of MIN_RUN equal pixels + size_t literal = 0; + while (position + literal < count && literal < MAX_TOKEN) { + const size_t next = position + literal; + if (next + MIN_RUN <= count && pixels[next] == pixels[next + 1] && pixels[next] == pixels[next + 2]) { + break; + } + literal++; + } + if (used + 1 + literal > outCapacity) { + return 0; + } + out[used++] = static_cast(literal); + for (size_t i = 0; i < literal; i++) { + out[used++] = pixels[position + i]; + } + position += literal; + } + return used; +} + +bool decodeTile(const uint16_t* data, size_t dataCount, uint16_t* pixels, size_t count) { + size_t read = 0; + size_t written = 0; + while (read < dataCount && written < count) { + const uint16_t token = data[read++]; + const size_t length = token & MAX_TOKEN; + if (length == 0 || written + length > count) { + return false; + } + if (token & RUN_FLAG) { + if (read >= dataCount) { + return false; + } + const uint16_t pixel = data[read++]; + for (size_t i = 0; i < length; i++) { + pixels[written++] = pixel; + } + } else { + if (read + length > dataCount) { + return false; + } + for (size_t i = 0; i < length; i++) { + pixels[written++] = data[read++]; + } + } + } + return written == count && read == dataCount; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/TileCodec.h b/Apps/Maps/main/Source/render/TileCodec.h new file mode 100644 index 0000000..ea0aa7d --- /dev/null +++ b/Apps/Maps/main/Source/render/TileCodec.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace maps { + +/** + * Run-length coding of rendered RGB565 tiles for the SD card cache, where reading is the slow part. + * The data is a sequence of 16-bit tokens: a token with the top bit set repeats the next pixel (token & 0x7FFF) + * times, otherwise the token counts the literal pixels that follow. + */ +size_t encodeTile(const uint16_t* pixels, size_t count, uint16_t* out, size_t outCapacity); +bool decodeTile(const uint16_t* data, size_t dataCount, uint16_t* pixels, size_t count); + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/TileRenderer.cpp b/Apps/Maps/main/Source/render/TileRenderer.cpp new file mode 100644 index 0000000..6f91728 --- /dev/null +++ b/Apps/Maps/main/Source/render/TileRenderer.cpp @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "TileRenderer.h" + +#include "../platform/Platform.h" +#include "PreparedTile.h" + +#include + +namespace maps { + +namespace { + +/** Thinner lines get no round joins or caps, they would not show. */ +constexpr float MIN_JOIN_WIDTH = 1.5f; +/** Cosine of the smallest turn that gets a round join, about 8 degrees. */ +constexpr float JOIN_COSINE = 0.99f; +/** Buildings smaller than this, in pixels both ways, are left out at native z14. */ +constexpr float MIN_BUILDING_PIXELS = 2.0f; + +} // namespace + +TileRenderer::~TileRenderer() { + largeFree(body); +} + +bool TileRenderer::init() { + return decoder.init() && raster.init(TILE_SIZE, TILE_SIZE); +} + +void TileRenderer::setLabelHook(LabelHook hook, void* context) { + labelHook = hook; + labelContext = context; +} + +void TileRenderer::begin(const TileKey& source, const TileKey& target, const Palette& tilePalette, uint16_t* out) { + palette = &tilePalette; + pixels = out; + zoom = target.z; + featureCount = 0; + geometryUs = 0; + fillUs = 0; + buildingUs = 0; + for (int i = 0; i < TILE_SIZE * TILE_SIZE; i++) { + pixels[i] = palette->background; + } + raster.clear(); + cancelled = false; + dz = target.z - source.z; + offsetX = static_cast((target.x - (source.x << dz)) * TILE_SIZE); + offsetY = static_cast((target.y - (source.y << dz)) * TILE_SIZE); +} + +void TileRenderer::finishStats(RenderStats* stats, uint32_t start, uint32_t scanned, uint32_t sweeps) const { + if (stats == nullptr) { + return; + } + stats->totalMs = nowMs() - start; + stats->scanMs = scanned - start; + stats->sweeps = sweeps; + stats->features = featureCount; + stats->geometryMs = static_cast(geometryUs / 1000); + stats->fillMs = static_cast(fillUs / 1000); + stats->geometryUs = static_cast(geometryUs); + stats->fillUs = static_cast(fillUs); + stats->buildingUs = static_cast(buildingUs); +} + +bool TileRenderer::render(ByteInput* input, const TileKey& source, const TileKey& target, const Palette& tilePalette, uint16_t* out, RenderStats* stats) { + const uint32_t start = nowMs(); + preparedExtent = 0; + begin(source, target, tilePalette, out); + + if (!decoder.scan(input)) { + logError("Tile %d/%d/%d could not be read", source.z, source.x, source.y); + return false; + } + const uint32_t scanned = nowMs(); + + // Stages are drawn in order. A sweep reads the tile once and draws consecutive stages as long as their + // layers come later in the file than the layer of the previous stage of that sweep. + uint32_t sweeps = 0; + int next = 0; + constexpr int stageCount = static_cast(Stage::Count); + while (next < stageCount) { + Stage sweepStages[stageCount]; + Layer sweepLayers[stageCount]; + int count = 0; + int lastIndex = -1; + while (next < stageCount) { + const auto candidate = static_cast(next); + const Layer layer = stageLayer(candidate); + const int index = decoder.fileIndex(layer); + if (index < 0) { + next++; + continue; + } + if (index <= lastIndex) { + break; + } + sweepStages[count] = candidate; + sweepLayers[count] = layer; + count++; + lastIndex = index; + next++; + } + if (count == 0) { + break; + } + // One sweep visits each layer once, so its stages all use different layers + for (int i = 0; i < count; i++) { + layerStages[static_cast(sweepLayers[i])] = sweepStages[i]; + } + if (!decoder.sweep(sweepLayers, count, *this)) { + if (!cancelled) { + logError("Tile %d/%d/%d is damaged", source.z, source.x, source.y); + } + return false; + } + sweeps++; + } + + finishStats(stats, start, scanned, sweeps); + if (stats != nullptr) { + stats->decompressedBytes = static_cast(decoder.decompressedSize()); + } + return true; +} + +namespace { + +bool readVarint(const uint8_t*& data, const uint8_t* end, uint64_t& value) { + value = 0; + for (int shift = 0; shift < 64 && data < end; shift += 7) { + const uint8_t byte = *data++; + value |= static_cast(byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + return true; + } + } + return false; +} + +int32_t unzigzag(uint64_t value) { + return static_cast((value >> 1) ^ (~(value & 1) + 1)); +} + +} // namespace + +bool TileRenderer::renderPrepared(const uint8_t* data, size_t size, const TileKey& target, const Palette& tilePalette, uint16_t* out, RenderStats* stats) { + const uint32_t start = nowMs(); + PreparedHeader header {}; + if (!readPreparedHeader(data, size, header) || !isPreparedReadable(header, header.tile, header.sourceSize) || + PREPARED_HEADER_SIZE + header.storedSize > size) { + logError("Prepared tile for %d/%d/%d is damaged", target.z, target.x, target.y); + return false; + } + if (target.z < header.tile.z || !(ancestorOf(target, header.tile.z) == header.tile)) { + return false; + } + const uint8_t* bodyStart = data + PREPARED_HEADER_SIZE; + if ((header.flags & PREPARED_FLAG_DEFLATED) != 0) { + if (header.bodySize > bodyCapacity) { + largeFree(body); + bodyCapacity = (header.bodySize + 0xFFFF) & ~static_cast(0xFFFF); + body = static_cast(largeAlloc(bodyCapacity)); + if (body == nullptr) { + bodyCapacity = 0; + return false; + } + } + MemoryInput stored(bodyStart, header.storedSize); + if (!decoder.inflate(&stored, body, header.bodySize)) { + logError("Prepared tile %d/%d/%d does not inflate", header.tile.z, header.tile.x, header.tile.y); + return false; + } + bodyStart = body; + } else if (header.bodySize != header.storedSize) { + return false; + } + const uint32_t inflated = nowMs(); + const uint8_t* bodyEnd = bodyStart + header.bodySize; + + // Where each section starts, then the stages in draw order + const uint8_t* sectionData[static_cast(Stage::Count)] = {}; + const uint8_t* sectionEnd[static_cast(Stage::Count)] = {}; + const uint8_t* labelSection = nullptr; + size_t labelSize = 0; + int labelCount = 0; + const uint8_t* cursor = bodyStart; + while (cursor < bodyEnd && *cursor != 0xFF) { + const uint8_t id = *cursor++; + uint64_t count; + uint64_t length; + if (!readVarint(cursor, bodyEnd, count) || !readVarint(cursor, bodyEnd, length) || + length > static_cast(bodyEnd - cursor)) { + logError("Prepared tile %d/%d/%d is damaged", header.tile.z, header.tile.x, header.tile.y); + return false; + } + if (id == PREPARED_LABEL_SECTION) { + labelSection = cursor; + labelSize = static_cast(length); + labelCount = static_cast(count); + } else if (id >= static_cast(Stage::Count)) { + logError("Prepared tile %d/%d/%d is damaged", header.tile.z, header.tile.x, header.tile.y); + return false; + } else { + sectionData[id] = cursor; + sectionEnd[id] = cursor + length; + } + cursor += length; + } + + // Labels have the same format from version 3 on, so a version 3 tile keeps its labels until it is regenerated + if (header.version >= 3 && labelHook != nullptr && labelSection != nullptr) { + labelHook(header.tile, labelSection, labelSize, labelCount, labelContext); + } + preparedExtent = header.extent; + begin(header.tile, target, tilePalette, out); + for (int i = 0; i < static_cast(Stage::Count); i++) { + const auto drawStage = static_cast(i); + const int section = static_cast(preparedSection(drawStage)); + if (sectionData[section] != nullptr && !playSection(sectionData[section], sectionEnd[section], drawStage)) { + if (!cancelled) { + logError("Prepared tile %d/%d/%d is damaged", header.tile.z, header.tile.x, header.tile.y); + } + preparedExtent = 0; + return false; + } + } + preparedExtent = 0; + finishStats(stats, start, inflated, 1); + if (stats != nullptr) { + stats->decompressedBytes = header.bodySize; + } + return true; +} + +bool TileRenderer::playSection(const uint8_t* data, const uint8_t* end, Stage drawStage) { + const Layer layer = stageLayer(preparedSection(drawStage)); + layerStages[static_cast(layer)] = drawStage; + TilePoint chunk[64]; + while (data < end) { + if (shouldStop()) { + return false; + } + FeatureInfo info; + info.layer = layer; + const uint8_t flags = *data++; + info.geometryType = flags & 3; + info.maritime = (flags & (1 << 2)) != 0; + info.ramp = (flags & (1 << 3)) != 0; + uint64_t value; + if (!readVarint(data, end, value)) { + return false; + } + info.featureClass = static_cast(value); + if ((flags & (1 << 4)) != 0) { + if (!readVarint(data, end, value)) { + return false; + } + info.subclass = static_cast(value); + } + if ((flags & (1 << 5)) != 0) { + if (!readVarint(data, end, value)) { + return false; + } + info.brunnel = static_cast(value); + } + if ((flags & (1 << 6)) != 0) { + if (data >= end) { + return false; + } + info.adminLevel = static_cast(*data++); + } + uint64_t rings; + if (!readVarint(data, end, rings)) { + return false; + } + const bool drawn = beginFeature(info); + int32_t x = 0; + int32_t y = 0; + for (uint64_t ring = 0; ring < rings; ring++) { + uint64_t ringHeader; + if (!readVarint(data, end, ringHeader)) { + return false; + } + const uint64_t pointCount = ringHeader >> 1; + if (drawn) { + beginRing(); + } + int count = 0; + for (uint64_t i = 0; i < pointCount; i++) { + uint64_t dx; + uint64_t dy; + if (!readVarint(data, end, dx) || !readVarint(data, end, dy)) { + return false; + } + x += unzigzag(dx); + y += unzigzag(dy); + if (!drawn) { + continue; + } + chunk[count++] = { x, y }; + if (count == 64) { + points(chunk, count); + count = 0; + } + } + if (drawn) { + points(chunk, count); + endRing((ringHeader & 1) != 0); + } + } + if (drawn) { + endFeature(); + } + } + return true; +} + +bool TileRenderer::beginFeature(const FeatureInfo& info) { + stage = layerStages[static_cast(info.layer)]; + const int extent = preparedExtent > 0 ? preparedExtent : decoder.extent(info.layer); + scale = static_cast(TILE_SIZE << dz) / static_cast(extent); + if (!styleFor(info, stage, zoom, *palette, style)) { + return false; + } + featureCount++; + featureStartUs = nowUs(); + // At the tile's own z14 buildings are many and small, overzoomed views keep full detail + cheapFeature = stage == Stage::Building && dz == 0 && zoom == MAX_SOURCE_ZOOM; + // The 1-bit palette has no greys to show, so every feature skips the soft edge + raster.setAntialias(!palette->binary && !cheapFeature); + boundsMinX = 1e30f; + boundsMinY = 1e30f; + boundsMaxX = -1e30f; + boundsMaxY = -1e30f; + return true; +} + +void TileRenderer::beginRing() { + firstPoint = true; +} + +void TileRenderer::addPoint(float x, float y) { + if (cheapFeature) { + boundsMinX = std::fmin(boundsMinX, x); + boundsMinY = std::fmin(boundsMinY, y); + boundsMaxX = std::fmax(boundsMaxX, x); + boundsMaxY = std::fmax(boundsMaxY, y); + } + if (firstPoint) { + firstPoint = false; + hasDirection = false; + startX = x; + startY = y; + if (style.lineWidth >= MIN_JOIN_WIDTH) { + raster.addDot(x, y, style.lineWidth * 0.5f); + } + } else if (style.lineWidth > 0.0f) { + const float dx = x - lastX; + const float dy = y - lastY; + const float length = std::sqrt(dx * dx + dy * dy); + if (length < 0.05f) { + return; + } + const float half = style.lineWidth * 0.5f; + raster.addSegment(lastX, lastY, x, y, half); + // A round join only shows where the line turns noticeably + const float directionX = dx / length; + const float directionY = dy / length; + if (hasDirection && style.lineWidth >= MIN_JOIN_WIDTH && directionX * lastDirectionX + directionY * lastDirectionY < JOIN_COSINE) { + raster.addDot(lastX, lastY, half); + } + hasDirection = true; + lastDirectionX = directionX; + lastDirectionY = directionY; + } else { + raster.addLine(lastX, lastY, x, y); + } + lastX = x; + lastY = y; +} + +void TileRenderer::points(const TilePoint* chunk, int count) { + for (int i = 0; i < count; i++) { + addPoint(static_cast(chunk[i].x) * scale - offsetX, static_cast(chunk[i].y) * scale - offsetY); + } +} + +void TileRenderer::endRing(bool closed) { + if (firstPoint) { + return; + } + if (style.lineWidth > 0.0f) { + if (closed) { + raster.addSegment(lastX, lastY, startX, startY, style.lineWidth * 0.5f); + } + if (style.lineWidth >= MIN_JOIN_WIDTH) { + raster.addDot(lastX, lastY, style.lineWidth * 0.5f); + } + } else { + raster.addLine(lastX, lastY, startX, startY); + } +} + +bool TileRenderer::shouldStop() { + if (cancelFlag != nullptr && cancelFlag->load()) { + cancelled = true; + } + return cancelled; +} + +void TileRenderer::endFeature() { + const uint64_t filled = nowUs(); + geometryUs += filled - featureStartUs; + if (cheapFeature && boundsMaxX - boundsMinX < MIN_BUILDING_PIXELS && boundsMaxY - boundsMinY < MIN_BUILDING_PIXELS) { + raster.clear(); + } else { + raster.fill(pixels, TILE_SIZE, style.color, style.opacity, style.pattern); + } + if (cheapFeature) { + raster.setAntialias(true); + } + const uint64_t done = nowUs(); + fillUs += done - filled; + if (stage == Stage::Building) { + buildingUs += done - featureStartUs; + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/render/TileRenderer.h b/Apps/Maps/main/Source/render/TileRenderer.h new file mode 100644 index 0000000..7cc917d --- /dev/null +++ b/Apps/Maps/main/Source/render/TileRenderer.h @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../mvt/MvtDecoder.h" +#include "../tiles/TileMath.h" +#include "Raster.h" +#include "Style.h" + +#include +#include +#include + +namespace maps { + +struct RenderStats { + uint32_t totalMs; + uint32_t scanMs; + uint32_t sweeps; + uint32_t features; + uint32_t decompressedBytes; + /** Time spent decoding the geometry of drawn features and adding their edges. */ + uint32_t geometryMs; + /** Time spent turning coverage into pixels. */ + uint32_t fillMs; + /** The same in microseconds, and the part of geometry and fill spent on buildings. */ + uint32_t geometryUs; + uint32_t fillUs; + uint32_t buildingUs; +}; + +/** + * Renders one 256x256 RGB565 tile from a vector tile file. The source tile can be an ancestor of the target + * tile, which is then drawn magnified from the part of the source that covers it (overzoom). + */ +class TileRenderer final : private GeometrySink { +public: + TileRenderer() = default; + ~TileRenderer() override; + TileRenderer(const TileRenderer&) = delete; + TileRenderer& operator=(const TileRenderer&) = delete; + + bool init(); + bool render(ByteInput* input, const TileKey& source, const TileKey& target, const Palette& palette, uint16_t* pixels, RenderStats* stats); + /** Renders from a prepared tile (see PreparedTile.h), whose header names the source tile. */ + bool renderPrepared(const uint8_t* data, size_t size, const TileKey& target, const Palette& palette, uint16_t* pixels, RenderStats* stats); + + /** Gets the label section of a prepared tile as it is drawn; source is the tile the .prt belongs to. */ + using LabelHook = void (*)(const TileKey& source, const uint8_t* section, size_t size, int count, void* context); + void setLabelHook(LabelHook hook, void* context); + + /** The decoder, lent to a TileConverter so a lane needs only one decompressor. */ + MvtDecoder& tileDecoder() { return decoder; } + + /** A flag that stops the running render when set, which then returns false. */ + void setCancelFlag(const std::atomic* flag) { cancelFlag = flag; } + bool wasCancelled() const { return cancelled; } + +private: + void begin(const TileKey& source, const TileKey& target, const Palette& palette, uint16_t* out); + void finishStats(RenderStats* stats, uint32_t start, uint32_t scanned, uint32_t sweeps) const; + bool playSection(const uint8_t* data, const uint8_t* end, Stage drawStage); + + bool beginFeature(const FeatureInfo& info) override; + void beginRing() override; + void points(const TilePoint* points, int count) override; + void endRing(bool closed) override; + void endFeature() override; + bool shouldStop() override; + + void addPoint(float x, float y); + + MvtDecoder decoder; + Raster raster; + const Palette* palette = nullptr; + uint16_t* pixels = nullptr; + Stage stage = Stage::Landcover; + Stage layerStages[static_cast(Layer::Count)] {}; + int dz = 0; + int zoom = 0; + float scale = 1.0f; + float offsetX = 0.0f; + float offsetY = 0.0f; + DrawStyle style {}; + /** Buildings at native z14: aliased, and skipped when smaller than about 2 px. */ + bool cheapFeature = false; + float boundsMinX = 0.0f; + float boundsMinY = 0.0f; + float boundsMaxX = 0.0f; + float boundsMaxY = 0.0f; + bool firstPoint = true; + float startX = 0.0f; + float startY = 0.0f; + float lastX = 0.0f; + float lastY = 0.0f; + bool hasDirection = false; + float lastDirectionX = 0.0f; + float lastDirectionY = 0.0f; + uint32_t featureCount = 0; + uint64_t featureStartUs = 0; + uint64_t geometryUs = 0; + uint64_t fillUs = 0; + uint64_t buildingUs = 0; + /** Coordinate units per tile of prepared tiles, 0 while rendering a vector tile. */ + int preparedExtent = 0; + uint8_t* body = nullptr; + size_t bodyCapacity = 0; + LabelHook labelHook = nullptr; + void* labelContext = nullptr; + const std::atomic* cancelFlag = nullptr; + bool cancelled = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/GrowArray.h b/Apps/Maps/main/Source/route/GrowArray.h new file mode 100644 index 0000000..2cf5e16 --- /dev/null +++ b/Apps/Maps/main/Source/route/GrowArray.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../platform/Platform.h" + +#include +#include + +namespace maps { + +/** + * A growing array of trivially copyable values in large memory (PSRAM on the device). It never throws: a failed + * growth sets failed() and drops the value. + */ +template +class GrowArray { +public: + GrowArray() = default; + ~GrowArray() { largeFree(data); } + GrowArray(const GrowArray&) = delete; + GrowArray& operator=(const GrowArray&) = delete; + + void clear() { + length = 0; + error = false; + } + /** Clears and frees the memory. */ + void release() { + largeFree(data); + data = nullptr; + capacity = 0; + clear(); + } + bool reserve(size_t count) { + if (count <= capacity) { + return true; + } + size_t next = capacity < 64 ? 64 : capacity; + while (next < count) { + next *= 2; + } + auto* grown = static_cast(largeAlloc(next * sizeof(T))); + if (grown == nullptr) { + error = true; + return false; + } + if (length > 0) { + memcpy(static_cast(grown), data, length * sizeof(T)); + } + largeFree(data); + data = grown; + capacity = next; + return true; + } + /** Room for count values, filled with zero bytes. */ + bool resize(size_t count) { + if (!reserve(count)) { + return false; + } + if (count > length) { + memset(static_cast(data + length), 0, (count - length) * sizeof(T)); + } + length = count; + return true; + } + void push(const T& value) { + if (length < capacity || reserve(length + 1)) { + data[length++] = value; + } + } + void pop() { + if (length > 0) { + length--; + } + } + T& operator[](size_t index) { return data[index]; } + const T& operator[](size_t index) const { return data[index]; } + T* begin() { return data; } + T* end() { return data + length; } + const T* begin() const { return data; } + const T* end() const { return data + length; } + size_t size() const { return length; } + size_t allocated() const { return capacity * sizeof(T); } + bool failed() const { return error; } + +private: + T* data = nullptr; + size_t capacity = 0; + size_t length = 0; + bool error = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/RouteFollower.cpp b/Apps/Maps/main/Source/route/RouteFollower.cpp new file mode 100644 index 0000000..7cc91c9 --- /dev/null +++ b/Apps/Maps/main/Source/route/RouteFollower.cpp @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "RouteFollower.h" + +#include +#include +#include + +namespace maps { + +namespace { + +constexpr float PI_F = 3.14159265f; +/** Directions at a turn are measured over this many metres before and after it, so a kink in the drawing is no turn. */ +constexpr float BEARING_METRES = 15.0f; + +/** Compass bearing in degrees of the step from a to b (graph y grows southwards). */ +float bearingOf(const RoutePoint& a, const RoutePoint& b) { + return std::atan2(static_cast(b.x - a.x), static_cast(a.y - b.y)) * 180.0f / PI_F; +} + +float segmentLength(const RoutePoint& a, const RoutePoint& b) { + const float dx = static_cast(b.x - a.x); + const float dy = static_cast(b.y - a.y); + return std::sqrt(dx * dx + dy * dy); +} + +} // namespace + +Turn turnFor(float degrees) { + const float angle = std::fabs(degrees); + const bool right = degrees > 0.0f; + if (angle < 25.0f) { + return Turn::Straight; + } + if (angle < 50.0f) { + return right ? Turn::SlightRight : Turn::SlightLeft; + } + if (angle < 120.0f) { + return right ? Turn::Right : Turn::Left; + } + if (angle < 160.0f) { + return right ? Turn::SharpRight : Turn::SharpLeft; + } + return Turn::UTurn; +} + +const char* turnPhrase(Turn turn) { + switch (turn) { + case Turn::SlightLeft: + return "keep left"; + case Turn::Left: + return "turn left"; + case Turn::SharpLeft: + return "turn sharp left"; + case Turn::SlightRight: + return "keep right"; + case Turn::Right: + return "turn right"; + case Turn::SharpRight: + return "turn sharp right"; + case Turn::UTurn: + return "turn around"; + case Turn::Arrive: + return "arrive"; + case Turn::Roundabout: + return "enter the roundabout"; + default: + return "continue"; + } +} + +void routeManeuvers(const Route& route, GrowArray& out) { + out.clear(); + const size_t count = route.points.size(); + for (size_t s = 1; s < route.steps.size(); s++) { + const size_t at = std::min(route.steps[s].point, count - 1); + if (at == 0 || at + 1 >= count) { + continue; + } + // A point some metres before and after the junction + size_t before = at; + while (before > 0 && route.distances[at] - route.distances[before] < BEARING_METRES) { + before--; + } + size_t after = at; + while (after + 1 < count && route.distances[after] - route.distances[at] < BEARING_METRES) { + after++; + } + if (before == at || after == at) { + continue; + } + float change = bearingOf(route.points[at], route.points[after]) - bearingOf(route.points[before], route.points[at]); + while (change > 180.0f) { + change -= 360.0f; + } + while (change <= -180.0f) { + change += 360.0f; + } + const Turn turn = turnFor(change); + // Where the road just goes on (another name, the same direction) no instruction is given + if (turn == Turn::Straight) { + continue; + } + out.push({ route.distances[at], turn, static_cast(s) }); + } + out.push({ route.lengthM, Turn::Arrive, -1 }); +} + +void RouteFollower::begin(const Route& route) { + routeManeuvers(route, maneuvers); + current = {}; + current.state = State::Waiting; + current.remainingM = route.lengthM; + current.remainingS = route.seconds; + current.nextTurn = maneuvers.size() > 0 ? maneuvers[0].turn : Turn::Arrive; + current.toTurnM = maneuvers.size() > 0 ? maneuvers[0].atM : route.lengthM; + current.nextStep = maneuvers.size() > 0 ? maneuvers[0].step : -1; + lastSegment = 0; + away = false; + awaySince = 0; + active = route.points.size() >= 2; +} + +const RouteFollower::Status& RouteFollower::update(const Route& route, double lat, double lon, float accuracyM, uint32_t now) { + if (!active || route.points.size() < 2 || current.state == State::Arrived) { + return current; + } + const RoutePoint here = routePointFrom(lat, lon); + const auto metresPerUnit = static_cast(routeMetresPerUnit(here.y)); + const size_t segments = route.points.size() - 1; + // Nearest segment: near the last match first, the whole route when that finds nothing close + auto nearest = [&](size_t first, size_t last, size_t& bestSegment, float& bestT, float& bestDistance) { + bestDistance = INFINITY; + for (size_t i = first; i < last; i++) { + const RoutePoint& a = route.points[i]; + const RoutePoint& b = route.points[i + 1]; + const float dx = static_cast(b.x - a.x); + const float dy = static_cast(b.y - a.y); + const float lengthSquared = dx * dx + dy * dy; + float t = lengthSquared > 0.0f ? ((here.x - a.x) * dx + (here.y - a.y) * dy) / lengthSquared : 0.0f; + t = std::clamp(t, 0.0f, 1.0f); + const float px = a.x + t * dx - here.x; + const float py = a.y + t * dy - here.y; + float distance = std::sqrt(px * px + py * py) * metresPerUnit; + // A route that passes the same street twice: going back along it counts a little more + if (i + 2 < lastSegment) { + distance += 5.0f; + } + if (distance < bestDistance) { + bestDistance = distance; + bestSegment = i; + bestT = t; + } + } + }; + size_t segment = 0; + float t = 0.0f; + float distance = INFINITY; + const size_t windowFirst = lastSegment > 20 ? lastSegment - 20 : 0; + const size_t windowLast = std::min(segments, lastSegment + 400); + nearest(windowFirst, windowLast, segment, t, distance); + const float matchLimit = accuracyM + MATCH_METRES; + if (distance > matchLimit && (windowFirst > 0 || windowLast < segments)) { + nearest(0, segments, segment, t, distance); + } + const float segmentMetres = route.distances[segment + 1] - route.distances[segment]; + const float progress = route.distances[segment] + t * segmentMetres; + current.distanceFromRouteM = distance; + const float offLimit = std::max(OFF_ROUTE_METRES, accuracyM + 25.0f); + if (distance <= matchLimit) { + away = false; + lastSegment = segment; + current.progressM = progress; + const RoutePoint& a = route.points[segment]; + const RoutePoint& b = route.points[segment + 1]; + current.matched = { a.x + static_cast((b.x - a.x) * t), a.y + static_cast((b.y - a.y) * t) }; + current.state = State::OnRoute; + } else if (distance > offLimit) { + if (!away) { + away = true; + awaySince = now; + } + current.state = now - awaySince >= OFF_ROUTE_MS ? State::OffRoute : State::Away; + } + // Arrival: close to the goal, or the end of the route reached on it + const RoutePoint& goal = route.points[route.points.size() - 1]; + const float toGoal = segmentLength(here, goal) * metresPerUnit; + if (toGoal <= ARRIVE_METRES || (current.state == State::OnRoute && route.lengthM - current.progressM <= ARRIVE_METRES)) { + current.state = State::Arrived; + current.remainingM = 0.0f; + current.remainingS = 0.0f; + current.nextTurn = Turn::Arrive; + current.toTurnM = 0.0f; + current.nextStep = -1; + return current; + } + current.remainingM = std::max(0.0f, route.lengthM - current.progressM); + current.remainingS = route.lengthM > 0.0f ? route.seconds * current.remainingM / route.lengthM : 0.0f; + for (const Maneuver& maneuver : maneuvers) { + if (maneuver.atM > current.progressM + 1.0f || maneuver.turn == Turn::Arrive) { + current.nextTurn = maneuver.turn; + current.toTurnM = std::max(0.0f, maneuver.atM - current.progressM); + current.nextStep = maneuver.step; + break; + } + } + return current; +} + +void formatDistance(float metres, char* out, size_t size) { + if (metres < 100.0f) { + snprintf(out, size, "%d m", static_cast(std::floor(metres / 10.0f + 0.5f)) * 10); + } else if (metres < 1000.0f) { + snprintf(out, size, "%d m", static_cast(std::floor(metres / 50.0f + 0.5f)) * 50); + } else if (metres < 10000.0f) { + snprintf(out, size, "%.1f km", metres / 1000.0f); + } else { + snprintf(out, size, "%d km", static_cast(std::floor(metres / 1000.0f + 0.5f))); + } +} + +void formatDuration(float seconds, char* out, size_t size) { + const int minutes = std::max(1, static_cast(std::floor(seconds / 60.0f + 0.5f))); + if (minutes < 60) { + snprintf(out, size, "%d min", minutes); + } else { + snprintf(out, size, "%d h %02d min", minutes / 60, minutes % 60); + } +} + +void RouteFollower::instruction(const Route& route, char* out, size_t size) const { + if (current.state == State::Arrived) { + snprintf(out, size, "You have arrived"); + return; + } + char distance[16]; + formatDistance(current.toTurnM, distance, sizeof(distance)); + if (current.nextTurn == Turn::Arrive) { + snprintf(out, size, "In %s you arrive", distance); + return; + } + char name[64] = ""; + if (current.nextStep >= 0 && static_cast(current.nextStep) < route.steps.size()) { + route.stepName(route.steps[current.nextStep], name, sizeof(name)); + } + const char* phrase = turnPhrase(current.nextTurn); + const bool named = name[0] != '\0'; + if (current.toTurnM < 15.0f && named) { + snprintf(out, size, "Now %s onto %s", phrase, name); + } else if (current.toTurnM < 15.0f) { + snprintf(out, size, "Now %s", phrase); + } else if (named) { + snprintf(out, size, "In %s %s onto %s", distance, phrase, name); + } else { + snprintf(out, size, "In %s %s", distance, phrase); + } +} + +void RouteFollower::remaining(char* out, size_t size) const { + char distance[16]; + char time[24]; + formatDistance(current.remainingM, distance, sizeof(distance)); + formatDuration(current.remainingS, time, sizeof(time)); + snprintf(out, size, "%s \xC2\xB7 %s left", distance, time); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/RouteFollower.h b/Apps/Maps/main/Source/route/RouteFollower.h new file mode 100644 index 0000000..6886a06 --- /dev/null +++ b/Apps/Maps/main/Source/route/RouteFollower.h @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "GrowArray.h" +#include "RouteSearch.h" +#include "RouteTurn.h" + +#include +#include + +namespace maps { + +/** A turn of the route: where it is (distance from the start) and onto which road. */ +struct Maneuver { + float atM; + Turn turn; + /** The step it turns onto (its name and class), -1 for the arrival. */ + int step; +}; + +/** Finds the turns of a route from the angles at its steps (M5c-4). Straight continuations are left out. */ +void routeManeuvers(const Route& route, GrowArray& out); +/** The turn for a change of direction in degrees (positive: to the right). */ +Turn turnFor(float degrees); +/** "turn left", "keep slightly right", ... for an instruction. */ +const char* turnPhrase(Turn turn); + +/** + * Follows a route with positions (M5c-4): snaps a position to the route within accuracy + MATCH_METRES, notices when + * it has been more than OFF_ROUTE_METRES (or accuracy + 25 m) away for OFF_ROUTE_MS, finds the next turn and arrival. + */ +class RouteFollower { +public: + static constexpr float MATCH_METRES = 20.0f; + static constexpr float OFF_ROUTE_METRES = 40.0f; + static constexpr uint32_t OFF_ROUTE_MS = 10000; + static constexpr float ARRIVE_METRES = 25.0f; + + enum class State : uint8_t { Waiting, OnRoute, Away, OffRoute, Arrived }; + + struct Status { + State state; + /** Distance along the route of the matched point, and that point. */ + float progressM; + RoutePoint matched; + float distanceFromRouteM; + float remainingM; + float remainingS; + Turn nextTurn; + float toTurnM; + /** The step the next turn goes onto, -1 for the arrival. */ + int nextStep; + }; + + /** Starts following a route; the maneuvers are worked out once here. */ + void begin(const Route& route); + void end() { active = false; } + bool isActive() const { return active; } + /** A new position (accuracy in metres) at time now. */ + const Status& update(const Route& route, double lat, double lon, float accuracyM, uint32_t now); + const Status& status() const { return current; } + /** The text of the next instruction, e.g. "In 120 m turn left onto Štefánikova". */ + void instruction(const Route& route, char* out, size_t size) const; + /** "2.1 km · 28 min left". */ + void remaining(char* out, size_t size) const; + +private: + bool active = false; + GrowArray maneuvers; + Status current {}; + /** The segment matched last, where the next match looks first. */ + size_t lastSegment = 0; + uint32_t awaySince = 0; + bool away = false; +}; + +/** "120 m", "850 m", "2.4 km". */ +void formatDistance(float metres, char* out, size_t size); +/** "4 min", "1 h 05 min". */ +void formatDuration(float seconds, char* out, size_t size); + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/RouteGraph.cpp b/Apps/Maps/main/Source/route/RouteGraph.cpp new file mode 100644 index 0000000..825f448 --- /dev/null +++ b/Apps/Maps/main/Source/route/RouteGraph.cpp @@ -0,0 +1,1057 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "RouteGraph.h" + +#include "../platform/Platform.h" + +#include +// miniz maps inflate to mz_inflate, which hides MvtDecoder::inflate +#ifdef inflate +#undef inflate +#endif + +#include +#include +#include + +namespace maps { + +namespace { + +constexpr char MAGIC[4] = { 'T', 'M', 'P', 'G' }; +constexpr int GRID_CELL = 32; +constexpr int GRID_SIZE = GRAPH_EXTENT / GRID_CELL; +/** Longer edges are split by a node, so a shape fits TileGraph::shape()'s buffer. */ +constexpr uint32_t MAX_SHAPE_POINTS = 250; +constexpr double EARTH_CIRCUMFERENCE = 40075016.686; +constexpr double PI_D = 3.14159265358979323846; + +/** Rounds to the nearest integer: lroundf is not exported to apps by the firmware. */ +/** hypot and hypotf are not exported to apps either. */ +template +T distanceOf(T dx, T dy) { + return std::sqrt(dx * dx + dy * dy); +} + +int32_t roundUnit(float value) { + return static_cast(std::floor(value + 0.5f)); +} + +uint16_t readU16(const uint8_t* bytes) { + return static_cast(bytes[0] | (bytes[1] << 8)); +} + +uint32_t readU32(const uint8_t* bytes) { + return static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); +} + +uint32_t pointKey(int32_t x, int32_t y) { + return (static_cast(x + 1) << 16) | static_cast(y + 1); +} + +int deflateCallback(const void* buffer, int length, void* user) { + auto* out = static_cast(user); + out->putBytes(buffer, static_cast(length)); + return out->failed() ? 0 : 1; +} + +bool textIs(const char* text, uint16_t length, const char* value) { + return text != nullptr && strlen(value) == length && memcmp(text, value, length) == 0; +} + +uint8_t levelOf(Value brunnel, int8_t layer) { + const uint8_t kind = brunnel == Value::bridge ? 1 : (brunnel == Value::tunnel ? 2 : 0); + return static_cast((kind << 4) | ((layer + 8) & 15)); +} + +} // namespace + +RoadClass roadClassOf(Value featureClass) { + switch (featureClass) { + case Value::motorway: + return RoadClass::Motorway; + case Value::trunk: + return RoadClass::Trunk; + case Value::primary: + return RoadClass::Primary; + case Value::secondary: + return RoadClass::Secondary; + case Value::tertiary: + return RoadClass::Tertiary; + case Value::minor: + return RoadClass::Minor; + case Value::service: + return RoadClass::Service; + case Value::track: + return RoadClass::Track; + case Value::path: + return RoadClass::Path; + default: + return RoadClass::Count; + } +} + +const char* roadClassName(RoadClass roadClass) { + static const char* const NAMES[] = { "motorway", "trunk", "primary", "secondary", "tertiary", "minor", "service", "track", "path" }; + return roadClass < RoadClass::Count ? NAMES[static_cast(roadClass)] : ""; +} + +bool readGraphHeader(const uint8_t* data, size_t size, GraphHeader& header) { + if (data == nullptr || size < GRAPH_HEADER_SIZE || memcmp(data, MAGIC, 4) != 0) { + return false; + } + header.version = readU16(data + 4); + header.flags = readU16(data + 6); + header.tile.z = data[8]; + header.extent = readU16(data + 10); + header.tile.x = static_cast(readU32(data + 12)); + header.tile.y = static_cast(readU32(data + 16)); + header.sourceSize = readU32(data + 20); + header.nodeCount = readU32(data + 24); + header.edgeCount = readU32(data + 28); + header.shapeBytes = readU32(data + 32); + header.nameBytes = readU32(data + 36); + header.bodySize = readU32(data + 40); + header.storedSize = readU32(data + 44); + memcpy(header.sourceVersion, data + 48, PREPARED_VERSION_LENGTH); + header.sourceVersion[PREPARED_VERSION_LENGTH] = '\0'; + return true; +} + +bool isGraphCurrent(const GraphHeader& header, const TileKey& tile, uint32_t sourceSize) { + return header.version == GRAPH_FORMAT_VERSION && header.tile == tile && header.sourceSize == sourceSize && header.extent == GRAPH_EXTENT; +} + +// --------------------------------------------------------------------------------------------------------------- + +void GraphBuilder::trim() { + ring.release(); + ways.release(); + wayPoints.release(); + pointWay.release(); + vertices.release(); + shared.release(); + nodeOf.release(); + nodes.release(); + edges.release(); + shapes.release(); + names.release(); + nameLines.release(); + namePoints.release(); + namePointLine.release(); + cellStart.release(); + cellItems.release(); +} + +bool GraphBuilder::beginFeature(const FeatureInfo& info) { + if (info.geometryType != GEOMETRY_LINE) { + return false; + } + if (info.layer == Layer::TransportationName) { + const RoadClass roadClass = roadClassOf(info.featureClass); + if (info.name == nullptr || info.nameLength == 0 || info.nameLength > 255) { + return false; + } + inNames = true; + currentName = internName(info.name, info.nameLength); + current = {}; + current.roadClass = static_cast(roadClass); + scale = nameScale; + return currentName != 0; + } + const RoadClass roadClass = roadClassOf(info.featureClass); + if (roadClass == RoadClass::Count) { + return false; + } + inNames = false; + current = {}; + current.roadClass = static_cast(roadClass); + current.flags = static_cast((info.brunnel == Value::bridge ? EDGE_BRIDGE : 0) | (info.brunnel == Value::tunnel ? EDGE_TUNNEL : 0) | + (info.ramp ? EDGE_RAMP : 0) | (textIs(info.subclassText, info.subclassTextLength, "steps") ? EDGE_STEPS : 0) | + (textIs(info.subclassText, info.subclassTextLength, "footway") || textIs(info.subclassText, info.subclassTextLength, "pedestrian") ? + EDGE_FOOTWAY : + 0) | + (textIs(info.subclassText, info.subclassTextLength, "cycleway") ? EDGE_CYCLEWAY : 0)); + current.oneway = info.oneway; + current.level = levelOf(info.brunnel, info.layerNumber); + scale = static_cast(GRAPH_EXTENT) / static_cast(decoderExtent); + counters.ways++; + return true; +} + +void GraphBuilder::beginRing() { + ring.clear(); +} + +void GraphBuilder::points(const TilePoint* chunk, int count) { + for (int i = 0; i < count; i++) { + // Lines reach into the tile's buffer: up to a few hundred units outside, well inside int16 + const auto x = static_cast(roundUnit(chunk[i].x * scale)); + const auto y = static_cast(roundUnit(chunk[i].y * scale)); + const Point point { static_cast(std::clamp(x, -30000, 30000)), static_cast(std::clamp(y, -30000, 30000)) }; + if (ring.size() > 0 && ring[ring.size() - 1].x == point.x && ring[ring.size() - 1].y == point.y) { + continue; + } + ring.push(point); + } +} + +void GraphBuilder::endRing(bool closed) { + (void)closed; + if (ring.size() < 2) { + return; + } + if (inNames) { + NameLine line { static_cast(namePoints.size()), static_cast(ring.size()), currentName, current.roadClass }; + for (const Point& point : ring) { + namePoints.push(point); + } + nameLines.push(line); + return; + } + clipAndAdd(); +} + +void GraphBuilder::clipAndAdd() { + // Liang-Barsky per segment, intersections rounded, as in the study + const int lo = 0; + const int hi = GRAPH_EXTENT; + GrowArray& piece = clipped; + piece.clear(); + for (size_t i = 0; i + 1 < ring.size(); i++) { + const float x0 = ring[i].x; + const float y0 = ring[i].y; + const float dx = ring[i + 1].x - x0; + const float dy = ring[i + 1].y - y0; + float t0 = 0.0f; + float t1 = 1.0f; + bool inside = true; + const float p[4] = { -dx, dx, -dy, dy }; + const float q[4] = { x0 - lo, hi - x0, y0 - lo, hi - y0 }; + for (int k = 0; k < 4 && inside; k++) { + if (p[k] == 0.0f) { + inside = q[k] >= 0.0f; + } else { + const float r = q[k] / p[k]; + if (p[k] < 0.0f) { + if (r > t1) { + inside = false; + } else { + t0 = std::max(t0, r); + } + } else { + if (r < t0) { + inside = false; + } else { + t1 = std::min(t1, r); + } + } + } + } + if (!inside) { + if (piece.size() >= 2) { + addPart(piece.begin(), piece.size()); + } + piece.clear(); + continue; + } + const Point a { static_cast(roundUnit(x0 + t0 * dx)), static_cast(roundUnit(y0 + t0 * dy)) }; + const Point b { static_cast(roundUnit(x0 + t1 * dx)), static_cast(roundUnit(y0 + t1 * dy)) }; + if (a.x == b.x && a.y == b.y) { + continue; + } + if (piece.size() > 0 && piece[piece.size() - 1].x == a.x && piece[piece.size() - 1].y == a.y) { + piece.push(b); + } else { + if (piece.size() >= 2) { + addPart(piece.begin(), piece.size()); + } + piece.clear(); + piece.push(a); + piece.push(b); + } + } + if (piece.size() >= 2) { + addPart(piece.begin(), piece.size()); + } +} + +void GraphBuilder::addPart(const Point* points, size_t count) { + // Half-open ownership: a segment lying on the x = 4096 or y = 4096 side belongs to the neighbour tile + size_t start = 0; + for (size_t i = 0; i + 1 <= count; i++) { + const bool last = i + 1 == count; + const bool onFar = !last && ((points[i].x == GRAPH_EXTENT && points[i + 1].x == GRAPH_EXTENT) || + (points[i].y == GRAPH_EXTENT && points[i + 1].y == GRAPH_EXTENT)); + if (!onFar && !last) { + continue; + } + const size_t end = last ? count : i + 1; + if (end - start >= 2) { + Way way = current; + way.first = static_cast(wayPoints.size()); + way.count = static_cast(end - start); + for (size_t j = start; j < end; j++) { + wayPoints.push(points[j]); + } + ways.push(way); + counters.points += way.count; + } + start = i + 1; + } +} + +bool GraphBuilder::indexPoints() { + // Every way vertex by its coordinate: sorted, so the vertices sharing one are next to each other + const size_t count = wayPoints.size(); + if (!pointWay.resize(count) || !vertices.resize(count) || !shared.resize(count)) { + return false; + } + for (size_t w = 0; w < ways.size(); w++) { + for (uint32_t j = 0; j < ways[w].count; j++) { + pointWay[ways[w].first + j] = static_cast(w); + } + } + for (size_t i = 0; i < count; i++) { + vertices[i] = { pointKey(wayPoints[i].x, wayPoints[i].y), static_cast(i) }; + } + std::sort(vertices.begin(), vertices.end(), [](const Vertex& a, const Vertex& b) { + return a.key != b.key ? a.key < b.key : a.point < b.point; + }); + for (size_t i = 0; i < count;) { + size_t j = i; + while (j < count && vertices[j].key == vertices[i].key) { + j++; + } + for (size_t k = i; k < j; k++) { + shared[vertices[k].point] = static_cast(std::min(j - i, 65535)); + } + i = j; + } + return true; +} + +void GraphBuilder::snapJunctions() { + // A grid of the segments, 32 units a cell + const size_t cellCount = static_cast(GRID_SIZE) * GRID_SIZE; + if (!cellStart.resize(cellCount + 1)) { + return; + } + auto cellsOf = [](const Point& a, const Point& b, int& left, int& top, int& right, int& bottom) { + left = std::clamp(std::min(a.x, b.x) / GRID_CELL, 0, GRID_SIZE - 1); + right = std::clamp(std::max(a.x, b.x) / GRID_CELL, 0, GRID_SIZE - 1); + top = std::clamp(std::min(a.y, b.y) / GRID_CELL, 0, GRID_SIZE - 1); + bottom = std::clamp(std::max(a.y, b.y) / GRID_CELL, 0, GRID_SIZE - 1); + }; + for (const Way& way : ways) { + for (uint32_t j = 0; j + 1 < way.count; j++) { + int left; + int top; + int right; + int bottom; + cellsOf(wayPoints[way.first + j], wayPoints[way.first + j + 1], left, top, right, bottom); + for (int cy = top; cy <= bottom; cy++) { + for (int cx = left; cx <= right; cx++) { + cellStart[static_cast(cy) * GRID_SIZE + cx + 1]++; + } + } + } + } + for (size_t i = 1; i <= cellCount; i++) { + cellStart[i] += cellStart[i - 1]; + } + if (!cellItems.resize(cellStart[cellCount])) { + return; + } + { + GrowArray fill; + if (!fill.resize(cellCount)) { + return; + } + for (const Way& way : ways) { + for (uint32_t j = 0; j + 1 < way.count; j++) { + int left; + int top; + int right; + int bottom; + cellsOf(wayPoints[way.first + j], wayPoints[way.first + j + 1], left, top, right, bottom); + for (int cy = top; cy <= bottom; cy++) { + for (int cx = left; cx <= right; cx++) { + const size_t cell = static_cast(cy) * GRID_SIZE + cx; + cellItems[cellStart[cell] + fill[cell]++] = way.first + j; + } + } + } + } + } + struct Insert { + uint32_t segment; + float t; + Point point; + }; + struct Move { + uint32_t point; + Point to; + }; + GrowArray inserts; + GrowArray moves; + for (size_t w = 0; w < ways.size(); w++) { + const Way& way = ways[w]; + for (int end = 0; end < 2; end++) { + const uint32_t index = end == 0 ? way.first : way.first + way.count - 1; + if (shared[index] != 1) { + continue; + } + const Point p = wayPoints[index]; + float bestDistance = static_cast(GRAPH_SNAP_UNITS) + 1e-3f; + uint32_t bestSegment = UINT32_MAX; + float bestT = 0.0f; + const int left = std::clamp((p.x - GRAPH_SNAP_UNITS) / GRID_CELL, 0, GRID_SIZE - 1); + const int right = std::clamp((p.x + GRAPH_SNAP_UNITS) / GRID_CELL, 0, GRID_SIZE - 1); + const int top = std::clamp((p.y - GRAPH_SNAP_UNITS) / GRID_CELL, 0, GRID_SIZE - 1); + const int bottom = std::clamp((p.y + GRAPH_SNAP_UNITS) / GRID_CELL, 0, GRID_SIZE - 1); + for (int cy = top; cy <= bottom; cy++) { + for (int cx = left; cx <= right; cx++) { + const size_t cell = static_cast(cy) * GRID_SIZE + cx; + for (uint32_t k = cellStart[cell]; k < cellStart[cell + 1]; k++) { + const uint32_t segment = cellItems[k]; + const uint32_t otherWay = pointWay[segment]; + if (otherWay == w || ways[otherWay].level != way.level) { + continue; + } + const Point a = wayPoints[segment]; + const Point b = wayPoints[segment + 1]; + const float dx = static_cast(b.x - a.x); + const float dy = static_cast(b.y - a.y); + const float lengthSquared = dx * dx + dy * dy; + float t = lengthSquared > 0.0f ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared : 0.0f; + t = std::clamp(t, 0.0f, 1.0f); + const float distance = distanceOf(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); + if (distance < bestDistance) { + bestDistance = distance; + bestSegment = segment; + bestT = t; + } + } + } + } + if (bestSegment == UINT32_MAX) { + continue; + } + const Point a = wayPoints[bestSegment]; + const Point b = wayPoints[bestSegment + 1]; + const Point q { static_cast(roundUnit(a.x + bestT * (b.x - a.x))), static_cast(roundUnit(a.y + bestT * (b.y - a.y))) }; + if ((q.x == a.x && q.y == a.y) || (q.x == b.x && q.y == b.y)) { + counters.snappedToVertex++; + } else { + counters.snappedToSegment++; + inserts.push({ bestSegment, bestT, q }); + } + moves.push({ index, q }); + } + } + for (const Move& move : moves) { + wayPoints[move.point] = move.to; + } + if (inserts.size() == 0 && moves.size() == 0) { + return; + } + // Rebuild the point list with the inserted foot points, dropping repeated points + std::sort(inserts.begin(), inserts.end(), [](const Insert& a, const Insert& b) { + return a.segment != b.segment ? a.segment < b.segment : a.t < b.t; + }); + GrowArray rebuilt; + GrowArray kept; + size_t next = 0; + for (const Way& way : ways) { + Way copy = way; + copy.first = static_cast(rebuilt.size()); + for (uint32_t j = 0; j < way.count; j++) { + const uint32_t index = way.first + j; + const Point p = wayPoints[index]; + if (rebuilt.size() == copy.first || rebuilt[rebuilt.size() - 1].x != p.x || rebuilt[rebuilt.size() - 1].y != p.y) { + rebuilt.push(p); + } + while (next < inserts.size() && inserts[next].segment == index) { + const Point q = inserts[next].point; + if (rebuilt[rebuilt.size() - 1].x != q.x || rebuilt[rebuilt.size() - 1].y != q.y) { + rebuilt.push(q); + } + next++; + } + } + copy.count = static_cast(rebuilt.size() - copy.first); + if (copy.count >= 2) { + kept.push(copy); + } else { + while (rebuilt.size() > copy.first) { + rebuilt.pop(); + } + } + } + wayPoints.clear(); + for (const Point& p : rebuilt) { + wayPoints.push(p); + } + ways.clear(); + for (const Way& way : kept) { + ways.push(way); + } +} + +bool GraphBuilder::makeNodes() { + if (!indexPoints() || !nodeOf.resize(wayPoints.size())) { + return false; + } + std::fill(nodeOf.begin(), nodeOf.end(), UINT32_MAX); + nodes.clear(); + auto isEnd = [&](uint32_t point) { + const Way& way = ways[pointWay[point]]; + return point == way.first || point == way.first + way.count - 1; + }; + // Union-find over the vertices at one coordinate: same level, or one of them a way end + constexpr size_t MAX_GROUP = 64; + uint8_t parent[MAX_GROUP]; + auto find = [&](uint8_t i) { + while (parent[i] != i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + }; + const size_t count = vertices.size(); + for (size_t i = 0; i < count;) { + size_t j = i; + while (j < count && vertices[j].key == vertices[i].key) { + j++; + } + const size_t size = std::min(j - i, MAX_GROUP); + if (size >= 2) { + for (size_t a = 0; a < size; a++) { + parent[a] = static_cast(a); + } + for (size_t a = 0; a < size; a++) { + for (size_t b = a + 1; b < size; b++) { + const uint32_t pa = vertices[i + a].point; + const uint32_t pb = vertices[i + b].point; + if (isEnd(pa) || isEnd(pb) || ways[pointWay[pa]].level == ways[pointWay[pb]].level) { + parent[find(static_cast(a))] = find(static_cast(b)); + } + } + } + for (size_t a = 0; a < size; a++) { + const uint8_t root = find(static_cast(a)); + // A group of one mid vertex is no node (a bridge over a road) + bool alone = true; + for (size_t b = 0; b < size && alone; b++) { + alone = b == a || find(static_cast(b)) != root; + } + const uint32_t point = vertices[i + a].point; + if (alone && !isEnd(point)) { + continue; + } + // The group's node: the one of its first member + uint32_t node = UINT32_MAX; + for (size_t b = 0; b < a && node == UINT32_MAX; b++) { + if (find(static_cast(b)) == root) { + node = nodeOf[vertices[i + b].point]; + } + } + if (node == UINT32_MAX) { + node = static_cast(nodes.size()); + const Point p = wayPoints[point]; + nodes.push({ static_cast(p.x), static_cast(p.y), ways[pointWay[point]].level }); + } + nodeOf[point] = node; + } + } + i = j; + } + // Way ends not shared with anything + for (const Way& way : ways) { + for (int end = 0; end < 2; end++) { + const uint32_t point = end == 0 ? way.first : way.first + way.count - 1; + if (nodeOf[point] == UINT32_MAX) { + const Point p = wayPoints[point]; + nodeOf[point] = static_cast(nodes.size()); + nodes.push({ static_cast(p.x), static_cast(p.y), way.level }); + } + } + // Long stretches without a node get one, so every shape fits the reader's buffer + uint32_t since = 0; + for (uint32_t j = 1; j + 1 < way.count; j++) { + const uint32_t point = way.first + j; + if (nodeOf[point] != UINT32_MAX) { + since = 0; + } else if (++since >= MAX_SHAPE_POINTS) { + const Point p = wayPoints[point]; + nodeOf[point] = static_cast(nodes.size()); + nodes.push({ static_cast(p.x), static_cast(p.y), way.level }); + since = 0; + } + } + } + return !nodes.failed() && nodes.size() <= 65535; +} + +bool GraphBuilder::makeEdges() { + edges.clear(); + shapes.clear(); + // Metres per unit at a tile row: Mercator scale at its latitude + const double worldUnits = std::ldexp(static_cast(GRAPH_EXTENT), tile.z); + auto metresPerUnit = [&](double y) { + const double globalY = tile.y * static_cast(GRAPH_EXTENT) + y; + const double latitude = std::atan(std::sinh(PI_D * (1.0 - 2.0 * globalY / worldUnits))); + return EARTH_CIRCUMFERENCE / worldUnits * std::cos(latitude); + }; + for (const Way& way : ways) { + uint32_t start = way.first; + double length = 0.0; + for (uint32_t j = 1; j < way.count; j++) { + const uint32_t point = way.first + j; + const Point a = wayPoints[point - 1]; + const Point b = wayPoints[point]; + length += distanceOf(static_cast(b.x - a.x), static_cast(b.y - a.y)) * metresPerUnit((a.y + b.y) * 0.5); + if (nodeOf[point] == UINT32_MAX) { + continue; + } + const uint32_t from = nodeOf[start]; + const uint32_t to = nodeOf[point]; + if (from != to) { + const bool reversed = way.oneway < 0; + GraphEdge edge {}; + edge.from = static_cast(reversed ? to : from); + edge.to = static_cast(reversed ? from : to); + edge.lengthDm = static_cast(std::min(65535.0, std::round(length * 10.0))); + edge.roadClass = way.roadClass; + edge.flags = static_cast(way.flags | (way.oneway != 0 ? EDGE_ONEWAY : 0)); + edge.shapeOffset = static_cast(shapes.size()); + edge.shapeCount = static_cast(point - start - 1); + // Deltas from the edge's from-node along the way to go + int32_t x = reversed ? wayPoints[point].x : wayPoints[start].x; + int32_t y = reversed ? wayPoints[point].y : wayPoints[start].y; + for (uint32_t k = 1; k + 1 <= point - start; k++) { + const uint32_t index = reversed ? point - k : start + k; + shapes.putZigzag(wayPoints[index].x - x); + shapes.putZigzag(wayPoints[index].y - y); + x = wayPoints[index].x; + y = wayPoints[index].y; + } + // The name next to the edge's middle point + const uint32_t middle = start + (point - start) / 2; + const Point m0 = wayPoints[middle]; + const Point m1 = wayPoints[std::min(middle + 1, point)]; + edge.name = nameNear((m0.x + m1.x) / 2, (m0.y + m1.y) / 2, way.roadClass); + counters.named += edge.name != 0 ? 1 : 0; + edges.push(edge); + } + start = point; + length = 0.0; + } + } + return !edges.failed() && !shapes.failed(); +} + +uint32_t GraphBuilder::internName(const char* text, size_t length) { + // Offset + 1 of the name in the name bytes; the same name once + const uint8_t* bytes = names.bytes(); + size_t at = 0; + while (at < names.size()) { + const size_t stored = bytes[at]; + if (stored == length && memcmp(bytes + at + 1, text, length) == 0) { + return static_cast(at + 1); + } + at += 1 + stored; + } + if (names.size() + 1 + length >= 65535) { + return 0; + } + const auto offset = static_cast(names.size()); + names.put(static_cast(length)); + names.putBytes(text, length); + return names.failed() ? 0 : offset + 1; +} + +bool GraphBuilder::buildNameGrid() { + // The street name segments by grid cell, like the snap grid: an edge looks only at the cells around its middle + const size_t cellCount = static_cast(GRID_SIZE) * GRID_SIZE; + cellStart.clear(); + cellItems.clear(); + if (!cellStart.resize(cellCount + 1) || !namePointLine.resize(namePoints.size())) { + return false; + } + for (size_t i = 0; i < nameLines.size(); i++) { + for (uint32_t j = 0; j < nameLines[i].count; j++) { + namePointLine[nameLines[i].first + j] = static_cast(i); + } + } + auto forCells = [&](uint32_t segment, auto visit) { + const Point a = namePoints[segment]; + const Point b = namePoints[segment + 1]; + const int left = std::clamp(std::min(a.x, b.x) / GRID_CELL, 0, GRID_SIZE - 1); + const int right = std::clamp(std::max(a.x, b.x) / GRID_CELL, 0, GRID_SIZE - 1); + const int top = std::clamp(std::min(a.y, b.y) / GRID_CELL, 0, GRID_SIZE - 1); + const int bottom = std::clamp(std::max(a.y, b.y) / GRID_CELL, 0, GRID_SIZE - 1); + for (int cy = top; cy <= bottom; cy++) { + for (int cx = left; cx <= right; cx++) { + visit(static_cast(cy) * GRID_SIZE + cx); + } + } + }; + for (const NameLine& line : nameLines) { + for (uint32_t j = 0; j + 1 < line.count; j++) { + forCells(line.first + j, [&](size_t cell) { cellStart[cell + 1]++; }); + } + } + for (size_t i = 1; i <= cellCount; i++) { + cellStart[i] += cellStart[i - 1]; + } + GrowArray fill; + if (!cellItems.resize(cellStart[cellCount]) || !fill.resize(cellCount)) { + return false; + } + for (const NameLine& line : nameLines) { + for (uint32_t j = 0; j + 1 < line.count; j++) { + const uint32_t segment = line.first + j; + forCells(segment, [&](size_t cell) { cellItems[cellStart[cell] + fill[cell]++] = segment; }); + } + } + return true; +} + +uint16_t GraphBuilder::nameNear(int32_t x, int32_t y, uint8_t roadClass) { + // The nearest street name line; a line of the same class wins against one of another class up to 2 units nearer + float best = static_cast(GRAPH_NAME_UNITS) + 1.0f; + uint32_t bestName = 0; + if (cellStart.size() == 0) { + return 0; + } + const int left = std::clamp(static_cast(x - GRAPH_NAME_UNITS) / GRID_CELL, 0, GRID_SIZE - 1); + const int right = std::clamp(static_cast(x + GRAPH_NAME_UNITS) / GRID_CELL, 0, GRID_SIZE - 1); + const int top = std::clamp(static_cast(y - GRAPH_NAME_UNITS) / GRID_CELL, 0, GRID_SIZE - 1); + const int bottom = std::clamp(static_cast(y + GRAPH_NAME_UNITS) / GRID_CELL, 0, GRID_SIZE - 1); + for (int cy = top; cy <= bottom; cy++) { + for (int cx = left; cx <= right; cx++) { + const size_t cell = static_cast(cy) * GRID_SIZE + cx; + for (uint32_t k = cellStart[cell]; k < cellStart[cell + 1]; k++) { + const uint32_t segment = cellItems[k]; + const NameLine& line = nameLines[namePointLine[segment]]; + const Point a = namePoints[segment]; + const Point b = namePoints[segment + 1]; + const float dx = static_cast(b.x - a.x); + const float dy = static_cast(b.y - a.y); + const float lengthSquared = dx * dx + dy * dy; + float t = lengthSquared > 0.0f ? ((x - a.x) * dx + (y - a.y) * dy) / lengthSquared : 0.0f; + t = std::clamp(t, 0.0f, 1.0f); + const float distance = distanceOf(x - (a.x + t * dx), y - (a.y + t * dy)); + const float score = distance - (line.roadClass == roadClass ? 2.0f : 0.0f); + if (distance <= GRAPH_NAME_UNITS && score < best) { + best = score; + bestName = line.name; + } + } + } + } + return static_cast(bestName); +} + +bool GraphBuilder::build(MvtDecoder& decoder, ByteInput* input, const TileKey& sourceTile, uint32_t sourceSize, const char* sourceVersion, + bool deflate, ByteWriter& out, GraphStats* stats) { + const uint32_t start = nowMs(); + tile = sourceTile; + counters = {}; + failureText = ""; + ways.clear(); + wayPoints.clear(); + nameLines.clear(); + namePoints.clear(); + names.clear(); + cellStart.clear(); + cellItems.clear(); + if (!decoder.scan(input, false)) { + failureText = "reading the tile"; + return false; + } + decoderExtent = decoder.extent(Layer::Transportation); + if (decoderExtent <= 0) { + decoderExtent = GRAPH_EXTENT; + } + const int nameExtent = decoder.extent(Layer::TransportationName); + const Layer layers[2] = { Layer::Transportation, Layer::TransportationName }; + // The name lines need their own scale: set per feature from the layer + nameScale = nameExtent > 0 ? static_cast(GRAPH_EXTENT) / static_cast(nameExtent) : 1.0f; + if (!decoder.sweep(layers, 2, *this)) { + failureText = "decoding the roads"; + return false; + } + if (ways.failed() || wayPoints.failed() || namePoints.failed() || nameLines.failed() || names.failed()) { + failureText = "no memory for the roads"; + return false; + } + if (!indexPoints()) { + failureText = "no memory for the junctions"; + return false; + } + snapJunctions(); + // Each working array goes as soon as it is not needed: a dense city tile left too little PSRAM for the rest + cellStart.release(); + cellItems.release(); + if (!makeNodes()) { + failureText = "too many junctions, or no memory for them"; + return false; + } + vertices.release(); + shared.release(); + pointWay.release(); + if (!buildNameGrid()) { + failureText = "no memory for the street names"; + return false; + } + if (!makeEdges()) { + failureText = "no memory for the edges"; + return false; + } + nodeOf.release(); + cellStart.release(); + cellItems.release(); + namePointLine.release(); + ways.release(); + wayPoints.release(); + nameLines.release(); + namePoints.release(); + ring.release(); + clipped.release(); + for (const GraphNode& node : nodes) { + counters.borderNodes += node.x == 0 || node.y == 0 || node.x == GRAPH_EXTENT || node.y == GRAPH_EXTENT ? 1 : 0; + } + counters.nodes = static_cast(nodes.size()); + counters.edges = static_cast(edges.size()); + if (!write(sourceVersion, sourceSize, deflate, out)) { + return false; + } + if (stats != nullptr) { + *stats = counters; + stats->totalMs = nowMs() - start; + } + return true; +} + +bool GraphBuilder::write(const char* sourceVersion, uint32_t sourceSize, bool deflate, ByteWriter& out) { + const size_t bodySize = nodes.size() * GRAPH_NODE_BYTES + edges.size() * GRAPH_EDGE_BYTES + shapes.size() + names.size(); + out.clear(); + out.putBytes(MAGIC, 4); + out.putU16(GRAPH_FORMAT_VERSION); + out.putU16(deflate ? PREPARED_FLAG_DEFLATED : 0); + out.put(static_cast(tile.z)); + out.put(0); + out.putU16(GRAPH_EXTENT); + out.putU32(static_cast(tile.x)); + out.putU32(static_cast(tile.y)); + out.putU32(sourceSize); + out.putU32(static_cast(nodes.size())); + out.putU32(static_cast(edges.size())); + out.putU32(static_cast(shapes.size())); + out.putU32(static_cast(names.size())); + out.putU32(static_cast(bodySize)); + out.putU32(0); + char version[PREPARED_VERSION_LENGTH] = {}; + if (sourceVersion != nullptr) { + const size_t length = strlen(sourceVersion); + memcpy(version, sourceVersion, length < PREPARED_VERSION_LENGTH ? length : PREPARED_VERSION_LENGTH); + } + out.putBytes(version, PREPARED_VERSION_LENGTH); + // The body in one buffer first: small next to the tile (a dense city tile about 100 KB) + ByteWriter body; + if (!body.reserve(bodySize)) { + failureText = "no memory for the graph file"; + return false; + } + for (const GraphNode& node : nodes) { + body.putU16(node.x); + body.putU16(node.y); + body.put(node.level); + } + for (const GraphEdge& edge : edges) { + body.putU16(edge.from); + body.putU16(edge.to); + body.putU16(edge.lengthDm); + body.put(edge.roadClass); + body.put(edge.flags); + body.putU16(edge.name); + body.putU32(edge.shapeOffset); + body.putU16(edge.shapeCount); + } + body.putBytes(shapes.bytes(), shapes.size()); + body.putBytes(names.bytes(), names.size()); + if (body.failed()) { + failureText = "no memory for the graph file"; + return false; + } + // The compressor needs a 320 KB block: the rest is in the body now + edges.release(); + shapes.release(); + names.release(); + if (!deflate) { + out.putBytes(body.bytes(), body.size()); + } else { + auto* compressor = static_cast(largeAlloc(sizeof(tdefl_compressor))); + bool ok = compressor != nullptr && tdefl_init(compressor, deflateCallback, &out, 128 | TDEFL_WRITE_ZLIB_HEADER) == TDEFL_STATUS_OKAY && + tdefl_compress_buffer(compressor, body.bytes(), body.size(), TDEFL_FINISH) == TDEFL_STATUS_DONE; + largeFree(compressor); + if (!ok) { + failureText = "no 320 KB block of memory for the compressor"; + return false; + } + } + if (out.failed()) { + failureText = "no memory for the graph file"; + return false; + } + out.setU32(44, static_cast(out.size() - GRAPH_HEADER_SIZE)); + counters.bodyBytes = static_cast(bodySize); + counters.storedBytes = static_cast(out.size() - GRAPH_HEADER_SIZE); + return true; +} + +// --------------------------------------------------------------------------------------------------------------- + +void TileGraph::clear() { + nodes.clear(); + edges.clear(); + firstAdjacent.clear(); + adjacent.clear(); + shapes.clear(); + names.clear(); + for (auto& side : border) { + side.clear(); + } +} + +size_t TileGraph::bytes() const { + size_t total = nodes.allocated() + edges.allocated() + firstAdjacent.allocated() + adjacent.allocated() + shapes.allocated() + names.allocated(); + for (const auto& side : border) { + total += side.allocated(); + } + return total; +} + +const char* TileGraph::name(uint32_t edge, uint8_t& length) const { + const uint16_t offset = edges[edge].name; + if (offset == 0 || offset > names.size()) { + length = 0; + return ""; + } + length = names[offset - 1]; + if (offset + length > names.size()) { + length = 0; + return ""; + } + return reinterpret_cast(names.begin() + offset); +} + +bool loadTileGraph(const uint8_t* data, size_t size, InflateReader& inflater, TileGraph& graph) { + GraphHeader header {}; + graph.clear(); + if (!readGraphHeader(data, size, header) || header.version != GRAPH_FORMAT_VERSION || GRAPH_HEADER_SIZE + header.storedSize > size || + header.nodeCount > 65535) { + return false; + } + const size_t expected = header.nodeCount * GRAPH_NODE_BYTES + header.edgeCount * GRAPH_EDGE_BYTES + header.shapeBytes + header.nameBytes; + if (expected != header.bodySize) { + return false; + } + const uint8_t* body = data + GRAPH_HEADER_SIZE; + GrowArray inflated; + if ((header.flags & PREPARED_FLAG_DEFLATED) != 0) { + if (!inflated.resize(header.bodySize)) { + return false; + } + MemoryInput stored(body, header.storedSize); + if (!inflater.begin(&stored) || inflater.read(inflated.begin(), header.bodySize) != header.bodySize || inflater.failed()) { + return false; + } + body = inflated.begin(); + } else if (header.storedSize != header.bodySize) { + return false; + } + graph.tile = header.tile; + if (!graph.nodes.resize(header.nodeCount) || !graph.edges.resize(header.edgeCount) || !graph.shapes.resize(header.shapeBytes) || + !graph.names.resize(header.nameBytes) || !graph.firstAdjacent.resize(header.nodeCount + 1)) { + return false; + } + const uint8_t* cursor = body; + for (uint32_t i = 0; i < header.nodeCount; i++, cursor += GRAPH_NODE_BYTES) { + graph.nodes[i] = { readU16(cursor), readU16(cursor + 2), cursor[4] }; + } + for (uint32_t i = 0; i < header.edgeCount; i++, cursor += GRAPH_EDGE_BYTES) { + GraphEdge& edge = graph.edges[i]; + edge.from = readU16(cursor); + edge.to = readU16(cursor + 2); + edge.lengthDm = readU16(cursor + 4); + edge.roadClass = cursor[6]; + edge.flags = cursor[7]; + edge.name = readU16(cursor + 8); + edge.shapeOffset = readU32(cursor + 10); + edge.shapeCount = readU16(cursor + 14); + if (edge.from >= header.nodeCount || edge.to >= header.nodeCount || edge.shapeOffset > header.shapeBytes) { + graph.clear(); + return false; + } + } + if (header.shapeBytes > 0) { + memcpy(graph.shapes.begin(), cursor, header.shapeBytes); + } + cursor += header.shapeBytes; + if (header.nameBytes > 0) { + memcpy(graph.names.begin(), cursor, header.nameBytes); + } + // Adjacency: every edge from its from-node, and from its to-node backwards (a oneway edge too: the profile decides) + for (uint32_t i = 0; i < header.edgeCount; i++) { + graph.firstAdjacent[graph.edges[i].from + 1]++; + graph.firstAdjacent[graph.edges[i].to + 1]++; + } + for (uint32_t i = 1; i <= header.nodeCount; i++) { + graph.firstAdjacent[i] += graph.firstAdjacent[i - 1]; + } + if (!graph.adjacent.resize(static_cast(header.edgeCount) * 2)) { + graph.clear(); + return false; + } + { + GrowArray fill; + if (!fill.resize(header.nodeCount)) { + graph.clear(); + return false; + } + for (uint32_t i = 0; i < header.edgeCount; i++) { + const GraphEdge& edge = graph.edges[i]; + graph.adjacent[graph.firstAdjacent[edge.from] + fill[edge.from]++] = i << 1; + graph.adjacent[graph.firstAdjacent[edge.to] + fill[edge.to]++] = (i << 1) | 1; + } + } + // Border nodes per side, sorted along it + for (uint32_t i = 0; i < header.nodeCount; i++) { + const GraphNode& node = graph.nodes[i]; + if (node.x == 0) { + graph.border[0].push(i); + } + if (node.y == 0) { + graph.border[1].push(i); + } + if (node.x == GRAPH_EXTENT) { + graph.border[2].push(i); + } + if (node.y == GRAPH_EXTENT) { + graph.border[3].push(i); + } + } + for (int side = 0; side < 4; side++) { + const bool vertical = side == 0 || side == 2; + std::sort(graph.border[side].begin(), graph.border[side].end(), [&](uint32_t a, uint32_t b) { + return vertical ? graph.nodes[a].y < graph.nodes[b].y : graph.nodes[a].x < graph.nodes[b].x; + }); + } + return !graph.adjacent.failed(); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/RouteGraph.h b/Apps/Maps/main/Source/route/RouteGraph.h new file mode 100644 index 0000000..98629e0 --- /dev/null +++ b/Apps/Maps/main/Source/route/RouteGraph.h @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../mvt/MvtDecoder.h" +#include "../render/PreparedTile.h" +#include "../tiles/TileMath.h" +#include "GrowArray.h" + +#include +#include + +namespace maps { + +/** + * The routing graph of one z14 tile, stored as .prg next to the original (M5c-1). It is made on the device from the + * tile's `transportation` layer, with the rules of the PC study (docs/M5c-routing-study.md, host/routing/build_graph.py): + * - lines are clipped to the tile; a clipped segment lying on the x = 4096 or y = 4096 side belongs to the neighbour; + * - classes motorway ... path are kept, `*_construction` and rail, ferry, transit, pier, aerialway, raceway are not; + * - a dead-end way end within GRAPH_SNAP_UNITS of a segment of another way on the same level is moved onto it, and + * the foot point becomes a shared vertex (simplified tiles often lose the junction vertex of the through road); + * - a vertex is a node where two or more way vertices share the coordinate, or where a way ends; vertices at one + * coordinate join when they have the same level (brunnel, layer), except that a way end joins all of them; + * - edges run from node to node with their shape points, length in decimetres, class, oneway and level flags, and the + * street name found next to them in `transportation_name`. + * Way ends on the tile's sides are border nodes: when the search loads a neighbour, border nodes on the shared side + * within GRAPH_JOIN_UNITS of each other and on the same level are the same point (RouteSearch). + * + * Layout (little endian): an 80-byte header, then the body, raw or as one zlib stream: + * header: "TMPG", u16 version, u16 flags (1 deflated), u8 z, u8 0, u16 extent, u32 x, u32 y, u32 source size, + * u32 node count, u32 edge count, u32 shape bytes, u32 name bytes, u32 body size, u32 stored body size, + * char source version[32] + * body: nodes (u16 x, u16 y, u8 level), edges (GRAPH_EDGE_BYTES each: u16 from, u16 to, u16 length in dm, + * u8 class, u8 flags, u16 name (byte offset + 1 into the names, 0 for none), u32 shape offset, u16 shape + * point count), shapes (zigzag varint deltas x, y of the points between the ends, from the from-node), + * names (u8 length, UTF-8 bytes, each) + * A oneway road against its drawing direction (oneway = -1) is stored reversed, so from -> to is always the way to go. + */ +constexpr uint16_t GRAPH_FORMAT_VERSION = PREPARED_GRAPH_FORMAT_VERSION; +constexpr int GRAPH_EXTENT = 4096; +constexpr size_t GRAPH_HEADER_SIZE = 80; +constexpr size_t GRAPH_NODE_BYTES = 5; +constexpr size_t GRAPH_EDGE_BYTES = 16; +constexpr int GRAPH_SNAP_UNITS = 2; +constexpr int GRAPH_JOIN_UNITS = 1; +/** An edge's name is the nearest street name line within this many units of its middle. */ +constexpr int GRAPH_NAME_UNITS = 12; + +enum class RoadClass : uint8_t { Motorway, Trunk, Primary, Secondary, Tertiary, Minor, Service, Track, Path, Count }; + +enum GraphEdgeFlag : uint8_t { + EDGE_ONEWAY = 1, + EDGE_BRIDGE = 2, + EDGE_TUNNEL = 4, + EDGE_RAMP = 8, + /** Path subclasses, for later use by the profiles. */ + EDGE_STEPS = 16, + EDGE_FOOTWAY = 32, + EDGE_CYCLEWAY = 64, +}; + +/** The road class of a transportation feature, Count for features that are not roads to route on. */ +RoadClass roadClassOf(Value featureClass); +const char* roadClassName(RoadClass roadClass); + +struct GraphHeader { + uint16_t version; + uint16_t flags; + TileKey tile; + int extent; + uint32_t sourceSize; + uint32_t nodeCount; + uint32_t edgeCount; + uint32_t shapeBytes; + uint32_t nameBytes; + uint32_t bodySize; + uint32_t storedSize; + char sourceVersion[PREPARED_VERSION_LENGTH + 1]; +}; + +bool readGraphHeader(const uint8_t* data, size_t size, GraphHeader& header); +bool isGraphCurrent(const GraphHeader& header, const TileKey& tile, uint32_t sourceSize); + +struct GraphNode { + uint16_t x; + uint16_t y; + /** brunnel << 4 | (layer + 8): bridge 1, tunnel 2. */ + uint8_t level; +}; + +struct GraphEdge { + uint16_t from; + uint16_t to; + uint16_t lengthDm; + uint8_t roadClass; + uint8_t flags; + uint16_t name; + uint32_t shapeOffset; + uint16_t shapeCount; +}; + +struct GraphStats { + uint32_t totalMs; + uint32_t ways; + uint32_t points; + uint32_t snappedToVertex; + uint32_t snappedToSegment; + uint32_t nodes; + uint32_t edges; + uint32_t named; + uint32_t borderNodes; + uint32_t bodyBytes; + uint32_t storedBytes; +}; + +/** + * Builds the graph file of a tile from its vector tile, with the decoder of a render lane. Its working memory (a dense + * city tile: a few hundred KB) is kept between tiles until trim(). + */ +class GraphBuilder final : private GeometrySink { +public: + GraphBuilder() = default; + GraphBuilder(const GraphBuilder&) = delete; + GraphBuilder& operator=(const GraphBuilder&) = delete; + + bool build(MvtDecoder& decoder, ByteInput* input, const TileKey& tile, uint32_t sourceSize, const char* sourceVersion, bool deflate, + ByteWriter& out, GraphStats* stats); + const char* failure() const { return failureText; } + void trim(); + +private: + struct Point { + int16_t x; + int16_t y; + }; + struct Way { + uint32_t first; + uint32_t count; + uint8_t roadClass; + uint8_t flags; + int8_t oneway; + uint8_t level; + }; + struct NameLine { + uint32_t first; + uint32_t count; + uint32_t name; + uint8_t roadClass; + }; + struct Vertex { + uint32_t key; + uint32_t point; + }; + + bool beginFeature(const FeatureInfo& info) override; + void beginRing() override; + void points(const TilePoint* points, int count) override; + void endRing(bool closed) override; + void endFeature() override {} + + void clipAndAdd(); + void addPart(const Point* points, size_t count); + bool indexPoints(); + void snapJunctions(); + bool makeNodes(); + bool makeEdges(); + bool buildNameGrid(); + uint16_t nameNear(int32_t x, int32_t y, uint8_t roadClass); + uint32_t internName(const char* text, size_t length); + bool write(const char* sourceVersion, uint32_t sourceSize, bool deflate, ByteWriter& out); + + const char* failureText = ""; + TileKey tile {}; + int extent = GRAPH_EXTENT; + float scale = 1.0f; + // Current feature + bool inNames = false; + Way current {}; + uint32_t currentName = 0; + GrowArray ring; + GrowArray clipped; + int decoderExtent = GRAPH_EXTENT; + float nameScale = 1.0f; + // Ways and their points + GrowArray ways; + GrowArray wayPoints; + GrowArray pointWay; + GrowArray vertices; + /** Per point: how many way vertices share its coordinate. */ + GrowArray shared; + /** Per point: its node, or UINT32_MAX. */ + GrowArray nodeOf; + GrowArray nodes; + GrowArray edges; + ByteWriter shapes; + ByteWriter names; + // Street name lines and a grid of their segments + GrowArray nameLines; + GrowArray namePoints; + GrowArray namePointLine; + GrowArray cellStart; + GrowArray cellItems; + GraphStats counters {}; +}; + +/** A graph file read into memory, with the adjacency the search needs. */ +struct TileGraph { + TileKey tile {}; + GrowArray nodes; + GrowArray edges; + /** Per node: its first entry in adjacent, nodeCount + 1 entries. */ + GrowArray firstAdjacent; + /** Edge index << 1 | 1 when the edge is used from its to-node (backwards). */ + GrowArray adjacent; + GrowArray shapes; + GrowArray names; + /** Border nodes per side (0 left x = 0, 1 top y = 0, 2 right x = 4096, 3 bottom y = 4096), sorted by position. */ + GrowArray border[4]; + + void clear(); + size_t bytes() const; + /** The name of an edge, empty when it has none. */ + const char* name(uint32_t edge, uint8_t& length) const; + /** Calls point(x, y) for the shape of an edge from from to to (or back), in tile units, ends included. */ + template + void shape(uint32_t edge, bool backwards, Point point) const; +}; + +/** Parses a .prg file (inflating with inflater when deflated) into graph. */ +bool loadTileGraph(const uint8_t* data, size_t size, InflateReader& inflater, TileGraph& graph); + +// --------------------------------------------------------------------------------------------------------------- + +namespace graph_detail { +inline bool readVarint(const uint8_t*& data, const uint8_t* end, uint32_t& value) { + value = 0; + for (int shift = 0; shift < 35 && data < end; shift += 7) { + const uint8_t byte = *data++; + value |= static_cast(byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + return true; + } + } + return false; +} +inline int32_t unzigzag(uint32_t value) { + return static_cast((value >> 1) ^ (~(value & 1) + 1)); +} +} // namespace graph_detail + +template +void TileGraph::shape(uint32_t edge, bool backwards, Point point) const { + const GraphEdge& e = edges[edge]; + const GraphNode& a = nodes[e.from]; + const GraphNode& b = nodes[e.to]; + // 2 KB of stack; longer shapes are cut (the builder splits longer edges) + constexpr int MAX = 256; + int32_t xs[MAX]; + int32_t ys[MAX]; + int count = 0; + xs[count] = a.x; + ys[count] = a.y; + count++; + const uint8_t* cursor = shapes.begin() + e.shapeOffset; + const uint8_t* end = shapes.end(); + int32_t x = a.x; + int32_t y = a.y; + for (int i = 0; i < e.shapeCount && count < MAX - 1; i++) { + uint32_t dx; + uint32_t dy; + if (!graph_detail::readVarint(cursor, end, dx) || !graph_detail::readVarint(cursor, end, dy)) { + break; + } + x += graph_detail::unzigzag(dx); + y += graph_detail::unzigzag(dy); + xs[count] = x; + ys[count] = y; + count++; + } + xs[count] = b.x; + ys[count] = b.y; + count++; + for (int i = 0; i < count; i++) { + const int j = backwards ? count - 1 - i : i; + point(xs[j], ys[j]); + } +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/RouteProfile.h b/Apps/Maps/main/Source/route/RouteProfile.h new file mode 100644 index 0000000..b47f7b5 --- /dev/null +++ b/Apps/Maps/main/Source/route/RouteProfile.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "RouteGraph.h" + +#include + +namespace maps { + +/** Walk (the default), bike, car. Values are stored in settings and route files: only add at the end. */ +enum class RouteProfile : uint8_t { Walk, Bike, Car, Count }; + +const char* routeProfileName(RouteProfile profile); + +/** + * Speeds for the time estimate and the search cost, in km/h: one table for all profiles. Walk and bike use one speed + * for every road; car uses the road class. + */ +constexpr float WALK_KMH = 4.5f; +constexpr float BIKE_KMH = 15.0f; +constexpr float CAR_KMH[static_cast(RoadClass::Count)] = { + 110.0f, // motorway + 90.0f, // trunk + 70.0f, // primary + 60.0f, // secondary + 50.0f, // tertiary + 40.0f, // minor + 20.0f, // service + 20.0f, // track (not used by car) + 5.0f, // path (not used by car) +}; +/** The speed the car search assumes for the straight line to the goal (A* weighted a little: see routeMaxKmh). */ +constexpr float CAR_ESTIMATE_KMH = 70.0f; +/** The speed of a profile for the search's straight-line estimate. */ +float routeMaxKmh(RouteProfile profile); + +/** Whether a profile may use an edge at all: bike no motorway or trunk; car no path (footways, steps) or track. */ +inline bool routeAllows(RouteProfile profile, uint8_t roadClass, uint8_t flags) { + (void)flags; + switch (profile) { + case RouteProfile::Bike: + return roadClass != static_cast(RoadClass::Motorway) && roadClass != static_cast(RoadClass::Trunk); + case RouteProfile::Car: + return roadClass != static_cast(RoadClass::Path) && roadClass != static_cast(RoadClass::Track); + default: + return true; + } +} + +/** Whether an edge may be used against its direction: oneway counts for bike and car, walking ignores it. */ +inline bool routeAllowsBackwards(RouteProfile profile, uint8_t flags) { + return profile == RouteProfile::Walk || (flags & EDGE_ONEWAY) == 0; +} + +/** Seconds to travel lengthM metres on a road of this class. */ +float routeSeconds(RouteProfile profile, uint8_t roadClass, float lengthM); + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/RouteSearch.cpp b/Apps/Maps/main/Source/route/RouteSearch.cpp new file mode 100644 index 0000000..01026a8 --- /dev/null +++ b/Apps/Maps/main/Source/route/RouteSearch.cpp @@ -0,0 +1,1086 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "RouteSearch.h" + +#include "../platform/Platform.h" + +#include +#include +#include + +namespace maps { + +namespace { + +constexpr uint32_t EMPTY = 0xFFFFFFFFu; +constexpr uint32_t NO_PARENT = 0xFFFFFFFEu; +constexpr int MAX_TILES = 160; +/** A road piece of fewer nodes that touches no tile side is an island, never snapped to (see isIsland()). */ +constexpr int ISLAND_NODES = 40; +/** A start or goal whose roads reach fewer nodes than this, over tile sides too, moves to a road outside them. */ +constexpr uint32_t COMPONENT_NODES = 300; +constexpr int SNAP_ATTEMPTS = 5; +/** How much more the estimate counts in the second pass (see find()). */ +constexpr float RETRY_WEIGHT = 1.6f; +constexpr double PI_D = 3.14159265358979323846; +constexpr double EARTH_CIRCUMFERENCE = 40075016.686; +constexpr double WORLD_UNITS = 16384.0 * GRAPH_EXTENT; +constexpr uint8_t VIA_BACKWARDS = 1; +constexpr uint8_t VIA_JOIN = 2; +constexpr uint8_t VIA_START = 4; + +uint16_t readU16(const uint8_t* bytes) { + return static_cast(bytes[0] | (bytes[1] << 8)); +} + +uint32_t readU32(const uint8_t* bytes) { + return static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); +} + +uint32_t keyOf(int slot, uint32_t node) { + return (static_cast(slot) << 16) | node; +} + +int slotOf(uint32_t key) { + return static_cast(key >> 16); +} + +uint16_t nodeOfKey(uint32_t key) { + return static_cast(key & 0xFFFF); +} + +uint32_t hashOf(uint32_t key) { + return key * 2654435761u; +} + +float lengthOf(int32_t dx, int32_t dy) { + return std::sqrt(static_cast(dx) * dx + static_cast(dy) * dy); +} + +/** The shape of an edge as points with their distance in units from its from-node, into fixed arrays. */ +struct ShapeBuffer { + static constexpr int MAX = 260; + int32_t x[MAX]; + int32_t y[MAX]; + float at[MAX]; + int count = 0; + + void load(const TileGraph& graph, uint32_t edge) { + count = 0; + graph.shape(edge, false, [&](int32_t px, int32_t py) { + if (count < MAX) { + x[count] = px; + y[count] = py; + at[count] = count == 0 ? 0.0f : at[count - 1] + lengthOf(px - x[count - 1], py - y[count - 1]); + count++; + } + }); + } + float total() const { return count > 0 ? at[count - 1] : 0.0f; } + /** The point at distance d along the shape. */ + void pointAt(float d, int32_t& px, int32_t& py) const { + for (int i = 1; i < count; i++) { + if (at[i] >= d || i == count - 1) { + const float span = at[i] - at[i - 1]; + const float t = span > 0.0f ? std::clamp((d - at[i - 1]) / span, 0.0f, 1.0f) : 0.0f; + px = static_cast(std::floor(x[i - 1] + (x[i] - x[i - 1]) * t + 0.5f)); + py = static_cast(std::floor(y[i - 1] + (y[i] - y[i - 1]) * t + 0.5f)); + return; + } + } + px = count > 0 ? x[0] : 0; + py = count > 0 ? y[0] : 0; + } +}; + +} // namespace + +// --------------------------------------------------------------------------------------------------------------- + +RoutePoint routePointFrom(double lat, double lon) { + const double x = (lon + 180.0) / 360.0 * WORLD_UNITS; + const double s = std::sin(lat * PI_D / 180.0); + const double y = (0.5 - std::log((1.0 + s) / (1.0 - s)) / (4.0 * PI_D)) * WORLD_UNITS; + return { static_cast(std::floor(x + 0.5)), static_cast(std::floor(y + 0.5)) }; +} + +void routePointTo(const RoutePoint& point, double& lat, double& lon) { + lon = point.x / WORLD_UNITS * 360.0 - 180.0; + lat = std::atan(std::sinh(PI_D * (1.0 - 2.0 * point.y / WORLD_UNITS))) * 180.0 / PI_D; +} + +double routeMetresPerUnit(int32_t y) { + const double latitude = std::atan(std::sinh(PI_D * (1.0 - 2.0 * y / WORLD_UNITS))); + return EARTH_CIRCUMFERENCE / WORLD_UNITS * std::cos(latitude); +} + +const char* routeProfileName(RouteProfile profile) { + switch (profile) { + case RouteProfile::Bike: + return "Bike"; + case RouteProfile::Car: + return "Car"; + default: + return "Walk"; + } +} + +float routeMaxKmh(RouteProfile profile) { + switch (profile) { + case RouteProfile::Bike: + return BIKE_KMH; + case RouteProfile::Car: + // Not the motorway speed: with it the estimate is so weak in towns that the search reads most of the region. + // Routes may come out a little slower than the best one, never through roads the profile cannot use + return CAR_ESTIMATE_KMH; + default: + return WALK_KMH; + } +} + +float routeSeconds(RouteProfile profile, uint8_t roadClass, float lengthM) { + float kmh = WALK_KMH; + if (profile == RouteProfile::Bike) { + kmh = BIKE_KMH; + } else if (profile == RouteProfile::Car) { + kmh = CAR_KMH[std::min(roadClass, static_cast(RoadClass::Count) - 1)]; + } + return lengthM * 3.6f / kmh; +} + +const char* routeResultText(RouteResult result) { + switch (result) { + case RouteResult::Found: + return "Route found"; + case RouteResult::NoRoadNearStart: + return "No road near the start in the downloaded map"; + case RouteResult::NoRoadNearGoal: + return "No road near the destination in the downloaded map"; + case RouteResult::NoPath: + return "No route found in the downloaded map"; + case RouteResult::TooLong: + return "Route too long for this device; try a shorter one"; + case RouteResult::NoMemory: + return "Not enough memory for a route now; try again"; + case RouteResult::Cancelled: + return "Route search stopped"; + } + return ""; +} + +void Route::clear() { + points.clear(); + distances.clear(); + steps.clear(); + names.clear(); + lengthM = 0.0f; + seconds = 0.0f; +} + +const char* Route::stepName(const RouteStep& step, char* out, size_t size) const { + const size_t length = std::min(step.nameLength, size - 1); + if (step.nameLength == 0 || step.nameOffset + step.nameLength > names.size()) { + out[0] = '\0'; + return out; + } + memcpy(out, names.begin() + step.nameOffset, length); + out[length] = '\0'; + return out; +} + +// --------------------------------------------------------------------------------------------------------------- + +struct RouteSearch::Tile { + TileKey key; + bool missing; + uint32_t nodeCount; + uint32_t edgeCount; + uint8_t* block; + size_t bytes; + uint16_t* nodeX; + uint16_t* nodeY; + uint8_t* level; + uint32_t* first; + uint16_t* adjacent; + uint16_t* from; + uint16_t* to; + uint16_t* length; + uint8_t* roadClass; + uint8_t* flags; + uint16_t* border[4]; + uint32_t borderCount[4]; +}; + +RouteSearch::~RouteSearch() { + end(); +} + +bool RouteSearch::begin(size_t memoryBytes) { + end(); + // Node states take a quarter of the budget, the queue half as much, the tiles' graphs the rest: on the T-Deck + // the graphs ran out first with 40 % for the states (device bench, 11 Nov 2026). Any size, not a power of two: + // rounding down halved the states with 1.8 MB, and car routes of 2 km became "too long" (user, 11 Nov 2026) + const uint32_t capacity = static_cast(std::clamp(memoryBytes / 4 / sizeof(Entry), 4096, 1u << 20)); + table = static_cast(largeAlloc(static_cast(capacity) * sizeof(Entry))); + queueCapacity = capacity; + queue = static_cast(largeAlloc(static_cast(queueCapacity) * sizeof(QueueItem))); + tiles = static_cast(largeAlloc(sizeof(Tile) * MAX_TILES)); + if (table == nullptr || queue == nullptr || tiles == nullptr) { + end(); + return false; + } + tableCapacity = capacity; + maxNodes = capacity / 4 * 3; + const size_t fixed = static_cast(capacity) * sizeof(Entry) + static_cast(queueCapacity) * sizeof(QueueItem) + sizeof(Tile) * MAX_TILES; + budget = memoryBytes > fixed ? memoryBytes - fixed : 0; + tileCount = 0; + if (!inflaterReady) { + inflaterReady = inflater.init(); + } + return inflaterReady; +} + +void RouteSearch::release() { + for (int i = 0; i < tileCount; i++) { + largeFree(tiles[i].block); + } + tileCount = 0; + graphBytes = 0; +} + +void RouteSearch::end() { + if (tiles != nullptr) { + release(); + } + largeFree(table); + largeFree(queue); + largeFree(tiles); + table = nullptr; + queue = nullptr; + tiles = nullptr; + fileBuffer.release(); + bodyBuffer.release(); + maxNodes = 0; +} + +bool RouteSearch::loadTile(GraphSource& source, const TileKey& tile, Tile& out) { + out = {}; + out.key = tile; + out.missing = true; + const uint32_t start = nowMs(); + const bool read = source.readGraph(tile, fileBuffer); + loadMs += nowMs() - start; + GraphHeader header {}; + if (!read || !readGraphHeader(fileBuffer.begin(), fileBuffer.size(), header) || header.version != GRAPH_FORMAT_VERSION || + !(header.tile == tile) || GRAPH_HEADER_SIZE + header.storedSize > fileBuffer.size() || header.nodeCount > 65535 || + header.edgeCount >= 32768 || + header.bodySize != header.nodeCount * GRAPH_NODE_BYTES + header.edgeCount * GRAPH_EDGE_BYTES + header.shapeBytes + header.nameBytes) { + if (read) { + logError("Route: graph of %d/%d/%d not usable (%u bytes, version %u)", tile.z, tile.x, tile.y, static_cast(fileBuffer.size()), + static_cast(header.version)); + } + counters.tilesMissing++; + return true; + } + const uint8_t* body = fileBuffer.begin() + GRAPH_HEADER_SIZE; + if ((header.flags & PREPARED_FLAG_DEFLATED) != 0) { + // Only nodes and edges are needed: the shapes and names are read again for the route that is found + const size_t wanted = header.nodeCount * GRAPH_NODE_BYTES + header.edgeCount * GRAPH_EDGE_BYTES; + if (!bodyBuffer.resize(wanted)) { + return false; + } + MemoryInput input(body, header.storedSize); + if (!inflater.begin(&input) || inflater.read(bodyBuffer.begin(), wanted) != wanted || inflater.failed()) { + counters.tilesMissing++; + return true; + } + body = bodyBuffer.begin(); + } + const uint32_t n = header.nodeCount; + const uint32_t e = header.edgeCount; + // One block: x, y, first (u32, aligned first), adjacency, edge fields, levels, classes, flags, borders + const size_t bytes = n * 4u + (n + 1) * 4u + e * 4u + e * 6u + n + e * 2u + n * 2u * 2u + 64; + if (graphBytes + bytes > budget || source.memoryShort()) { + return false; + } + auto* block = static_cast(largeAlloc(bytes)); + if (block == nullptr) { + return false; + } + uint8_t* cursor = block; + auto take = [&](size_t size) { + uint8_t* at = cursor; + cursor += (size + 3) & ~static_cast(3); + return at; + }; + out.first = reinterpret_cast(take((n + 1) * 4u)); + out.nodeX = reinterpret_cast(take(n * 2u)); + out.nodeY = reinterpret_cast(take(n * 2u)); + out.adjacent = reinterpret_cast(take(e * 4u)); + out.from = reinterpret_cast(take(e * 2u)); + out.to = reinterpret_cast(take(e * 2u)); + out.length = reinterpret_cast(take(e * 2u)); + out.level = take(n); + out.roadClass = take(e); + out.flags = take(e); + out.block = block; + out.bytes = bytes; + out.nodeCount = n; + out.edgeCount = e; + out.missing = false; + memset(out.first, 0, (n + 1) * 4u); + for (uint32_t i = 0; i < n; i++) { + const uint8_t* node = body + i * GRAPH_NODE_BYTES; + out.nodeX[i] = readU16(node); + out.nodeY[i] = readU16(node + 2); + out.level[i] = node[4]; + } + const uint8_t* edges = body + n * GRAPH_NODE_BYTES; + for (uint32_t i = 0; i < e; i++) { + const uint8_t* edge = edges + i * GRAPH_EDGE_BYTES; + out.from[i] = readU16(edge); + out.to[i] = readU16(edge + 2); + out.length[i] = readU16(edge + 4); + out.roadClass[i] = edge[6]; + out.flags[i] = edge[7]; + if (out.from[i] >= n || out.to[i] >= n) { + largeFree(block); + out = {}; + out.key = tile; + out.missing = true; + counters.tilesMissing++; + return true; + } + out.first[out.from[i] + 1]++; + out.first[out.to[i] + 1]++; + } + for (uint32_t i = 1; i <= n; i++) { + out.first[i] += out.first[i - 1]; + } + // The adjacency is filled from the end of each node's range, counting first[node + 1] down to the node's start + for (uint32_t i = 0; i < e; i++) { + out.adjacent[--out.first[out.from[i] + 1]] = static_cast(i << 1); + out.adjacent[--out.first[out.to[i] + 1]] = static_cast((i << 1) | 1); + } + // first[i + 1] now points at the start of node i's entries: shift back + for (uint32_t i = 0; i < n; i++) { + out.first[i] = out.first[i + 1]; + } + out.first[n] = e * 2; + // Border nodes per side, sorted along it (side 0 x = 0, 1 y = 0, 2 x = 4096, 3 y = 4096) + uint16_t* borders = reinterpret_cast(cursor); + size_t borderUsed = 0; + for (int side = 0; side < 4; side++) { + out.border[side] = borders + borderUsed; + out.borderCount[side] = 0; + for (uint32_t i = 0; i < n && borderUsed < n * 2u; i++) { + const bool on = (side == 0 && out.nodeX[i] == 0) || (side == 1 && out.nodeY[i] == 0) || (side == 2 && out.nodeX[i] == GRAPH_EXTENT) || + (side == 3 && out.nodeY[i] == GRAPH_EXTENT); + if (on) { + out.border[side][out.borderCount[side]++] = static_cast(i); + borderUsed++; + } + } + const bool vertical = side == 0 || side == 2; + std::sort(out.border[side], out.border[side] + out.borderCount[side], + [&](uint16_t a, uint16_t b) { return vertical ? out.nodeY[a] < out.nodeY[b] : out.nodeX[a] < out.nodeX[b]; }); + } + graphBytes += bytes; + peakGraphBytes = std::max(peakGraphBytes, graphBytes); + counters.tilesRead++; + return true; +} + +int RouteSearch::tileSlot(GraphSource& source, const TileKey& tile) { + for (int i = 0; i < tileCount; i++) { + if (tiles[i].key == tile) { + return tiles[i].missing ? -1 : i; + } + } + // Once the memory or the tile list is full no further tile is read: the search ends as too long + if (tilesFull || tileCount >= MAX_TILES) { + tilesFull = true; + return -2; + } + if (!loadTile(source, tile, tiles[tileCount])) { + // Over the budget: the tile counts as missing (never read again), the search goes on around it. Short of + // memory on the device: the search ends + if (source.memoryShort()) { + tilesFull = true; + return -2; + } + tiles[tileCount] = {}; + tiles[tileCount].key = tile; + tiles[tileCount].missing = true; + tileCount++; + budgetHit = true; + return -1; + } + tileCount++; + return tiles[tileCount - 1].missing ? -1 : tileCount - 1; +} + +void RouteSearch::resetStates() { + memset(static_cast(table), 0xFF, static_cast(tableCapacity) * sizeof(Entry)); + used = 0; + maxNodes = tableCapacity / 4 * 3; + queueSize = 0; +} + +RouteSearch::Entry* RouteSearch::entry(uint32_t key, bool create) { + uint32_t index = static_cast((static_cast(hashOf(key)) * tableCapacity) >> 32); + while (true) { + Entry& slot = table[index]; + if (slot.key == key) { + return &slot; + } + if (slot.key == EMPTY) { + if (!create || used >= maxNodes) { + return nullptr; + } + used++; + slot = { key, INFINITY, NO_PARENT, 0, 0, 0 }; + return &slot; + } + index = index + 1 < tableCapacity ? index + 1 : 0; + } +} + +bool RouteSearch::push(float f, uint32_t key) { + if (queueSize >= queueCapacity) { + return false; + } + uint32_t i = queueSize++; + while (i > 0) { + const uint32_t parent = (i - 1) / 2; + if (queue[parent].f <= f) { + break; + } + queue[i] = queue[parent]; + i = parent; + } + queue[i] = { f, key }; + return true; +} + +bool RouteSearch::pop(QueueItem& out) { + if (queueSize == 0) { + return false; + } + out = queue[0]; + const QueueItem last = queue[--queueSize]; + uint32_t i = 0; + while (true) { + uint32_t child = i * 2 + 1; + if (child >= queueSize) { + break; + } + if (child + 1 < queueSize && queue[child + 1].f < queue[child].f) { + child++; + } + if (queue[child].f >= last.f) { + break; + } + queue[i] = queue[child]; + i = child; + } + if (queueSize > 0) { + queue[i] = last; + } + return true; +} + +RoutePoint RouteSearch::globalOf(uint32_t key) const { + const Tile& tile = tiles[slotOf(key)]; + const uint16_t node = nodeOfKey(key); + return { tile.key.x * GRAPH_EXTENT + tile.nodeX[node], tile.key.y * GRAPH_EXTENT + tile.nodeY[node] }; +} + +bool RouteSearch::joinAcross(GraphSource& source, int slot, uint16_t node, int side, uint32_t& joined, bool& limit) { + const Tile* tile = &tiles[slot]; + const uint16_t x = tile->nodeX[node]; + const uint16_t y = tile->nodeY[node]; + const bool on = (side == 0 && x == 0) || (side == 1 && y == 0) || (side == 2 && x == GRAPH_EXTENT) || (side == 3 && y == GRAPH_EXTENT); + if (!on) { + return false; + } + const uint8_t level = tile->level[node]; + const TileKey here = tile->key; + const TileKey next { GRAPH_ZOOM, here.x + (side == 0 ? -1 : (side == 2 ? 1 : 0)), here.y + (side == 1 ? -1 : (side == 3 ? 1 : 0)) }; + const int nextSlot = tileSlot(source, next); + if (nextSlot == -2) { + limit = true; + return false; + } + if (nextSlot < 0) { + return false; + } + const Tile& neighbour = tiles[nextSlot]; + const int opposite = (side + 2) % 4; + const bool vertical = side == 0 || side == 2; + const int position = vertical ? y : x; + int bestNode = -1; + int bestGap = JOIN_UNITS + 1; + for (uint32_t i = 0; i < neighbour.borderCount[opposite]; i++) { + const uint16_t candidate = neighbour.border[opposite][i]; + const int at = vertical ? neighbour.nodeY[candidate] : neighbour.nodeX[candidate]; + const int gap = std::abs(at - position); + if (at > position + JOIN_UNITS) { + break; + } + if (gap < bestGap && neighbour.level[candidate] == level) { + bestGap = gap; + bestNode = candidate; + } + } + if (bestNode < 0) { + return false; + } + joined = keyOf(nextSlot, static_cast(bestNode)); + return true; +} + +bool RouteSearch::smallComponent(GraphSource& source, const Snap& at, RouteProfile componentProfile, bool towards, bool fresh) { + // Breadth first over the roads the profile may use in their allowed direction (away from a start, towards a goal: + // a start on a one-way piece that leads nowhere is as bad as an island), across tile sides, in the node table (its states + // mark the nodes seen) with the queue's array as the FIFO: small when it runs out before COMPONENT_NODES. Not + // fresh: the pieces found before stay marked, so snap() keeps leaving them out + if (fresh) { + resetStates(); + } + uint32_t head = 0; + uint32_t tail = 0; + const uint32_t cap = std::min(COMPONENT_NODES, queueCapacity); + auto add = [&](uint32_t key) { + if (entry(key, false) != nullptr) { + return true; + } + if (tail >= cap || entry(key, true) == nullptr) { + return false; + } + queue[tail++].key = key; + return true; + }; + // Seeded as the search is: a start leaves its road towards the to-node (and the from-node where it may go + // backwards), a goal is reached from the from-node (and the to-node) + const Tile& startTile = tiles[at.slot]; + const bool both = routeAllowsBackwards(componentProfile, startTile.flags[at.edge]); + const uint16_t ahead = towards ? startTile.from[at.edge] : startTile.to[at.edge]; + const uint16_t behind = towards ? startTile.to[at.edge] : startTile.from[at.edge]; + if (!add(keyOf(at.slot, ahead)) || (both && !add(keyOf(at.slot, behind)))) { + return false; + } + while (head < tail) { + const uint32_t key = queue[head++].key; + const int slot = slotOf(key); + const uint16_t node = nodeOfKey(key); + for (uint32_t k = tiles[slot].first[node]; k < tiles[slot].first[node + 1]; k++) { + const Tile& tile = tiles[slot]; + const uint16_t edge = static_cast(tile.adjacent[k] >> 1); + if (!routeAllows(componentProfile, tile.roadClass[edge], tile.flags[edge])) { + continue; + } + // Leaving this node along the edge is against its from-to direction when the entry is marked backwards; + // arriving here along it (towards) is against it when the entry is not + const bool backwards = (tile.adjacent[k] & 1) != 0; + if (backwards != towards && !routeAllowsBackwards(componentProfile, tile.flags[edge])) { + continue; + } + const uint16_t other = backwards ? tile.from[edge] : tile.to[edge]; + if (!add(keyOf(slot, other))) { + return false; + } + } + for (int side = 0; side < 4; side++) { + uint32_t joined = EMPTY; + bool limit = false; + if (joinAcross(source, slot, node, side, joined, limit) && !add(joined)) { + return false; + } + if (limit) { + // Out of memory for tiles: not known, taken as part of the network + return false; + } + } + } + return true; +} + +bool RouteSearch::snapOutside(GraphSource& source, const RoutePoint& point, RouteProfile snapProfile, bool towards, Snap& out) { + // A start or goal on a small piece of road (a service road in a pedestrian zone for a car) goes to the nearest road + // outside it; an old town has several such pieces close together + for (int attempt = 0; attempt < SNAP_ATTEMPTS && smallComponent(source, out, snapProfile, towards, attempt == 0); attempt++) { + excludeSeen = true; + Snap other {}; + const bool found = snap(source, point, snapProfile, other); + excludeSeen = false; + if (!found) { + break; + } + out = other; + } + resetStates(); + return true; +} + +bool RouteSearch::isIsland(const Tile& tile, uint16_t edge, RouteProfile islandProfile) const { + // The nodes reached from the edge over roads the profile may use, either way: an island when they run out before + // ISLAND_NODES and none lies on a side of the tile (where it would join the next tile). Found on the device: a + // destination snapped to a 2-node footway, and the search read the whole city looking for a way to it. + uint16_t seen[ISLAND_NODES]; + int count = 0; + int next = 0; + auto visit = [&](uint16_t node) { + for (int i = 0; i < count; i++) { + if (seen[i] == node) { + return true; + } + } + if (count >= ISLAND_NODES) { + return false; + } + seen[count++] = node; + return true; + }; + visit(tile.from[edge]); + visit(tile.to[edge]); + while (next < count) { + const uint16_t node = seen[next++]; + const uint16_t x = tile.nodeX[node]; + const uint16_t y = tile.nodeY[node]; + if (x == 0 || y == 0 || x == GRAPH_EXTENT || y == GRAPH_EXTENT) { + return false; + } + for (uint32_t k = tile.first[node]; k < tile.first[node + 1]; k++) { + const uint16_t other = static_cast(tile.adjacent[k] >> 1); + if (!routeAllows(islandProfile, tile.roadClass[other], tile.flags[other])) { + continue; + } + const uint16_t far = (tile.adjacent[k] & 1) != 0 ? tile.from[other] : tile.to[other]; + if (!visit(far)) { + // More than ISLAND_NODES: part of the network + return false; + } + } + } + return true; +} + +bool RouteSearch::snap(GraphSource& source, const RoutePoint& point, RouteProfile snapProfile, Snap& out) { + // The tile of the point and the neighbours within reach + const double metresPerUnit = routeMetresPerUnit(point.y); + // A car may stop further from a destination off the roads (a park, a forest): the rest is on foot + const float radius = snapProfile == RouteProfile::Car ? CAR_SNAP_METRES : SNAP_METRES; + const auto reach = static_cast(radius / metresPerUnit) + 1; + float best = radius; + bool found = false; + int islandSlot = -1; + uint32_t islandEdge = 0; + bool island = false; + TileGraph graph; + ShapeBuffer shape; + const int left = (point.x - reach) / GRAPH_EXTENT; + const int right = (point.x + reach) / GRAPH_EXTENT; + const int top = (point.y - reach) / GRAPH_EXTENT; + const int bottom = (point.y + reach) / GRAPH_EXTENT; + for (int ty = top; ty <= bottom; ty++) { + for (int tx = left; tx <= right; tx++) { + const TileKey key { GRAPH_ZOOM, tx, ty }; + const int slot = tileSlot(source, key); + if (slot < 0 || !source.readGraph(key, fileBuffer)) { + continue; + } + // The full graph with shapes, for this tile only + if (!loadTileGraph(fileBuffer.begin(), fileBuffer.size(), inflater, graph)) { + continue; + } + const int32_t px = point.x - tx * GRAPH_EXTENT; + const int32_t py = point.y - ty * GRAPH_EXTENT; + for (uint32_t e = 0; e < graph.edges.size(); e++) { + const GraphEdge& edge = graph.edges[e]; + if (!routeAllows(snapProfile, edge.roadClass, edge.flags)) { + continue; + } + shape.load(graph, e); + for (int i = 1; i < shape.count; i++) { + const float ax = static_cast(shape.x[i - 1]); + const float ay = static_cast(shape.y[i - 1]); + const float dx = shape.x[i] - ax; + const float dy = shape.y[i] - ay; + const float lengthSquared = dx * dx + dy * dy; + float t = lengthSquared > 0.0f ? ((px - ax) * dx + (py - ay) * dy) / lengthSquared : 0.0f; + t = std::clamp(t, 0.0f, 1.0f); + const float qx = ax + t * dx; + const float qy = ay + t * dy; + const float distance = std::sqrt((px - qx) * (px - qx) + (py - qy) * (py - qy)) * static_cast(metresPerUnit); + if (distance < best && excludeSeen && + (entry(keyOf(slot, tiles[slot].from[e]), false) != nullptr || entry(keyOf(slot, tiles[slot].to[e]), false) != nullptr)) { + // In the small piece of road found before (snapOutside) + continue; + } + if (distance < best && !(slot == islandSlot && e == islandEdge && island)) { + // A road piece that leads nowhere (a few nodes, off the tile's sides) is no start or goal + if (slot != islandSlot || e != islandEdge) { + islandSlot = slot; + islandEdge = e; + island = isIsland(tiles[slot], static_cast(e), snapProfile); + } + if (island) { + continue; + } + best = distance; + found = true; + out.slot = slot; + out.edge = static_cast(e); + out.along = shape.at[i - 1] + t * (shape.at[i] - shape.at[i - 1]); + out.total = shape.total(); + out.distanceM = distance; + out.point = { tx * GRAPH_EXTENT + static_cast(std::floor(qx + 0.5f)), ty * GRAPH_EXTENT + static_cast(std::floor(qy + 0.5f)) }; + } + } + } + } + } + return found; +} + +void RouteSearch::relax(uint32_t key, float g, uint32_t parent, uint16_t edge, uint8_t via, const RoutePoint& goal, float scale) { + Entry* state = entry(key, true); + if (state == nullptr) { + return; + } + if (state->closed != 0 || g >= state->g) { + return; + } + state->g = g; + state->parent = parent; + state->edge = edge; + state->via = via; + const RoutePoint at = globalOf(key); + const float h = lengthOf(goal.x - at.x, goal.y - at.y) * scale; + if (!push(g + h, key)) { + // A full queue ends the search as too long + maxNodes = used; + } + counters.discovered++; +} + +RouteResult RouteSearch::find(GraphSource& source, double fromLat, double fromLon, double toLat, double toLon, RouteProfile searchProfile, + Route& route, RouteStats* stats) { + const uint32_t started = nowMs(); + route.clear(); + counters = {}; + counters.passes = 1; + loadMs = 0; + cancelled = false; + profile = searchProfile; + if (table == nullptr) { + return RouteResult::NoMemory; + } + release(); + tilesFull = false; + budgetHit = false; + peakGraphBytes = 0; + resetStates(); + const RoutePoint from = routePointFrom(fromLat, fromLon); + const RoutePoint to = routePointFrom(toLat, toLon); + route.profile = profile; + route.from = from; + route.to = to; + auto finish = [&](RouteResult result) { + counters.totalMs = nowMs() - started; + counters.loadMs = loadMs; + counters.graphBytes = static_cast(peakGraphBytes); + counters.peakBytes = static_cast(peakGraphBytes + static_cast(used) * sizeof(Entry) + static_cast(queueSize) * sizeof(QueueItem)); + if (stats != nullptr) { + *stats = counters; + } + return result; + }; + Snap start {}; + Snap goal {}; + // Seconds per unit of straight distance at the fastest speed: never more than the real time, so A* stays exact + const float metresPerUnit = static_cast(std::min(routeMetresPerUnit(from.y), routeMetresPerUnit(to.y))) * 0.995f; + const float scale = metresPerUnit * 3.6f / routeMaxKmh(profile); + + float best = INFINITY; + uint32_t last = EMPTY; + uint8_t lastSide = 0; + bool direct = false; + bool limit = false; + // A second pass when the first one ran out of node states or of memory for the tiles' graphs (then without a path + // around the tiles it had to skip): the estimate counts more than the real distance, so the search heads for the + // goal and settles and reads far fewer. The route may be a little longer than the best one (at most RETRY_WEIGHT + // times, a few percent in practice), which beats "too long" for a city trip. Out of graph memory, the tiles are + // read again from the start; out of node states, the tiles read stay. + for (int pass = 0; pass < 2; pass++) { + const float weighted = pass == 0 ? scale : scale * RETRY_WEIGHT; + // Out of tile memory (the budget, or the device's free memory, tilesFull): the tiles go and are read again + const bool readAgain = pass > 0 && (budgetHit || tilesFull); + if (pass > 0) { + if (readAgain) { + release(); + budgetHit = false; + } + resetStates(); + tilesFull = false; + counters.passes = 2; + best = INFINITY; + last = EMPTY; + lastSide = 0; + direct = false; + limit = false; + } + if (pass == 0 || readAgain) { + // A tile near the start or goal that did not fit in the memory is no missing road + if (!snap(source, from, profile, start)) { + return finish(budgetHit || tilesFull ? RouteResult::NoMemory : RouteResult::NoRoadNearStart); + } + if (!snap(source, to, profile, goal)) { + return finish(budgetHit || tilesFull ? RouteResult::NoMemory : RouteResult::NoRoadNearGoal); + } + snapOutside(source, from, profile, false, start); + snapOutside(source, to, profile, true, goal); + } + const RoutePoint goalPoint = goal.point; + const Tile& startTile = tiles[start.slot]; + const float startCost = routeSeconds(profile, startTile.roadClass[start.edge], startTile.length[start.edge] / 10.0f); + const float a = start.total > 0.0f ? start.along / start.total : 0.0f; + const Tile& goalTile = tiles[goal.slot]; + const float goalCost = routeSeconds(profile, goalTile.roadClass[goal.edge], goalTile.length[goal.edge] / 10.0f); + const float b = goal.total > 0.0f ? goal.along / goal.total : 0.0f; + const bool startBackwards = routeAllowsBackwards(profile, startTile.flags[start.edge]); + const bool goalBackwards = routeAllowsBackwards(profile, goalTile.flags[goal.edge]); + const uint32_t goalFrom = keyOf(goal.slot, goalTile.from[goal.edge]); + const uint32_t goalTo = keyOf(goal.slot, goalTile.to[goal.edge]); + // Start and goal on the same road between the same two junctions + if (start.slot == goal.slot && start.edge == goal.edge) { + if (b >= a) { + best = (b - a) * startCost; + direct = true; + } else if (startBackwards) { + best = (a - b) * startCost; + direct = true; + } + } + relax(keyOf(start.slot, startTile.to[start.edge]), (1.0f - a) * startCost, NO_PARENT, start.edge, VIA_START, goalPoint, weighted); + if (startBackwards) { + relax(keyOf(start.slot, startTile.from[start.edge]), a * startCost, NO_PARENT, start.edge, VIA_START | VIA_BACKWARDS, goalPoint, weighted); + } + QueueItem item {}; + while (pop(item)) { + if (item.f >= best) { + break; + } + if ((counters.settled & 255) == 0 && cancelled) { + return finish(RouteResult::Cancelled); + } + Entry* state = entry(item.key, false); + if (state == nullptr || state->closed != 0) { + continue; + } + state->closed = 1; + counters.settled++; + const float g = state->g; + if (item.key == goalFrom && g + b * goalCost < best) { + best = g + b * goalCost; + last = item.key; + lastSide = 0; + direct = false; + } + if (item.key == goalTo && goalBackwards && g + (1.0f - b) * goalCost < best) { + best = g + (1.0f - b) * goalCost; + last = item.key; + lastSide = 1; + direct = false; + } + const int slot = slotOf(item.key); + const uint16_t node = nodeOfKey(item.key); + // Copies: loading a neighbour below may not move the tiles, but keeps the code clear of that question + const Tile* tile = &tiles[slot]; + for (uint32_t k = tile->first[node]; k < tile->first[node + 1]; k++) { + const uint16_t entryValue = tile->adjacent[k]; + const uint16_t edge = static_cast(entryValue >> 1); + const bool backwards = (entryValue & 1) != 0; + if (!routeAllows(profile, tile->roadClass[edge], tile->flags[edge]) || (backwards && !routeAllowsBackwards(profile, tile->flags[edge]))) { + continue; + } + const uint16_t other = backwards ? tile->from[edge] : tile->to[edge]; + const float cost = routeSeconds(profile, tile->roadClass[edge], tile->length[edge] / 10.0f); + relax(keyOf(slot, other), g + cost, item.key, edge, backwards ? VIA_BACKWARDS : 0, goalPoint, weighted); + } + // Across a tile side: the neighbour's border node at the same place and level is the same point + for (int side = 0; side < 4 && !limit; side++) { + uint32_t joined = EMPTY; + if (joinAcross(source, slot, node, side, joined, limit)) { + relax(joined, g, item.key, 0, VIA_JOIN, goalPoint, weighted); + } + } + if (used >= maxNodes || tilesFull) { + limit = true; + break; + } + } + if (best != INFINITY || !(limit || budgetHit)) { + break; + } + } + if (best == INFINITY) { + return finish(limit || budgetHit ? RouteResult::TooLong : RouteResult::NoPath); + } + route.seconds = best; + if (!assemble(source, start, goal, last, lastSide, direct, route)) { + route.clear(); + return finish(RouteResult::NoMemory); + } + return finish(RouteResult::Found); +} + +bool RouteSearch::assemble(GraphSource& source, const Snap& start, const Snap& goal, uint32_t last, uint8_t lastSide, bool direct, Route& route) { + // The hops from the start, newest first while walking back + struct Hop { + TileKey tile; + uint16_t edge; + uint8_t via; + }; + GrowArray hops; + if (!direct) { + uint32_t key = last; + uint32_t guard = 0; + while (key != NO_PARENT && guard++ < used + 2) { + const Entry* state = entry(key, false); + if (state == nullptr) { + return false; + } + hops.push({ tiles[slotOf(key)].key, state->edge, state->via }); + key = state->parent; + } + std::reverse(hops.begin(), hops.end()); + } + if (hops.failed()) { + return false; + } + // The search's graphs and states are not needed any more: their memory goes to the full graphs read now + const TileKey startTile = tiles[start.slot].key; + const TileKey goalTile = tiles[goal.slot].key; + release(); + TileGraph graph; + TileKey graphTile { -1, -1, -1 }; + ShapeBuffer shape; + auto useSlot = [&](const TileKey& tile) { + if (tile == graphTile) { + return true; + } + graphTile = { -1, -1, -1 }; + if (!source.readGraph(tile, fileBuffer) || !loadTileGraph(fileBuffer.begin(), fileBuffer.size(), inflater, graph)) { + return false; + } + graphTile = tile; + return true; + }; + auto addPoint = [&](const TileKey& tile, int32_t x, int32_t y) { + const RoutePoint point { tile.x * GRAPH_EXTENT + x, tile.y * GRAPH_EXTENT + y }; + if (route.points.size() > 0) { + const RoutePoint& previous = route.points[route.points.size() - 1]; + if (previous.x == point.x && previous.y == point.y) { + return; + } + } + route.points.push(point); + }; + auto addStep = [&](uint16_t edge) { + uint8_t length = 0; + const char* name = graph.name(edge, length); + const GraphEdge& e = graph.edges[edge]; + const uint32_t pointIndex = route.points.size() > 0 ? static_cast(route.points.size() - 1) : 0; + if (route.steps.size() > 0) { + const RouteStep& previous = route.steps[route.steps.size() - 1]; + if (previous.roadClass == e.roadClass && previous.nameLength == length && + (length == 0 || memcmp(route.names.begin() + previous.nameOffset, name, length) == 0)) { + return; + } + } + const auto offset = static_cast(std::min(route.names.size(), 65535)); + for (uint8_t i = 0; i < length && route.names.size() < 65000; i++) { + route.names.push(name[i]); + } + route.steps.push({ pointIndex, offset, length, e.roadClass, e.flags }); + }; + // Part of an edge's shape from distance d0 to d1 along it (backwards when d0 > d1) + auto addPart = [&](const TileKey& slot, uint16_t edge, float d0, float d1) { + if (!useSlot(slot) || edge >= graph.edges.size()) { + return false; + } + shape.load(graph, edge); + addStep(edge); + int32_t px; + int32_t py; + shape.pointAt(d0, px, py); + addPoint(slot, px, py); + if (d0 <= d1) { + for (int i = 0; i < shape.count; i++) { + if (shape.at[i] > d0 && shape.at[i] < d1) { + addPoint(slot, shape.x[i], shape.y[i]); + } + } + } else { + for (int i = shape.count - 1; i >= 0; i--) { + if (shape.at[i] < d0 && shape.at[i] > d1) { + addPoint(slot, shape.x[i], shape.y[i]); + } + } + } + shape.pointAt(d1, px, py); + addPoint(slot, px, py); + return true; + }; + route.points.push(route.from); + if (direct) { + if (!addPart(startTile, start.edge, start.along, goal.along)) { + return false; + } + } else { + for (size_t i = 0; i < hops.size(); i++) { + const Hop& hop = hops[i]; + if ((hop.via & VIA_JOIN) != 0) { + continue; + } + const bool backwards = (hop.via & VIA_BACKWARDS) != 0; + if ((hop.via & VIA_START) != 0) { + if (!addPart(hop.tile, hop.edge, start.along, backwards ? 0.0f : start.total)) { + return false; + } + continue; + } + if (!useSlot(hop.tile) || hop.edge >= graph.edges.size()) { + return false; + } + shape.load(graph, hop.edge); + if (!addPart(hop.tile, hop.edge, backwards ? shape.total() : 0.0f, backwards ? 0.0f : shape.total())) { + return false; + } + } + if (!addPart(goalTile, goal.edge, lastSide == 0 ? 0.0f : goal.total, goal.along)) { + return false; + } + } + const RoutePoint end = route.to; + if (route.points.size() == 0 || route.points[route.points.size() - 1].x != end.x || route.points[route.points.size() - 1].y != end.y) { + route.points.push(end); + } + if (!route.distances.resize(route.points.size())) { + return false; + } + float distance = 0.0f; + route.distances[0] = 0.0f; + for (size_t i = 1; i < route.points.size(); i++) { + const RoutePoint& a = route.points[i - 1]; + const RoutePoint& b = route.points[i]; + distance += lengthOf(b.x - a.x, b.y - a.y) * static_cast(routeMetresPerUnit((a.y + b.y) / 2)); + route.distances[i] = distance; + } + route.lengthM = distance; + return !route.points.failed() && !route.steps.failed() && !route.names.failed(); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/RouteSearch.h b/Apps/Maps/main/Source/route/RouteSearch.h new file mode 100644 index 0000000..79c1acc --- /dev/null +++ b/Apps/Maps/main/Source/route/RouteSearch.h @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../mvt/InflateReader.h" +#include "GrowArray.h" +#include "RouteGraph.h" +#include "RouteProfile.h" + +#include +#include +#include + +namespace maps { + +/** A point in global z14 graph units: x = tile x * 4096 + x in the tile, the same for y. */ +struct RoutePoint { + int32_t x; + int32_t y; +}; + +RoutePoint routePointFrom(double lat, double lon); +void routePointTo(const RoutePoint& point, double& lat, double& lon); +/** Metres per graph unit at the latitude of a point. */ +double routeMetresPerUnit(int32_t y); + +/** One stretch of a route on one road: where it starts in the route's points, its name and class. */ +struct RouteStep { + uint32_t point; + /** Offset into Route::names, the name's length (0: no name). */ + uint16_t nameOffset; + uint8_t nameLength; + uint8_t roadClass; + uint8_t flags; +}; + +/** A found route: its line, the distance from the start at each point, its roads, length and time. */ +struct Route { + RouteProfile profile = RouteProfile::Walk; + /** Where the route was asked for: the start and goal before snapping to roads. */ + RoutePoint from {}; + RoutePoint to {}; + GrowArray points; + GrowArray distances; + GrowArray steps; + GrowArray names; + float lengthM = 0.0f; + float seconds = 0.0f; + + void clear(); + bool isEmpty() const { return points.size() < 2; } + /** The name of a step, "" when it has none. */ + const char* stepName(const RouteStep& step, char* out, size_t size) const; +}; + +/** Gives the search the graph files of z14 tiles. */ +class GraphSource { +public: + virtual ~GraphSource() = default; + /** Reads the .prg file of a tile into file. False when there is none (yet). */ + virtual bool readGraph(const TileKey& tile, GrowArray& file) = 0; + /** Whether the device runs short of memory: the search then stops as too long rather than starve the app. */ + virtual bool memoryShort() { return false; } +}; + +enum class RouteResult : uint8_t { Found, NoRoadNearStart, NoRoadNearGoal, NoPath, TooLong, NoMemory, Cancelled }; +const char* routeResultText(RouteResult result); + +struct RouteStats { + uint32_t totalMs; + uint32_t loadMs; + uint32_t settled; + uint32_t discovered; + uint32_t tilesRead; + uint32_t tilesMissing; + uint32_t peakBytes; + uint32_t graphBytes; + /** 2 when the first pass ran out of node states and a second, goal-directed pass ran (see RouteSearch::find). */ + uint32_t passes; +}; + +/** + * A* over the tiles' graphs, read as the search reaches them (per-tile node ids; a hash map from tile and node to the + * search state, so nothing is sized for the whole region). Border nodes of neighbouring tiles join when they lie + * within GRAPH_JOIN_UNITS of each other on the shared side, on the same level. Start and goal snap to the nearest + * road the profile may use within SNAP_METRES (CAR_SNAP_METRES for a car) that is not a small island (a few nodes leading nowhere). The search stops cleanly with TooLong when it reaches its node cap or + * memory budget, after a second goal-directed pass on the tiles it has read (see find()). + */ +class RouteSearch { +public: + static constexpr float SNAP_METRES = 200.0f; + static constexpr float CAR_SNAP_METRES = 500.0f; + /** Joining needs 2 units: neighbouring tiles clip the same road up to 2 units apart (host/routing/check_prg.py). */ + static constexpr int JOIN_UNITS = 2; + + RouteSearch() = default; + ~RouteSearch(); + RouteSearch(const RouteSearch&) = delete; + RouteSearch& operator=(const RouteSearch&) = delete; + + /** + * Takes up to memoryBytes of large memory for the search: the node states, the queue and the tiles' graphs. Returns + * false when not even a small search fits. + */ + bool begin(size_t memoryBytes); + /** Gives the memory back. */ + void end(); + + RouteResult find(GraphSource& source, double fromLat, double fromLon, double toLat, double toLon, RouteProfile profile, Route& route, + RouteStats* stats); + /** Makes a running find() return Cancelled soon. */ + void cancel() { cancelled = true; } + /** The node states the memory allows, for the log. */ + uint32_t nodeCapacity() const { return maxNodes; } + +private: + struct Tile; + struct Entry { + uint32_t key; + float g; + uint32_t parent; + uint16_t edge; + uint8_t closed; + /** 1: the edge was used backwards; 2: a join across tiles (no edge); 4: from the start point. */ + uint8_t via; + }; + struct QueueItem { + float f; + uint32_t key; + }; + struct Snap { + int slot; + uint16_t edge; + /** Units along the edge's shape from its from-node, and the shape's length in units. */ + float along; + float total; + float distanceM; + RoutePoint point; + }; + + int tileSlot(GraphSource& source, const TileKey& tile); + bool loadTile(GraphSource& source, const TileKey& tile, Tile& out); + bool snap(GraphSource& source, const RoutePoint& point, RouteProfile profile, Snap& out); + bool isIsland(const Tile& tile, uint16_t edge, RouteProfile islandProfile) const; + /** The node a tile's node joins across one side; limit when the next tile could not be read. */ + bool joinAcross(GraphSource& source, int slot, uint16_t node, int side, uint32_t& joined, bool& limit); + /** + * Whether the roads around a snapped point reach fewer than COMPONENT_NODES nodes (the states mark them), driving + * away from it, or towards it for a goal. + */ + bool smallComponent(GraphSource& source, const Snap& at, RouteProfile componentProfile, bool towards, bool fresh); + /** Moves a snapped point off a small piece of road to the nearest road outside it. Leaves the states empty. */ + bool snapOutside(GraphSource& source, const RoutePoint& point, RouteProfile snapProfile, bool towards, Snap& out); + Entry* entry(uint32_t key, bool create); + void resetStates(); + bool push(float f, uint32_t key); + bool pop(QueueItem& out); + void relax(uint32_t key, float g, uint32_t parent, uint16_t edge, uint8_t via, const RoutePoint& goal, float scale); + RoutePoint globalOf(uint32_t key) const; + bool assemble(GraphSource& source, const Snap& start, const Snap& goal, uint32_t last, uint8_t lastSide, bool direct, Route& route); + void release(); + + Tile* tiles = nullptr; + int tileCount = 0; + Entry* table = nullptr; + uint32_t tableCapacity = 0; + uint32_t used = 0; + uint32_t maxNodes = 0; + QueueItem* queue = nullptr; + uint32_t queueSize = 0; + uint32_t queueCapacity = 0; + size_t budget = 0; + size_t graphBytes = 0; + size_t peakGraphBytes = 0; + bool tilesFull = false; + /** snap() leaves out edges with a state at either end (the small piece found by smallComponent()). */ + bool excludeSeen = false; + bool budgetHit = false; + GrowArray fileBuffer; + GrowArray bodyBuffer; + InflateReader inflater; + bool inflaterReady = false; + RouteProfile profile = RouteProfile::Walk; + std::atomic cancelled { false }; + RouteStats counters {}; + uint32_t loadMs = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/route/RouteTurn.h b/Apps/Maps/main/Source/route/RouteTurn.h new file mode 100644 index 0000000..e09448e --- /dev/null +++ b/Apps/Maps/main/Source/route/RouteTurn.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace maps { + +/** The kind of manoeuvre at a point of a route, from the angle between the way in and the way out. */ +enum class Turn : uint8_t { + Straight, + SlightLeft, + Left, + SharpLeft, + SlightRight, + Right, + SharpRight, + UTurn, + Arrive, + Roundabout +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/BrowsedMove.cpp b/Apps/Maps/main/Source/tiles/BrowsedMove.cpp new file mode 100644 index 0000000..e748686 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/BrowsedMove.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "BrowsedMove.h" + +#include "TilePaths.h" +#include "TileStore.h" + +#include "../render/PreparedTile.h" + +#include + +#include +#include + +namespace maps { + +namespace { + +bool exists(const char* path) { + struct stat info {}; + return stat(path, &info) == 0; +} + +/** The source version in a prepared tile's header, "" when it cannot be read. */ +void preparedVersion(const char* path, char* out, size_t size) { + out[0] = '\0'; + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return; + } + uint8_t bytes[PREPARED_HEADER_SIZE]; + PreparedHeader header {}; + if (fread(bytes, 1, sizeof(bytes), file) == sizeof(bytes) && readPreparedHeader(bytes, sizeof(bytes), header)) { + snprintf(out, size, "%s", header.sourceVersion); + } + fclose(file); +} + +} // namespace + +BrowsedMove moveBrowsedFiles(const char* root, const char* region, const TileKey& tile, const char* version, + const char* browsedVersion, uint64_t& size, bool (*makeFolder)(const char* path)) { + size = 0; + char fromOriginal[PATH_MAX_LENGTH]; + char fromPrepared[PATH_MAX_LENGTH]; + char toOriginal[PATH_MAX_LENGTH]; + char toPrepared[PATH_MAX_LENGTH]; + if (!tilePath(fromOriginal, sizeof(fromOriginal), root, BROWSE_REGION, tile) || + !preparedPath(fromPrepared, sizeof(fromPrepared), root, BROWSE_REGION, tile) || + !tilePath(toOriginal, sizeof(toOriginal), root, region, tile) || + !preparedPath(toPrepared, sizeof(toPrepared), root, region, tile)) { + return BrowsedMove::Failed; + } + struct stat info {}; + if (stat(fromOriginal, &info) != 0) { + return BrowsedMove::Failed; + } + size = static_cast(info.st_size); + const bool hasPrepared = exists(fromPrepared); + char tileVersion[PREPARED_VERSION_LENGTH + 1] = ""; + if (hasPrepared) { + preparedVersion(fromPrepared, tileVersion, sizeof(tileVersion)); + } + if (tileVersion[0] == '\0' && browsedVersion != nullptr) { + snprintf(tileVersion, sizeof(tileVersion), "%s", browsedVersion); + } + if (version != nullptr && version[0] != '\0' && tileVersion[0] != '\0' && strcmp(version, tileVersion) != 0) { + return BrowsedMove::Stale; + } + char folder[PATH_MAX_LENGTH]; + snprintf(folder, sizeof(folder), "%s/maps/%s/%d", root, region, tile.z); + makeFolder(folder); + if (tileFolderPath(folder, sizeof(folder), root, region, tile)) { + makeFolder(folder); + } + if (hasPrepared) { + // A prepared copy already in the region is from an interrupted move or an old download: it stays + if (exists(toPrepared)) { + remove(fromPrepared); + } else if (rename(fromPrepared, toPrepared) != 0) { + return BrowsedMove::Failed; + } + } + // The label and graph files follow, or are made again there when the move cannot take them + for (int file = 1; file < PREPARED_FILE_COUNT; file++) { + char from[PATH_MAX_LENGTH]; + char to[PATH_MAX_LENGTH]; + if (preparedFilePath(from, sizeof(from), root, BROWSE_REGION, tile, static_cast(file)) && + preparedFilePath(to, sizeof(to), root, region, tile, static_cast(file)) && exists(from) && + (exists(to) || rename(from, to) != 0)) { + remove(from); + } + } + if (rename(fromOriginal, toOriginal) != 0) { + return BrowsedMove::Failed; + } + return BrowsedMove::Moved; +} + +uint64_t removeBrowsedFiles(const char* root, const TileKey& tile) { + char path[PATH_MAX_LENGTH]; + for (int file = 0; file < PREPARED_FILE_COUNT; file++) { + if (preparedFilePath(path, sizeof(path), root, BROWSE_REGION, tile, static_cast(file))) { + remove(path); + } + } + struct stat info {}; + if (tilePath(path, sizeof(path), root, BROWSE_REGION, tile) && stat(path, &info) == 0 && remove(path) == 0) { + return static_cast(info.st_size); + } + return 0; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/BrowsedMove.h b/Apps/Maps/main/Source/tiles/BrowsedMove.h new file mode 100644 index 0000000..4c567e5 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/BrowsedMove.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "TileMath.h" + +#include +#include + +namespace maps { + +/** A browsed tile a download wants: moved into the region, from another source version (download it), or failed. */ +enum class BrowsedMove : uint8_t { Moved, Stale, Failed }; + +/** + * Moves a tile from the region "browsed" into region: two renames on the same card, the prepared copy (.prt) first, + * then the original (.pbf). Power lost between them leaves the region with the prepared copy only and the original + * still browsed; calling this again for the tile (the download does, when it resumes) finishes the move. + * + * The tile's source version is the one in its prepared copy's header, or browsedVersion when it has none. A tile from + * another version than version is not moved (Stale): the download fetches it fresh and then removes the old copy. + * An empty version on either side counts as a match. size receives the original's size in bytes. + * makeFolder creates one folder (mkdir); it is passed in so the PC tests run the same code. + */ +BrowsedMove moveBrowsedFiles(const char* root, const char* region, const TileKey& tile, const char* version, + const char* browsedVersion, uint64_t& size, bool (*makeFolder)(const char* path)); + +/** Deletes a stale browsed tile (prepared copy, then original); returns the original's size, 0 when it was not there. */ +uint64_t removeBrowsedFiles(const char* root, const TileKey& tile); + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/ChunkedBody.cpp b/Apps/Maps/main/Source/tiles/ChunkedBody.cpp new file mode 100644 index 0000000..59ab43e --- /dev/null +++ b/Apps/Maps/main/Source/tiles/ChunkedBody.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ChunkedBody.h" + +#include "../platform/Platform.h" + +#include + +namespace maps { + +ChunkedBody::~ChunkedBody() { + release(); +} + +void ChunkedBody::release() { + for (int i = 0; i < allocated; i++) { + largeFree(chunks[i]); + chunks[i] = nullptr; + } + allocated = 0; + clear(); +} + +void ChunkedBody::trim() { + while (allocated > KEEP_CHUNKS) { + allocated--; + largeFree(chunks[allocated]); + chunks[allocated] = nullptr; + } + clear(); +} + +bool ChunkedBody::ensure(size_t total) { + const size_t needed = (total + CHUNK_BYTES - 1) / CHUNK_BYTES; + if (needed > MAX_CHUNKS) { + return false; + } + while (static_cast(allocated) < needed) { + auto* piece = static_cast(largeAlloc(CHUNK_BYTES)); + if (piece == nullptr) { + return false; + } + chunks[allocated++] = piece; + } + return true; +} + +void ChunkedBody::putBytes(const void* bytes, size_t count) { + if (error) { + return; + } + if (!ensure(length + count)) { + error = true; + return; + } + const auto* source = static_cast(bytes); + while (count > 0) { + const size_t offset = length % CHUNK_BYTES; + const size_t part = count < CHUNK_BYTES - offset ? count : CHUNK_BYTES - offset; + memcpy(chunks[length / CHUNK_BYTES] + offset, source, part); + source += part; + length += part; + count -= part; + } +} + +size_t ChunkedBody::chunkSize(int index) const { + const size_t start = static_cast(index) * CHUNK_BYTES; + if (start >= length) { + return 0; + } + return length - start < CHUNK_BYTES ? length - start : CHUNK_BYTES; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/ChunkedBody.h b/Apps/Maps/main/Source/tiles/ChunkedBody.h new file mode 100644 index 0000000..63098a8 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/ChunkedBody.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace maps { + +/** + * A downloaded file in pieces of CHUNK_BYTES, so a tile of a megabyte needs no single free block of that size: late + * in a session PSRAM has the room, but in smaller blocks. + */ +class ChunkedBody { +public: + static constexpr size_t CHUNK_BYTES = 64 * 1024; + static constexpr int MAX_CHUNKS = 64; + /** After a file, pieces beyond this many go back to the heap. */ + static constexpr int KEEP_CHUNKS = 2; + + ChunkedBody() = default; + ~ChunkedBody(); + ChunkedBody(const ChunkedBody&) = delete; + ChunkedBody& operator=(const ChunkedBody&) = delete; + + void clear() { + length = 0; + error = false; + } + /** Clears and frees every piece. */ + void release(); + /** Clears and frees the pieces beyond KEEP_CHUNKS. */ + void trim(); + /** Room for total bytes, without marking a failure when there is none. */ + bool ensure(size_t total); + void putBytes(const void* bytes, size_t count); + void put(uint8_t value) { putBytes(&value, 1); } + /** Marks the body as failed, for a file that is too large. */ + void fail() { error = true; } + + size_t size() const { return length; } + bool failed() const { return error; } + /** The pieces holding the bytes, in order; every one but the last is full. */ + int chunkCount() const { return static_cast((length + CHUNK_BYTES - 1) / CHUNK_BYTES); } + const uint8_t* chunk(int index) const { return chunks[index]; } + size_t chunkSize(int index) const; + /** The bytes in one piece, or nullptr when they span several. */ + const uint8_t* contiguous() const { return length <= CHUNK_BYTES ? chunks[0] : nullptr; } + +private: + uint8_t* chunks[MAX_CHUNKS] = {}; + int allocated = 0; + size_t length = 0; + bool error = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/DownloadPlan.cpp b/Apps/Maps/main/Source/tiles/DownloadPlan.cpp new file mode 100644 index 0000000..f2851f2 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/DownloadPlan.cpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "DownloadPlan.h" + +namespace maps { + +namespace { + +// Mean compressed bytes per tile of 290 OpenFreeMap tiles (planet 20261025) from Budapest, Vienna, London, +// Split and rural Hungary. Dense city centres are up to twice as large, the countryside much smaller. +constexpr uint32_t TYPICAL_TILE_BYTES[MAX_SOURCE_ZOOM + 1] = { + 48000, 124000, 700000, 509000, 360000, 292000, 198000, 225000, 179000, 158000, 90000, 66000, 72000, 60000, 136000 +}; + +} // namespace + +uint32_t typicalTileBytes(int z) { + if (z < 0) { + return 0; + } + return TYPICAL_TILE_BYTES[z > MAX_SOURCE_ZOOM ? MAX_SOURCE_ZOOM : z]; +} + +uint64_t estimateBytes(const BoundingBox& box, int minZoom, int maxZoom) { + uint64_t total = 0; + for (int z = minZoom; z <= maxZoom; z++) { + total += static_cast(tileRange(box, z).count()) * typicalTileBytes(z); + } + return total; +} + +DownloadPlan planDownload(const BoundingBox& box, int requestedMaxZoom) { + DownloadPlan plan { box, 0, 0, 0, 0, false }; + const int top = requestedMaxZoom < MAX_SOURCE_ZOOM ? requestedMaxZoom : MAX_SOURCE_ZOOM; + for (int maxZoom = top; maxZoom >= 0; maxZoom--) { + const int64_t tiles = tileCount(box, 0, maxZoom); + const uint64_t bytes = estimateBytes(box, 0, maxZoom); + if (tiles <= MAX_DOWNLOAD_TILES && bytes <= MAX_DOWNLOAD_BYTES) { + plan.maxZoom = maxZoom; + plan.tiles = tiles; + plan.estimatedBytes = bytes; + plan.fits = true; + return plan; + } + } + plan.tiles = tileCount(box, 0, 0); + plan.estimatedBytes = estimateBytes(box, 0, 0); + return plan; +} + +bool tileAtIndex(const DownloadPlan& plan, int64_t index, TileKey& tile) { + if (index < 0) { + return false; + } + for (int z = plan.minZoom; z <= plan.maxZoom; z++) { + const TileRange range = tileRange(plan.box, z); + const int64_t count = range.count(); + if (index < count) { + const int64_t width = range.maxX - range.minX + 1; + tile = { z, range.minX + static_cast(index % width), range.minY + static_cast(index / width) }; + return true; + } + index -= count; + } + return false; +} + +int64_t indexOfTile(const DownloadPlan& plan, const TileKey& tile) { + int64_t before = 0; + for (int z = plan.minZoom; z <= plan.maxZoom; z++) { + const TileRange range = tileRange(plan.box, z); + if (z == tile.z) { + if (tile.x < range.minX || tile.x > range.maxX || tile.y < range.minY || tile.y > range.maxY) { + return -1; + } + const int64_t width = range.maxX - range.minX + 1; + return before + static_cast(tile.y - range.minY) * width + (tile.x - range.minX); + } + before += range.count(); + } + return -1; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/DownloadPlan.h b/Apps/Maps/main/Source/tiles/DownloadPlan.h new file mode 100644 index 0000000..796af11 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/DownloadPlan.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "TileMath.h" + +#include + +namespace maps { + +/** OpenFreeMap's limits for one offline download. They are not to be worked around by splitting an area. */ +constexpr int64_t MAX_DOWNLOAD_TILES = 3000; +constexpr uint64_t MAX_DOWNLOAD_BYTES = 50ull * 1000 * 1000; +constexpr int MAX_PARALLEL_REQUESTS = 4; + +struct DownloadPlan { + BoundingBox box; + int minZoom; + int maxZoom; + int64_t tiles; + uint64_t estimatedBytes; + /** False when even the lowest zoom levels do not fit the limits. */ + bool fits; +}; + +/** Typical compressed size of a tile at a zoom level, measured from real OpenFreeMap tiles. */ +uint32_t typicalTileBytes(int z); + +uint64_t estimateBytes(const BoundingBox& box, int minZoom, int maxZoom); + +/** The plan with the highest max zoom, at most requestedMaxZoom, that fits the download limits. */ +DownloadPlan planDownload(const BoundingBox& box, int requestedMaxZoom); + +/** + * The tile at a position in the download order: zoom levels from low to high, rows from top to bottom, + * columns from left to right. The order never changes, so a download resumes from its done count. + */ +bool tileAtIndex(const DownloadPlan& plan, int64_t index, TileKey& tile); + +/** The position of a tile in the download order, or -1 when the plan does not have it. */ +int64_t indexOfTile(const DownloadPlan& plan, const TileKey& tile); + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/IoQueue.h b/Apps/Maps/main/Source/tiles/IoQueue.h new file mode 100644 index 0000000..f63740a --- /dev/null +++ b/Apps/Maps/main/Source/tiles/IoQueue.h @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace maps { + +/** What an SD card job is for, in the order the I/O task serves them. */ +enum class IoPriority : uint8_t { + /** Tiles on screen, and the UI waiting for a file. */ + Visible, + /** Tiles around the screen. */ + Prefetch, + /** Cache writes, conversions, downloads, deletes, folder walks. */ + Background, + Count +}; + +/** + * The waiting jobs of the I/O task: the highest priority first, the oldest first within a priority. + * Not thread safe, the I/O task guards it with its own lock. + */ +template +class IoQueue { +public: + bool push(const Item& item, IoPriority priority) { + if (count >= CAPACITY) { + return false; + } + entries[count++] = { item, priority, nextSequence++ }; + return true; + } + + bool pop(Item& item, IoPriority* priority = nullptr) { + int best = -1; + for (int i = 0; i < count; i++) { + if (best < 0 || entries[i].priority < entries[best].priority || + (entries[i].priority == entries[best].priority && entries[i].sequence < entries[best].sequence)) { + best = i; + } + } + if (best < 0) { + return false; + } + item = entries[best].item; + if (priority != nullptr) { + *priority = entries[best].priority; + } + entries[best] = entries[--count]; + return true; + } + + /** Whether a job more urgent than priority waits. */ + bool hasMoreUrgent(IoPriority priority) const { + for (int i = 0; i < count; i++) { + if (entries[i].priority < priority) { + return true; + } + } + return false; + } + + int size() const { return count; } + +private: + struct Entry { + Item item; + IoPriority priority; + uint32_t sequence; + }; + + Entry entries[CAPACITY] {}; + int count = 0; + uint32_t nextSequence = 0; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/RegionCatalog.cpp b/Apps/Maps/main/Source/tiles/RegionCatalog.cpp new file mode 100644 index 0000000..1af30fe --- /dev/null +++ b/Apps/Maps/main/Source/tiles/RegionCatalog.cpp @@ -0,0 +1,538 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "RegionCatalog.h" + +#include +#include + +namespace maps { + +namespace { + +constexpr double UNIT = 10000.0; +// Small enough for a task stack (about 1.3 KB together); an outline crossing a tile row more often counts the whole row +constexpr int MAX_CROSSINGS = 128; +constexpr int MAX_INTERVALS = 96; + +uint16_t readU16(const uint8_t* p) { + return static_cast(static_cast(p[0]) | (static_cast(p[1]) << 8)); +} + +uint32_t readU32(const uint8_t* p) { + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + +int32_t readI32(const uint8_t* p) { + return static_cast(readU32(p)); +} + +int32_t zigzagDecode(uint64_t value) { + return static_cast((value >> 1) ^ (0 - (value & 1))); +} + +struct Cursor { + const uint8_t* data; + size_t size; + size_t pos; + + bool read(uint64_t& value) { + value = 0; + unsigned shift = 0; + while (pos < size) { + const uint8_t byte = data[pos++]; + if (shift < 64) { + value |= static_cast(byte & 0x7F) << shift; + } + if ((byte & 0x80) == 0) { + return true; + } + shift += 7; + if (shift >= 64) { + return false; + } + } + return false; + } +}; + +int floorToInt(double value) { + const double floored = std::floor(value); + if (floored <= -2147483648.0) { + return -2147483647 - 1; + } + if (floored >= 2147483647.0) { + return 2147483647; + } + return static_cast(floored); +} + +int ceilToInt(double value) { + return -floorToInt(-value); +} + +} // namespace + +bool RegionCatalog::open(const uint8_t* data, size_t size) { + data_ = nullptr; + size_ = 0; + count_ = 0; + outlineOffset_ = 0; + nameOffset_ = 0; + open_ = false; + + if (data == nullptr || size < 16) { + return false; + } + if (data[0] != 'T' || data[1] != 'M' || data[2] != 'R' || data[3] != 'C') { + return false; + } + if (readU16(data + 4) != 1) { + return false; + } + + const uint16_t count = readU16(data + 6); + const uint32_t outlineOffset = readU32(data + 8); + const uint32_t nameOffset = readU32(data + 12); + const size_t recordsEnd = 16 + static_cast(count) * 32; + if (recordsEnd > size || outlineOffset > size || nameOffset > size) { + return false; + } + + for (int i = 0; i < count; i++) { + const uint8_t* rec = data + 16 + static_cast(i) * 32; + const uint16_t parent = readU16(rec); + if (parent != 0xFFFF && parent >= count) { + return false; + } + const uint8_t nameLength = rec[3]; + const uint32_t nameRelative = readU32(rec + 4); + const uint32_t outlineRelative = readU32(rec + 24); + const uint64_t nameEnd = static_cast(nameOffset) + nameRelative + nameLength; + const uint64_t outlineBegin = static_cast(outlineOffset) + outlineRelative; + if (nameEnd > size || outlineBegin > size) { + return false; + } + } + + data_ = data; + size_ = size; + count_ = count; + outlineOffset_ = outlineOffset; + nameOffset_ = nameOffset; + open_ = true; + return true; +} + +bool RegionCatalog::isOpen() const { + return open_; +} + +int RegionCatalog::count() const { + return count_; +} + +const uint8_t* RegionCatalog::record(int index) const { + return data_ + 16 + static_cast(index) * 32; +} + +const uint8_t* RegionCatalog::outlineStart(int index, size_t& length) const { + const uint8_t* rec = record(index); + const size_t start = static_cast(outlineOffset_) + readU32(rec + 24); + if (start > size_) { + return nullptr; + } + size_t end = size_; + if (nameOffset_ >= start && nameOffset_ <= size_) { + end = nameOffset_; + } + length = end - start; + return data_ + start; +} + +CatalogRegion RegionCatalog::region(int index) const { + CatalogRegion result { -1, 0, nullptr, 0, { 0.0, 0.0, 0.0, 0.0 }, 0 }; + if (!open_ || index < 0 || index >= count_) { + return result; + } + const uint8_t* rec = record(index); + const uint16_t parent = readU16(rec); + result.parent = parent == 0xFFFF ? -1 : static_cast(parent); + result.level = rec[2]; + result.nameLength = rec[3]; + result.name = reinterpret_cast(data_ + nameOffset_ + readU32(rec + 4)); + result.box.west = static_cast(readI32(rec + 8)) / UNIT; + result.box.south = static_cast(readI32(rec + 12)) / UNIT; + result.box.east = static_cast(readI32(rec + 16)) / UNIT; + result.box.north = static_cast(readI32(rec + 20)) / UNIT; + result.childCount = readU16(rec + 30); + return result; +} + +void RegionCatalog::copyName(int index, char* out, size_t size) const { + if (out == nullptr || size == 0) { + return; + } + out[0] = '\0'; + if (!open_ || index < 0 || index >= count_) { + return; + } + const uint8_t* rec = record(index); + const size_t length = rec[3]; + const uint8_t* name = data_ + nameOffset_ + readU32(rec + 4); + size_t copy = length; + if (copy > size - 1) { + copy = size - 1; + } + for (size_t i = 0; i < copy; i++) { + out[i] = static_cast(name[i]); + } + out[copy] = '\0'; +} + +int RegionCatalog::children(int parent, int* out, int max) const { + if (!open_ || out == nullptr || max <= 0) { + return 0; + } + int written = 0; + for (int i = 0; i < count_ && written < max; i++) { + const uint16_t value = readU16(record(i)); + const int regionParent = value == 0xFFFF ? -1 : static_cast(value); + if (regionParent == parent) { + out[written++] = i; + } + } + return written; +} + +bool RegionCatalog::contains(int index, double lat, double lon) const { + if (!open_ || index < 0 || index >= count_) { + return false; + } + size_t end = 0; + const uint8_t* start = outlineStart(index, end); + if (start == nullptr) { + return false; + } + const uint16_t ringCount = readU16(record(index) + 28); + Cursor cursor { start, end, 0 }; + bool inside = false; + for (uint16_t r = 0; r < ringCount; r++) { + uint64_t pointCount = 0; + if (!cursor.read(pointCount)) { + return false; + } + if (pointCount == 0) { + continue; + } + uint64_t vx = 0; + uint64_t vy = 0; + if (!cursor.read(vx) || !cursor.read(vy)) { + return false; + } + int64_t lonUnits = zigzagDecode(vx); + int64_t latUnits = zigzagDecode(vy); + const double firstLon = static_cast(lonUnits) / UNIT; + const double firstLat = static_cast(latUnits) / UNIT; + double prevLon = firstLon; + double prevLat = firstLat; + if (pointCount < 2) { + continue; + } + for (uint64_t i = 1; i < pointCount; i++) { + uint64_t dx = 0; + uint64_t dy = 0; + if (!cursor.read(dx) || !cursor.read(dy)) { + return false; + } + lonUnits += zigzagDecode(dx); + latUnits += zigzagDecode(dy); + const double x = static_cast(lonUnits) / UNIT; + const double y = static_cast(latUnits) / UNIT; + if ((prevLat > lat) != (y > lat)) { + const double cross = prevLon + (lat - prevLat) * (x - prevLon) / (y - prevLat); + if (lon < cross) { + inside = !inside; + } + } + prevLon = x; + prevLat = y; + } + if ((prevLat > lat) != (firstLat > lat)) { + const double cross = prevLon + (lat - prevLat) * (firstLon - prevLon) / (firstLat - prevLat); + if (lon < cross) { + inside = !inside; + } + } + } + return inside; +} + +int RegionCatalog::smallestContaining(double lat, double lon) const { + if (!open_) { + return -1; + } + int best = -1; + int bestLevel = -1; + double bestArea = 0.0; + for (int i = 0; i < count_; i++) { + if (!contains(i, lat, lon)) { + continue; + } + const CatalogRegion info = region(i); + double width = info.box.east - info.box.west; + double height = info.box.north - info.box.south; + if (width < 0.0) { + width = -width; + } + if (height < 0.0) { + height = -height; + } + const double area = width * height; + if (info.level > bestLevel || (info.level == bestLevel && area < bestArea)) { + best = i; + bestLevel = info.level; + bestArea = area; + } + } + return best; +} + +int64_t RegionCatalog::forEachTile(int index, int z, bool (*visit)(const TileKey& tile, void* context), void* context, int64_t limit) const { + if (!open_ || index < 0 || index >= count_ || z < 0 || z > 30) { + return 0; + } + const uint8_t* rec = record(index); + const uint16_t ringCount = readU16(rec + 28); + size_t outlineEnd = 0; + const uint8_t* outline = outlineStart(index, outlineEnd); + if (outline == nullptr) { + return 0; + } + + const CatalogRegion info = region(index); + const TileRange rows = tileRange(info.box, z); + const int maxTile = (1 << z) - 1; + int64_t visited = 0; + + struct Interval { + int lo; + int hi; + }; + + for (int ty = rows.minY; ty <= rows.maxY; ty++) { + const double yc = static_cast(ty) + 0.5; + float crossings[MAX_CROSSINGS]; + int crossingCount = 0; + bool crossingOverflow = false; + + Interval intervals[MAX_INTERVALS]; + int intervalCount = 0; + bool intervalOverflow = false; + int spanMin = 0; + int spanMax = -1; + + auto addInterval = [&](int lo, int hi) { + if (lo > hi) { + return; + } + if (spanMax < spanMin) { + spanMin = lo; + spanMax = hi; + } else { + if (lo < spanMin) { + spanMin = lo; + } + if (hi > spanMax) { + spanMax = hi; + } + } + if (intervalCount < MAX_INTERVALS) { + intervals[intervalCount].lo = lo; + intervals[intervalCount].hi = hi; + intervalCount++; + } else { + intervalOverflow = true; + } + }; + + auto addEdge = [&](double x1, double y1, double x2, double y2) { + if ((y1 <= yc) != (y2 <= yc)) { + const double t = (yc - y1) / (y2 - y1); + const double x = x1 + t * (x2 - x1); + if (crossingCount < MAX_CROSSINGS) { + crossings[crossingCount++] = static_cast(x); + } else { + crossingOverflow = true; + } + } + + const double dx = x2 - x1; + const double dy = y2 - y1; + const double bandTop = static_cast(ty); + const double bandBottom = bandTop + 1.0; + if (dy == 0.0) { + if (y1 >= bandTop && y1 < bandBottom) { + addInterval(floorToInt(std::min(x1, x2)), floorToInt(std::max(x1, x2))); + } + return; + } + const double tA = (bandTop - y1) / dy; + const double tB = (bandBottom - y1) / dy; + const double tMin = std::min(tA, tB); + const double tMax = std::max(tA, tB); + if (tMax < 0.0 || tMin > 1.0) { + return; + } + const double tLo = std::max(0.0, tMin); + const double tHi = std::min(1.0, tMax); + if (tLo > tHi) { + return; + } + if (tLo == tHi) { + const double onlyY = y1 + tLo * dy; + if (onlyY < bandTop || onlyY >= bandBottom) { + return; + } + } + const double xa = x1 + tLo * dx; + const double xb = x1 + tHi * dx; + addInterval(floorToInt(std::min(xa, xb)), floorToInt(std::max(xa, xb))); + }; + + Cursor cursor { outline, outlineEnd, 0 }; + bool decodeOk = true; + for (uint16_t r = 0; r < ringCount && decodeOk; r++) { + uint64_t pointCount = 0; + if (!cursor.read(pointCount)) { + decodeOk = false; + break; + } + if (pointCount == 0) { + continue; + } + uint64_t vx = 0; + uint64_t vy = 0; + if (!cursor.read(vx) || !cursor.read(vy)) { + decodeOk = false; + break; + } + int64_t lonUnits = zigzagDecode(vx); + int64_t latUnits = zigzagDecode(vy); + const double firstX = lonToWorldX(static_cast(lonUnits) / UNIT, z) / TILE_SIZE; + const double firstY = latToWorldY(static_cast(latUnits) / UNIT, z) / TILE_SIZE; + double prevX = firstX; + double prevY = firstY; + if (pointCount < 2) { + continue; + } + for (uint64_t i = 1; i < pointCount; i++) { + uint64_t dx = 0; + uint64_t dy = 0; + if (!cursor.read(dx) || !cursor.read(dy)) { + decodeOk = false; + break; + } + lonUnits += zigzagDecode(dx); + latUnits += zigzagDecode(dy); + const double x = lonToWorldX(static_cast(lonUnits) / UNIT, z) / TILE_SIZE; + const double y = latToWorldY(static_cast(latUnits) / UNIT, z) / TILE_SIZE; + addEdge(prevX, prevY, x, y); + prevX = x; + prevY = y; + } + if (!decodeOk) { + break; + } + addEdge(prevX, prevY, firstX, firstY); + } + if (!decodeOk) { + return visited; + } + + if (crossingOverflow) { + addInterval(rows.minX, rows.maxX); + } else { + for (int i = 1; i < crossingCount; i++) { + const float value = crossings[i]; + int j = i - 1; + while (j >= 0 && crossings[j] > value) { + crossings[j + 1] = crossings[j]; + j--; + } + crossings[j + 1] = value; + } + for (int i = 0; i + 1 < crossingCount; i += 2) { + const double xa = crossings[i]; + const double xb = crossings[i + 1]; + const int txMin = ceilToInt(xa - 0.5); + const int txMax = ceilToInt(xb - 0.5) - 1; + addInterval(txMin, txMax); + } + } + + auto emit = [&](int lo, int hi) -> bool { + if (lo < 0) { + lo = 0; + } + if (hi > maxTile) { + hi = maxTile; + } + if (lo > hi) { + return true; + } + for (int tx = lo; tx <= hi; tx++) { + visited++; + if (visit != nullptr) { + const TileKey tile { z, tx, ty }; + if (!visit(tile, context)) { + return false; + } + } else if (limit != INT64_MAX && visited > limit) { + return false; + } + } + return true; + }; + + if (intervalOverflow) { + if (!emit(spanMin, spanMax)) { + return visited; + } + } else { + for (int i = 1; i < intervalCount; i++) { + const Interval current = intervals[i]; + int j = i - 1; + while (j >= 0 && intervals[j].lo > current.lo) { + intervals[j + 1] = intervals[j]; + j--; + } + intervals[j + 1] = current; + } + int merged = 0; + for (int i = 0; i < intervalCount; i++) { + if (merged == 0) { + intervals[merged++] = intervals[i]; + } else { + Interval& last = intervals[merged - 1]; + if (intervals[i].lo <= last.hi + 1) { + if (intervals[i].hi > last.hi) { + last.hi = intervals[i].hi; + } + } else { + intervals[merged++] = intervals[i]; + } + } + } + for (int i = 0; i < merged; i++) { + if (!emit(intervals[i].lo, intervals[i].hi)) { + return visited; + } + } + } + } + return visited; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/RegionCatalog.h b/Apps/Maps/main/Source/tiles/RegionCatalog.h new file mode 100644 index 0000000..fa0bda3 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/RegionCatalog.h @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "TileMath.h" + +#include +#include + +namespace maps { + +struct CatalogRegion { + int parent; // -1 for none + int level; + const char* name; // NOT NUL-terminated, nameLength bytes + int nameLength; + BoundingBox box; // degrees (from TileMath.h) + int childCount; +}; + +/** Reads a region catalog file held in memory; the buffer must outlive the catalog. */ +class RegionCatalog { +public: + /** Checks the header and that every record's offsets lie inside the buffer. */ + bool open(const uint8_t* data, size_t size); + bool isOpen() const; + int count() const; + /** Region i, 0 <= i < count(). */ + CatalogRegion region(int index) const; + /** Copies the region's name into out as a NUL-terminated string, cut to fit. */ + void copyName(int index, char* out, size_t size) const; + /** The children of parent (-1: the top level) into out in file order, at most max; returns how many. */ + int children(int parent, int* out, int max) const; + /** Whether the point lies inside the region's outline (even-odd over all its rings). */ + bool contains(int index, double lat, double lon) const; + /** The deepest region whose outline contains the point; among equal levels the one with the smaller box. -1 if none. */ + int smallestContaining(double lat, double lon) const; + /** + * The tiles at zoom z that touch the region's outline: a tile counts when its centre is inside, or when an outline + * edge passes through it. Calls visit(tile, context) for each, row by row from north to south, west to east, + * until visit returns false. Returns the number of tiles visited. With visit == nullptr it only counts, and stops + * counting once the count passes limit (returns limit + 1 then). + */ + int64_t forEachTile(int index, int z, bool (*visit)(const TileKey& tile, void* context), void* context, int64_t limit = INT64_MAX) const; + +private: + const uint8_t* record(int index) const; + const uint8_t* outlineStart(int index, size_t& end) const; + + const uint8_t* data_ = nullptr; + size_t size_ = 0; + int count_ = 0; + uint32_t outlineOffset_ = 0; + uint32_t nameOffset_ = 0; + bool open_ = false; +}; + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/RegionMeta.cpp b/Apps/Maps/main/Source/tiles/RegionMeta.cpp new file mode 100644 index 0000000..8c4fca3 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/RegionMeta.cpp @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "RegionMeta.h" + +#include +#include +#include + +namespace maps { + +namespace { + +const char* findValue(const char* text, const char* key) { + char pattern[32]; + snprintf(pattern, sizeof(pattern), "\"%s\"", key); + const char* found = strstr(text, pattern); + if (found == nullptr) { + return nullptr; + } + found = strchr(found + strlen(pattern), ':'); + if (found == nullptr) { + return nullptr; + } + found++; + while (*found == ' ' || *found == '\t' || *found == '\n' || *found == '\r') { + found++; + } + return found; +} + +bool readString(const char* text, const char* key, char* out, size_t size) { + const char* value = findValue(text, key); + if (value == nullptr || *value != '"') { + return false; + } + value++; + size_t length = 0; + while (value[length] != '"' && value[length] != '\0') { + length++; + } + if (value[length] != '"' || length >= size) { + return false; + } + memcpy(out, value, length); + out[length] = '\0'; + return true; +} + +bool readInteger(const char* text, const char* key, int64_t& out) { + const char* value = findValue(text, key); + if (value == nullptr) { + return false; + } + const bool negative = *value == '-'; + if (negative) { + value++; + } + if (*value < '0' || *value > '9') { + return false; + } + int64_t number = 0; + while (*value >= '0' && *value <= '9') { + number = number * 10 + (*value - '0'); + value++; + } + out = negative ? -number : number; + return true; +} + +} // namespace + +bool formatRegionMeta(const RegionMeta& meta, char* out, size_t size) { + const int written = snprintf(out, size, + "{\n" + " \"name\": \"%s\",\n" + " \"bbox\": [%.6f,%.6f,%.6f,%.6f],\n" + " \"minzoom\": %d,\n" + " \"maxzoom\": %d,\n" + " \"source\": \"%s\",\n" + " \"version\": \"%s\",\n" + " \"date\": \"%s\",\n" + " \"total\": %" PRId64 ",\n" + " \"done\": %" PRId64 ",\n" + " \"bytes\": %" PRIu64 ",\n" + " \"moved\": %" PRIu64 ",\n" + " \"outline\": \"%s\",\n" + " \"title\": \"%s\"\n" + "}\n", + meta.name, meta.box.west, meta.box.south, meta.box.east, meta.box.north, meta.minZoom, meta.maxZoom, + meta.source, meta.version, meta.date, meta.total, meta.done, meta.bytes, meta.moved, meta.outline, meta.title); + return written > 0 && static_cast(written) < size; +} + +bool parseRegionMeta(const char* text, RegionMeta& meta) { + meta = {}; + const char* bbox = findValue(text, "bbox"); + if (bbox == nullptr || sscanf(bbox, "[%lf,%lf,%lf,%lf]", &meta.box.west, &meta.box.south, &meta.box.east, &meta.box.north) != 4) { + return false; + } + int64_t minZoom = 0; + int64_t maxZoom = 0; + if (!readInteger(text, "minzoom", minZoom) || !readInteger(text, "maxzoom", maxZoom) || + minZoom < 0 || maxZoom > MAX_SOURCE_ZOOM || minZoom > maxZoom) { + return false; + } + meta.minZoom = static_cast(minZoom); + meta.maxZoom = static_cast(maxZoom); + readString(text, "name", meta.name, sizeof(meta.name)); + readString(text, "source", meta.source, sizeof(meta.source)); + readString(text, "version", meta.version, sizeof(meta.version)); + readString(text, "date", meta.date, sizeof(meta.date)); + // Written last, so a title that looks like a key cannot be taken for one of the keys above + readString(text, "outline", meta.outline, sizeof(meta.outline)); + readString(text, "title", meta.title, sizeof(meta.title)); + int64_t bytes = 0; + readInteger(text, "bytes", bytes); + meta.bytes = static_cast(bytes); + int64_t moved = 0; + if (readInteger(text, "moved", moved) && moved > 0) { + meta.moved = static_cast(moved); + } + if (!readInteger(text, "total", meta.total)) { + meta.total = tileCount(meta.box, meta.minZoom, meta.maxZoom); + } + if (!readInteger(text, "done", meta.done)) { + meta.done = 0; + } + return true; +} + +void setRegionTitle(RegionMeta& meta, const char* title) { + // Quotes and backslashes would end the JSON string early, the title only needs to read well + size_t length = 0; + for (; title[length] != '\0' && length + 1 < sizeof(meta.title); length++) { + const char c = title[length]; + meta.title[length] = c == '"' || c == '\\' || static_cast(c) < 0x20 ? '\'' : c; + } + // Never end inside a UTF-8 sequence: when the title was cut, drop a last character that lost bytes + if (title[length] != '\0') { + size_t lead = length; + while (lead > 0 && (static_cast(meta.title[lead - 1]) & 0xC0) == 0x80) { + lead--; + } + if (lead > 0 && (static_cast(meta.title[lead - 1]) & 0x80) != 0) { + const auto first = static_cast(meta.title[lead - 1]); + const size_t needed = first >= 0xF0 ? 4 : (first >= 0xE0 ? 3 : 2); + if (length - (lead - 1) < needed) { + length = lead - 1; + } + } + } + meta.title[length] = '\0'; +} + +void regionFolderName(const char* title, int number, char* out, size_t size) { + char base[REGION_NAME_MAX + 1]; + size_t length = 0; + for (const char* c = title; *c != '\0' && length < REGION_NAME_MAX; c++) { + if (*c >= 'A' && *c <= 'Z') { + base[length++] = static_cast(*c - 'A' + 'a'); + } else if ((*c >= 'a' && *c <= 'z') || (*c >= '0' && *c <= '9')) { + base[length++] = *c; + } + } + base[length] = '\0'; + if (length == 0) { + snprintf(base, sizeof(base), "area"); + length = 4; + } + if (number <= 0) { + snprintf(out, size, "%s", base); + return; + } + char suffix[12]; + const int digits = snprintf(suffix, sizeof(suffix), "%d", number); + const size_t keep = length + static_cast(digits) <= REGION_NAME_MAX ? length : REGION_NAME_MAX - static_cast(digits); + snprintf(out, size, "%.*s%s", static_cast(keep), base, suffix); +} + +const char* regionTitle(const RegionMeta& meta) { + return meta.title[0] != '\0' ? meta.title : meta.name; +} + +bool writeRegionMeta(const char* path, const RegionMeta& meta) { + char text[896]; + if (!formatRegionMeta(meta, text, sizeof(text))) { + return false; + } + char temporary[PATH_MAX_LENGTH + 8]; + snprintf(temporary, sizeof(temporary), "%s.tmp", path); + FILE* file = fopen(temporary, "w"); + if (file == nullptr) { + return false; + } + const size_t length = strlen(text); + const bool ok = fwrite(text, 1, length, file) == length; + fclose(file); + remove(path); + if (!ok || rename(temporary, path) != 0) { + remove(temporary); + return false; + } + return true; +} + +bool readRegionMeta(const char* path, RegionMeta& meta) { + FILE* file = fopen(path, "r"); + if (file == nullptr) { + return false; + } + char text[1024]; + const size_t length = fread(text, 1, sizeof(text) - 1, file); + fclose(file); + text[length] = '\0'; + return parseRegionMeta(text, meta); +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/RegionMeta.h b/Apps/Maps/main/Source/tiles/RegionMeta.h new file mode 100644 index 0000000..ba46f15 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/RegionMeta.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "DownloadPlan.h" +#include "TilePaths.h" + +#include +#include + +namespace maps { + +/** The state of a downloaded region, kept in maps//meta.json so a download resumes after a restart. */ +struct RegionMeta { + char name[REGION_NAME_MAX + 1]; + BoundingBox box; + int minZoom; + int maxZoom; + char source[160]; + char version[48]; + char date[24]; + int64_t total; + int64_t done; + uint64_t bytes; + /** The part of bytes that was moved over from the browsed region, not downloaded: outside the download limit. */ + uint64_t moved; + /** The catalog region whose outline the tiles follow, empty for a rectangle (Download this area). */ + char outline[64]; + /** The name shown to the user, e.g. "Szeged"; the folder name stays the short 8.3 name. Empty: the folder name. */ + char title[64]; + + bool isComplete() const { return total > 0 && done >= total; } + DownloadPlan plan() const { return { box, minZoom, maxZoom, total, 0, true }; } +}; + +bool writeRegionMeta(const char* path, const RegionMeta& meta); +/** Sets the display title, made safe for meta.json and cut at a UTF-8 character boundary. */ +void setRegionTitle(RegionMeta& meta, const char* title); +/** The title, or the folder name when there is none. */ +const char* regionTitle(const RegionMeta& meta); +/** + * A folder name for a region titled title: its ASCII letters and digits in lower case, at most REGION_NAME_MAX + * characters, so every path element stays a valid 8.3 name; number > 0 replaces the end with that number, for a + * name that is taken. "Zuid-Holland" gives "zuidholl", with number 2 "zuidhol2"; a title without letters gives "area". + */ +void regionFolderName(const char* title, int number, char* out, size_t size); +bool readRegionMeta(const char* path, RegionMeta& meta); +bool formatRegionMeta(const RegionMeta& meta, char* out, size_t size); +bool parseRegionMeta(const char* text, RegionMeta& meta); + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/TileJson.cpp b/Apps/Maps/main/Source/tiles/TileJson.cpp new file mode 100644 index 0000000..c6494ee --- /dev/null +++ b/Apps/Maps/main/Source/tiles/TileJson.cpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "TileJson.h" + +#include +#include + +namespace maps { + +bool parseTileJson(const char* text, char* urlTemplate, size_t templateSize, char* version, size_t versionSize) { + const char* tiles = strstr(text, "\"tiles\""); + if (tiles == nullptr) { + return false; + } + const char* open = strchr(tiles, '['); + if (open == nullptr) { + return false; + } + const char* start = strchr(open, '"'); + if (start == nullptr) { + return false; + } + start++; + const char* end = strchr(start, '"'); + if (end == nullptr || static_cast(end - start) >= templateSize) { + return false; + } + const size_t length = static_cast(end - start); + memcpy(urlTemplate, start, length); + urlTemplate[length] = '\0'; + // Only https URLs with all three placeholders are accepted + if (strncmp(urlTemplate, "https://", 8) != 0 || strstr(urlTemplate, "{z}") == nullptr || + strstr(urlTemplate, "{x}") == nullptr || strstr(urlTemplate, "{y}") == nullptr || + strstr(urlTemplate, "openstreetmap.org") != nullptr) { + return false; + } + version[0] = '\0'; + const char* placeholder = strstr(urlTemplate, "/{z}"); + if (placeholder != nullptr) { + const char* segment = placeholder; + while (segment > urlTemplate && segment[-1] != '/') { + segment--; + } + const size_t versionLength = static_cast(placeholder - segment); + if (versionLength < versionSize) { + memcpy(version, segment, versionLength); + version[versionLength] = '\0'; + } + } + return true; +} + +bool formatTileUrl(const char* urlTemplate, const TileKey& tile, char* out, size_t size) { + size_t used = 0; + for (const char* p = urlTemplate; *p != '\0';) { + int written = 0; + if (strncmp(p, "{z}", 3) == 0) { + written = snprintf(out + used, size - used, "%d", tile.z); + p += 3; + } else if (strncmp(p, "{x}", 3) == 0) { + written = snprintf(out + used, size - used, "%d", tile.x); + p += 3; + } else if (strncmp(p, "{y}", 3) == 0) { + written = snprintf(out + used, size - used, "%d", tile.y); + p += 3; + } else { + if (used + 1 >= size) { + return false; + } + out[used] = *p++; + written = 1; + } + if (written <= 0 || used + static_cast(written) >= size) { + return false; + } + used += static_cast(written); + } + out[used] = '\0'; + return true; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/TileJson.h b/Apps/Maps/main/Source/tiles/TileJson.h new file mode 100644 index 0000000..2f8cf53 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/TileJson.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "TileMath.h" + +#include + +namespace maps { + +constexpr const char* TILEJSON_URL = "https://tiles.openfreemap.org/planet"; +constexpr const char* USER_AGENT = "TactilityMaps/0.2.0 (+https://github.com/TactilityProject/TactilityApps)"; + +/** + * Reads the first tile URL template of a TileJSON document, like ".../planet/20261025_080012_pt/{z}/{x}/{y}.pbf", + * and the version, which is the path segment before "/{z}". + */ +bool parseTileJson(const char* text, char* urlTemplate, size_t templateSize, char* version, size_t versionSize); + +/** Fills {z}, {x} and {y} of a URL template. */ +bool formatTileUrl(const char* urlTemplate, const TileKey& tile, char* out, size_t size); + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/TileMath.cpp b/Apps/Maps/main/Source/tiles/TileMath.cpp new file mode 100644 index 0000000..4ee0645 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/TileMath.cpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "TileMath.h" + +#include +#include + +namespace maps { + +namespace { + +constexpr double PI = 3.14159265358979323846; +constexpr double EARTH_CIRCUMFERENCE_M = 40075016.686; + +double worldSize(int z) { + return static_cast(TILE_SIZE) * static_cast(1u << z); +} + +} // namespace + +double lonToWorldX(double lon, int z) { + return (lon + 180.0) / 360.0 * worldSize(z); +} + +double latToWorldY(double lat, int z) { + lat = std::clamp(lat, -MAX_LATITUDE, MAX_LATITUDE); + const double rad = lat * PI / 180.0; + return (1.0 - std::log(std::tan(rad) + 1.0 / std::cos(rad)) / PI) / 2.0 * worldSize(z); +} + +double worldXToLon(double x, int z) { + return x / worldSize(z) * 360.0 - 180.0; +} + +double worldYToLat(double y, int z) { + const double n = PI - 2.0 * PI * y / worldSize(z); + return 180.0 / PI * std::atan(std::sinh(n)); +} + +TileKey tileAt(double lat, double lon, int z) { + const int last = (1 << z) - 1; + const int x = std::clamp(static_cast(std::floor(lonToWorldX(lon, z) / TILE_SIZE)), 0, last); + const int y = std::clamp(static_cast(std::floor(latToWorldY(lat, z) / TILE_SIZE)), 0, last); + return { z, x, y }; +} + +TileRange tileRange(const BoundingBox& box, int z) { + const TileKey topLeft = tileAt(box.north, box.west, z); + const TileKey bottomRight = tileAt(box.south, box.east, z); + return { + .z = z, + .minX = std::min(topLeft.x, bottomRight.x), + .minY = std::min(topLeft.y, bottomRight.y), + .maxX = std::max(topLeft.x, bottomRight.x), + .maxY = std::max(topLeft.y, bottomRight.y), + }; +} + +int64_t tileCount(const BoundingBox& box, int minZoom, int maxZoom) { + int64_t total = 0; + for (int z = minZoom; z <= maxZoom; z++) { + total += tileRange(box, z).count(); + } + return total; +} + +double metresPerPixel(double lat, int z) { + return EARTH_CIRCUMFERENCE_M * std::cos(lat * PI / 180.0) / worldSize(z); +} + +TileKey ancestorOf(const TileKey& tile, int z) { + const int shift = tile.z - z; + return { z, tile.x >> shift, tile.y >> shift }; +} + +bool isValidTile(const TileKey& tile) { + if (tile.z < 0 || tile.z > 30) { + return false; + } + const int count = 1 << tile.z; + return tile.x >= 0 && tile.x < count && tile.y >= 0 && tile.y < count; +} + +int wrapTileX(int x, int z) { + const int count = 1 << z; + x %= count; + return x < 0 ? x + count : x; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/TileMath.h b/Apps/Maps/main/Source/tiles/TileMath.h new file mode 100644 index 0000000..be912ff --- /dev/null +++ b/Apps/Maps/main/Source/tiles/TileMath.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace maps { + +constexpr int TILE_SIZE = 256; +constexpr int MAX_SOURCE_ZOOM = 14; +constexpr int MAX_VIEW_ZOOM = 19; +constexpr double MAX_LATITUDE = 85.05112878; + +struct TileKey { + int z; + int x; + int y; + + bool operator==(const TileKey& other) const { return z == other.z && x == other.x && y == other.y; } +}; + +struct TileRange { + int z; + int minX; + int minY; + int maxX; + int maxY; + + int64_t count() const { return static_cast(maxX - minX + 1) * (maxY - minY + 1); } +}; + +struct BoundingBox { + double west; + double south; + double east; + double north; +}; + +/** World pixel position at a zoom level, where the whole world is TILE_SIZE * 2^z pixels wide. */ +double lonToWorldX(double lon, int z); +double latToWorldY(double lat, int z); +double worldXToLon(double x, int z); +double worldYToLat(double y, int z); + +TileKey tileAt(double lat, double lon, int z); + +/** Tiles covering a bounding box at one zoom level. The box must not cross the antimeridian. */ +TileRange tileRange(const BoundingBox& box, int z); + +int64_t tileCount(const BoundingBox& box, int minZoom, int maxZoom); + +/** Ground metres per pixel at a latitude and zoom level. */ +double metresPerPixel(double lat, int z); + +/** The tile at zoom z that contains the given tile, z must not exceed tile.z. */ +TileKey ancestorOf(const TileKey& tile, int z); + +bool isValidTile(const TileKey& tile); + +/** The tile at x, wrapped around the antimeridian. */ +int wrapTileX(int x, int z); + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/TilePaths.cpp b/Apps/Maps/main/Source/tiles/TilePaths.cpp new file mode 100644 index 0000000..d4ca131 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/TilePaths.cpp @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "TilePaths.h" + +#include +#include + +namespace maps { + +namespace { + +bool fits(int written, size_t size) { + return written > 0 && static_cast(written) < size; +} + +} // namespace + +bool tilePath(char* out, size_t size, const char* root, const char* region, const TileKey& tile) { + return fits(snprintf(out, size, "%s/maps/%s/%d/%d/%d.pbf", root, region, tile.z, tile.x, tile.y), size); +} + +bool preparedPath(char* out, size_t size, const char* root, const char* region, const TileKey& tile) { + return fits(snprintf(out, size, "%s/maps/%s/%d/%d/%d.prt", root, region, tile.z, tile.x, tile.y), size); +} + +bool preparedTempPath(char* out, size_t size, const char* root, const char* region, const TileKey& tile) { + return fits(snprintf(out, size, "%s/maps/%s/%d/%d/%d.ptm", root, region, tile.z, tile.x, tile.y), size); +} + +bool preparedFilePath(char* out, size_t size, const char* root, const char* region, const TileKey& tile, PreparedFile file) { + static const char* const EXTENSIONS[PREPARED_FILE_COUNT] = { "prt", "prl", "prg" }; + return fits(snprintf(out, size, "%s/maps/%s/%d/%d/%d.%s", root, region, tile.z, tile.x, tile.y, EXTENSIONS[static_cast(file)]), size); +} + +bool preparedFileTempPath(char* out, size_t size, const char* root, const char* region, const TileKey& tile, PreparedFile file) { + static const char* const EXTENSIONS[PREPARED_FILE_COUNT] = { "ptm", "plm", "pgm" }; + return fits(snprintf(out, size, "%s/maps/%s/%d/%d/%d.%s", root, region, tile.z, tile.x, tile.y, EXTENSIONS[static_cast(file)]), size); +} + +bool tileFolderPath(char* out, size_t size, const char* root, const char* region, const TileKey& tile) { + return fits(snprintf(out, size, "%s/maps/%s/%d/%d", root, region, tile.z, tile.x), size); +} + +bool cachePath(char* out, size_t size, const char* root, const char* style, const TileKey& tile) { + return fits(snprintf(out, size, "%s/cache/%s/%d/%d/%d", root, style, tile.z, tile.x, tile.y), size); +} + +bool cacheFolderPath(char* out, size_t size, const char* root, const char* style, const TileKey& tile) { + return fits(snprintf(out, size, "%s/cache/%s/%d/%d", root, style, tile.z, tile.x), size); +} + +bool isInsideCache(const char* path, const char* root) { + const size_t rootLength = strlen(root); + if (strncmp(path, root, rootLength) != 0 || strncmp(path + rootLength, "/cache/", 7) != 0 || path[rootLength + 7] == '\0') { + return false; + } + // No way back out of the cache folder + return strstr(path, "/..") == nullptr && strstr(path, "\\") == nullptr; +} + +bool isValidRegionName(const char* name) { + const size_t length = strlen(name); + if (length == 0 || length > REGION_NAME_MAX) { + return false; + } + for (size_t i = 0; i < length; i++) { + const char c = name[i]; + const bool valid = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!valid) { + return false; + } + } + return true; +} + +} // namespace maps diff --git a/Apps/Maps/main/Source/tiles/TilePaths.h b/Apps/Maps/main/Source/tiles/TilePaths.h new file mode 100644 index 0000000..97d4e3e --- /dev/null +++ b/Apps/Maps/main/Source/tiles/TilePaths.h @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "TileMath.h" + +#include +#include + +namespace maps { + +constexpr size_t PATH_MAX_LENGTH = 128; +constexpr size_t REGION_NAME_MAX = 8; + +/** Writes "/maps////.pbf". Returns false when the path does not fit. */ +bool tilePath(char* out, size_t size, const char* root, const char* region, const TileKey& tile); + +/** Writes "/maps////.prt", the prepared copy next to the original. */ +bool preparedPath(char* out, size_t size, const char* root, const char* region, const TileKey& tile); +/** Writes "/maps////.ptm", where a prepared tile is written before it is renamed. */ +bool preparedTempPath(char* out, size_t size, const char* root, const char* region, const TileKey& tile); + +/** The prepared files next to an original: geometry (.prt), labels (.prl) and the routing graph (.prg). */ +enum class PreparedFile : uint8_t { Geometry, Labels, Graph }; +constexpr int PREPARED_FILE_COUNT = 3; +/** Writes "/maps////.prt", ".prl" or ".prg". */ +bool preparedFilePath(char* out, size_t size, const char* root, const char* region, const TileKey& tile, PreparedFile file); +/** Where that file is written before it is renamed: ".ptm", ".plm" or ".pgm". */ +bool preparedFileTempPath(char* out, size_t size, const char* root, const char* region, const TileKey& tile, PreparedFile file); + +/** Writes "/maps///", the folder of a tile column. */ +bool tileFolderPath(char* out, size_t size, const char* root, const char* region, const TileKey& tile); + +/** Writes "/cache/