diff --git a/Apps/Maps/CHANGELOG.md b/Apps/Maps/CHANGELOG.md new file mode 100644 index 0000000..53d5968 --- /dev/null +++ b/Apps/Maps/CHANGELOG.md @@ -0,0 +1,158 @@ +# Changelog + +## 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..a3df056 --- /dev/null +++ b/Apps/Maps/README.md @@ -0,0 +1,216 @@ +# 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.2.0, tested on a LilyGO T-Deck (ESP32-S3, 8 MB PSRAM). Changes are listed in +[CHANGELOG.md](CHANGELOG.md). + +## Install + +Needs Tactility 0.8.0 or newer on an ESP32-S3 board with 8 MB PSRAM, a FAT-formatted SD card, and Wi-Fi +for downloading maps. Tested on Tactility 0.8.0-dev (main at 0e276da2) 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) | +| Menu | M | Long click | ☰ button | +| Download an area | Shift + D | — | Menu → Download map | +| Credits | — | Click © | Tap ©, tap again for About and licences | +| Leave the app | Esc or Backspace | Menu → Exit | Menu → Exit | + +**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. Map data downloaded with 0.1.0 is upgraded in +place in idle time, the tiles on screen first; no download is needed. + +**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. + +**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). + +## 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 files in `assets/` are made by the scripts in `tools/`: `make_label_font.py` makes the label font subset +`assets/fonts/LabelSans.ttf` (needs fontTools), `build_catalog.py` makes the region catalog `assets/regions.bin`. + +## 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..d4c6654 --- /dev/null +++ b/Apps/Maps/THIRD_PARTY.md @@ -0,0 +1,12 @@ +# Third-party code + +| Component | Where | Licence | Notes | +|---|---|---|---| +| 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 (U+0020-007E, U+00A0-024F Latin-1 and Latin Extended-A/-B, U+0259, U+02BB-02BC, U+02C6-02DD, U+0300-036F combining marks, U+0370-03FF Greek, U+0400-052F Cyrillic, U+1E00-1EFF Latin Extended Additional, U+2010-2027, U+2030-203A, U+20AC, U+2116, weight 550, no hinting or layout tables) made by `tools/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. | + +`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. diff --git a/Apps/Maps/assets/fonts/LabelSans.ttf b/Apps/Maps/assets/fonts/LabelSans.ttf new file mode 100644 index 0000000..c09b0bd 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/Debug.cpp b/Apps/Maps/main/Source/app/Debug.cpp new file mode 100644 index 0000000..f48ae23 --- /dev/null +++ b/Apps/Maps/main/Source/app/Debug.cpp @@ -0,0 +1,144 @@ +// 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; + } + } + 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..5604f8f --- /dev/null +++ b/Apps/Maps/main/Source/app/Debug.h @@ -0,0 +1,49 @@ +// 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". + */ +class DebugScript { +public: + enum class Action { + None, + Key, + Shot, + Goto, + WaitIdle, + Exit + }; + + struct Step { + Action action; + uint32_t key; + char name[32]; + double lat; + double lon; + int zoom; + }; + + ~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..31b3170 --- /dev/null +++ b/Apps/Maps/main/Source/app/Downloader.cpp @@ -0,0 +1,1689 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Downloader.h" +#include "RootCertificates.h" + +#include "../platform/Platform.h" +#include "../mvt/PlaceFinder.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 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 + for (int i = 0; i < job->store->regionCount(); i++) { + const char* other = job->store->regionName(i); + if (strcmp(other, job->region) != 0 && tilePath(path, sizeof(path), job->root, other, job->tile) && fileExists(path)) { + return true; + } + } + return false; +} + +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 + if (preparedPath(prepared, sizeof(prepared), job->root, job->region, job->tile) && 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 }; + const bool found = io->run(IoPriority::Background, onCardJob, &job); + inThisRegion = job.inThisRegion; + return found; +} + +bool Downloader::downloadTile(Connection& connection, const char* region, const TileKey& tile, int& status) { + 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 waits until the map has been left alone for a moment, writes hold the display's shared bus + while (!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; + } + 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; + for (int i = 0; i < CONNECTIONS; i++) { + bytes += connections[i].received; + } + copy.bytes = meta.bytes + bytes; + 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; + } + bool inThisRegion = false; + if (tileOnCard(meta.name, tile, inThisRegion)) { + finishIndex(connection.index); + continue; + } + 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; + } + // The limit holds for the real size too, the estimate can be lower for dense city centres + xSemaphoreTake(mutex, portMAX_DELAY); + uint64_t bytes = meta.bytes; + 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); + for (int i = 0; i < CONNECTIONS; i++) { + meta.bytes += connections[i].received; + connections[i].received = 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); + 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); + 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..6fd74d5 --- /dev/null +++ b/Apps/Maps/main/Source/app/Downloader.h @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IoTask.h" + +#include "../render/PreparedTile.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. + * The tile URL comes from OpenFreeMap's TileJSON. Two connections 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: + static constexpr int CONNECTIONS = 2; + /** + * 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(); + /** 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; + }; + + 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); + bool downloadTile(Connection& connection, const char* region, const TileKey& tile, int& status); + 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..52ccdf8 --- /dev/null +++ b/Apps/Maps/main/Source/app/Input.cpp @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Input.h" + +#include "../platform/Platform.h" + +#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", keyboardCount, mouseCount, 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); + if (dropped > 0 || keyboardCount != keyboardsBefore || mouseCount != miceBefore) { + devicesChanged = true; + logInfo("Input: %d keyboards, %d mice", keyboardCount, mouseCount); + } +} + +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) { + 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) { + 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..ba5d5b8 --- /dev/null +++ b/Apps/Maps/main/Source/app/Input.h @@ -0,0 +1,64 @@ +// 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; } + bool hasKeyboard() const { return keyboardCount > 0; } + bool hasPointer() const { return mouseCount > 0; } + /** 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] {}; + 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..65ffc51 --- /dev/null +++ b/Apps/Maps/main/Source/app/IoTask.cpp @@ -0,0 +1,244 @@ +// 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) { + buffer.clear(); + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return false; + } + bool ok = fseek(file, 0, SEEK_END) == 0; + const long size = ok ? ftell(file) : -1; + 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..dfb5966 --- /dev/null +++ b/Apps/Maps/main/Source/app/IoTask.h @@ -0,0 +1,104 @@ +// 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 + +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. Returns false on error or when it yielded. */ + bool readFile(const char* path, FileBuffer& buffer); + /** 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..0cd4feb --- /dev/null +++ b/Apps/Maps/main/Source/app/LabelFontMaker.cpp @@ -0,0 +1,162 @@ +// 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%db.bin", root, EM_SIZES[i]); +} + +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); + } + 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..0d211c7 --- /dev/null +++ b/Apps/Maps/main/Source/app/LabelFontMaker.h @@ -0,0 +1,60 @@ +// 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/lblb.bin ("b" is the format number: change it when the sizes or the + * subset change). 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; + /** + * 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..5cadb8d --- /dev/null +++ b/Apps/Maps/main/Source/app/LabelStore.cpp @@ -0,0 +1,76 @@ +// 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].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].bytes); + entries[index] = entries[--entryCount]; +} + +void LabelStore::put(const TileKey& source, const uint8_t* section, size_t size, int count) { + if (mutex == nullptr || size == 0 || size > MAX_BYTES / 4) { + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + const int existing = find(source); + if (existing >= 0 && entries[existing].size == size) { + // The same tile drawn again, for another zoom level or after the raster cache dropped it + entries[existing].lastUse = ++useCounter; + xSemaphoreGive(mutex); + return; + } + 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); + } + auto* copy = static_cast(largeAlloc(size)); + if (copy != nullptr) { + memcpy(copy, section, size); + entries[entryCount++] = { source, copy, size, count, ++useCounter }; + bytesUsed += size; + changes++; + } + 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..1021a47 --- /dev/null +++ b/Apps/Maps/main/Source/app/LabelStore.h @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../tiles/TileMath.h" + +#include +#include + +#include +#include +#include + +namespace maps { + +/** + * The label sections of the source tiles drawn lately, copied out by the render lanes as they draw a prepared tile + * (TileRenderer's label hook) and read by the map loop to place labels. 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; + static constexpr size_t MAX_BYTES = 384 * 1024; + + bool begin(); + void end(); + /** Called on a render lane: keeps a copy of a tile's label section, replacing an older one. */ + void put(const TileKey& source, const uint8_t* section, size_t size, int count); + /** + * 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); + } + 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; + }; + + int find(const TileKey& tile) const; + void evict(int index); + + 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..8928402 --- /dev/null +++ b/Apps/Maps/main/Source/app/MapApp.cpp @@ -0,0 +1,130 @@ +// 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 + +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); + } +} + +} // 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; + } + mkdir(root, 0777); + char mapsFolder[maps::PATH_MAX_LENGTH]; + snprintf(mapsFolder, sizeof(mapsFolder), "%s/maps", root); + mkdir(mapsFolder, 0777); + char dataPath[maps::PATH_MAX_LENGTH] = ""; + paths_get_data_path(dataPath, sizeof(dataPath)); + + 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..e916f68 --- /dev/null +++ b/Apps/Maps/main/Source/app/MapInput.cpp @@ -0,0 +1,674 @@ +// 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 (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 (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; + } + 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: + 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 (menuOpen || sheet.isOpen() || downloadCard.isOpen() || screen != Screen::Map) { + handleKey(keys::ENTER); + } else if (controls.isVisible() && controls.usesFocus()) { + pressControl(controls.focused()); + } 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::handleTouch(uint32_t now) { + TouchState state; + 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; + 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 (screen == Screen::Map && !menuOpen && pillRect.width > 0 && pillRect.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 (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 (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/MapPlaces.cpp b/Apps/Maps/main/Source/app/MapPlaces.cpp new file mode 100644 index 0000000..18176da --- /dev/null +++ b/Apps/Maps/main/Source/app/MapPlaces.cpp @@ -0,0 +1,759 @@ +// 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 "../ui/Pins.h" +#include "../platform/Platform.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace maps { + +namespace { + +constexpr int PIN_HIT = 14; +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) { + 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]; + 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, input.hasKeyboard()); + sheet.layout(width, height, overlay.font()); + 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, overlay.font()); + 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; + break; + case WaypointSheet::Button::Rename: + if (!input.hasKeyboard()) { + showToast("Renaming needs a keyboard"); + break; + } + askRenameWaypoint(selectedWaypoint); + break; + case WaypointSheet::Button::Delete: + askDeleteWaypoint(selectedWaypoint); + break; + default: + break; + } +} + +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 + menu.add("Go to"); + popupActions[popupActionCount++] = 0; + if (input.hasKeyboard()) { + 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 (!input.hasKeyboard() || 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..36c101c --- /dev/null +++ b/Apps/Maps/main/Source/app/MapScreens.cpp @@ -0,0 +1,1013 @@ +// 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.", + "", + "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; + +/** 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; + } + 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 { + 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; + } +} + +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 (input.hasKeyboard()) { + 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 (!input.hasKeyboard()) { + 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.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::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) { + 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() { + drawDownloadScreen(canvas, overlay.font(), theme, downloadView, planDownload(frameBox(), MAX_SOURCE_ZOOM)); +} + +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..50e3aa4 --- /dev/null +++ b/Apps/Maps/main/Source/app/MapView.cpp @@ -0,0 +1,1544 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MapView.h" + +#include + +#include "../platform/Platform.h" +#include "../render/Compose.h" + +#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 + +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; + } + 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 card, where users find it + const char* second = strchr(rootPath + 1, '/'); + snprintf(gpxFolder, sizeof(gpxFolder), "%.*s/gpx", second != nullptr ? static_cast(second - rootPath) : 0, rootPath); + 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() }); + controls.layout(width, height, overlay.scaleBarTop(canvas)); + 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"); + } + 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(); + downloader.stop(); + 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; +} + +void MapView::centerOn(double lat, double lon, int newZoom) { + zoom = newZoom; + centerX = lonToWorldX(lon, zoom); + centerY = latToWorldY(lat, zoom); + 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 = {}; + 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); + 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."); + } + downloadCard.set(statusTitle[0] != '\0' ? statusTitle : status.region, rows, count, !running); + 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.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 + 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); + } else { + composeView(frame, width, height, centerX, centerY, zoom, lookupTile, this, palette->placeholder); + } + worker.unlock(); + frameLockMs = locked - start; + frameComposeMs = nowMs() - locked; + // 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))); + 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 }); + labelZoom = -1; +} + +void MapView::placeLabels() { + const uint32_t start = nowMs(); + labels.beginView(width, height, zoom); + 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) { + labels.addSection(bytes, size, count, transform); + }); + 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 from %d tiles in %u ms (reading %u, placing %u, of that drawing %u)", placedCount, + labels.candidateTotal(), 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::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(); + 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 (sheet.isOpen()) { + sheet.draw(canvas, overlay.font(), theme); + } + } + if (screen == Screen::Map) { + drawStatus(); + drawRecording(); + } + } + if (menuOpen) { + menu.draw(canvas, overlay.font(), theme); + } + if (dialog.isOpen()) { + dialog.draw(canvas, overlay.font(), theme); + } + if (prompt.isOpen()) { + prompt.draw(canvas, overlay.font(), theme); + } + if (downloadCard.isOpen() && !dialog.isOpen()) { + downloadCard.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); + } + centerOn(position.fix().latitude, position.fix().longitude, zoom); + 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(); + if (!position.update(location)) { + 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()) { + centerOn(position.fix().latitude, position.fix().longitude, zoom); + if (headingUp && headingFilter.update(position.fix().course, position.fix().speed, nowMs())) { + setBearing(headingFilter.heading(), false); + } + } + if (before != position.state() && !position.hasFix()) { + following = false; + headingUp = false; + } + 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); + probe.phase("loop"); + const uint32_t afterGaps = nowMs(); + + if (!waitingIdle) { + 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; + 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..baf0b8c --- /dev/null +++ b/Apps/Maps/main/Source/app/MapView.h @@ -0,0 +1,544 @@ +// 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 "LabelFontMaker.h" +#include "LabelStore.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.2.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. + */ +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 + }; + + enum class SettingItem : uint8_t { + SdCache, + PreparedTiles, + SmoothPinch, + BrowseDownload, + PointerSpeed, + Labels, + LatinNames, + Count + }; + + // MapView.cpp: the map itself + void centerOn(double lat, double lon, int zoom); + /** 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); + /** 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]; + LabelFontMaker fontMaker; + /** 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; + 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 = 12; + 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; + 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 {}; + /** 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/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..d1725af --- /dev/null +++ b/Apps/Maps/main/Source/app/TileWorker.cpp @@ -0,0 +1,1033 @@ +// 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; +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; + bool yielded; +}; + +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) || !fileSize(path, job->size) || + !preparedPath(prepared, sizeof(prepared), job->root, job->region, job->tile)) { + return false; + } + // Queued twice, or converted by the other lane meanwhile + job->current = PreparedScanner::isCurrent(prepared, job->tile, job->size); + 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; + 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) : io(io), path(path) { 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(IoPriority::Background, 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; + const ByteWriter* bytes; + bool yielded; +}; + +bool convertWriteJob(IoTask& io, void* context) { + auto* job = static_cast(context); + char prepared[PATH_MAX_LENGTH]; + char temporary[PATH_MAX_LENGTH]; + const bool ok = preparedPath(prepared, sizeof(prepared), job->root, job->region, job->tile) && + preparedTempPath(temporary, sizeof(temporary), job->root, job->region, job->tile) && + io.writeFile(prepared, temporary, job->bytes->bytes(), job->bytes->size(), true); + 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) { + lock(); + const int before = tileCache.slotCount(); + while (heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) < bytes && tileCache.slotCount() > MIN_CACHE_SLOTS && 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 download: %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 && magnifiedOutdated) { + magnifiedOutdated = false; + tileCache.dropMagnified(); + changed = true; + } + unlock(); +} + +bool TileWorker::queueConversion(const TileKey& tile, int region, bool urgent) { + 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) { + 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 }; + conversionCount = kept + 1; + return true; + } + if (conversionCount >= MAX_CONVERSIONS) { + return false; + } + conversions[conversionCount++] = { tile, region }; + return true; +} + +bool TileWorker::collectFromScan(const TileKey& tile, int region, void* context) { + auto* found = static_cast(context); + if (found->count >= found->room) { + return false; + } + found->items[found->count++] = { tile, region }; + 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) { + return false; + } + 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) { + // One conversion at a time: each needs about 0.4 MB of PSRAM, which a download needs as well. + // The oldest first, 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 ((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); + } + 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, false }; + bool ok = region[0] != '\0' && io->run(IoPriority::Background, convertReadJob, &read); + 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 {}; + 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; + } + if (ok) { + ok = lane.converter.convert(lane.renderer.tileDecoder(), input, job.tile, read.size, version, deflatePrepared, lane.converted, &stats); + reason = ok ? "writing the card" : lane.converter.failure(); + } + const uint32_t convertedAt = nowMs(); + ConvertWriteJob write { root, region, job.tile, &lane.converted, false }; + ok = ok && io->run(IoPriority::Background, convertWriteJob, &write); + requeue = write.yielded; + if (ok) { + logInfo("[%d] Prepared %d/%d/%d%s: %u -> %u bytes, read %u ms, convert %u ms, write %u ms", lane.index, job.tile.z, job.tile.x, + job.tile.y, streamed ? " in chunks" : "", static_cast(read.size), static_cast(lane.converted.size()), loaded - start, convertedAt - loaded, + nowMs() - convertedAt); + // Tiles drawn from the original or an older prepared copy are drawn again from the new one, which has labels + 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); + bool retry = false; + 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); + } + unlock(); + // Something was done, or the card is busy with more urgent work + return true; +} + +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(); + bool found = false; + const int first = visible ? 0 : visibleWanted; + const int last = visible ? visibleWanted : wantedCount; + 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); + } 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; + 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 = header.version < PREPARED_FORMAT_VERSION; + lane.heldKind = static_cast(Found::Prepared); + lane.heldOlder = job->olderPrepared; + 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(); + 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; + } + 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) { + 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 (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 (no labels) is made again before the idle scan gets to it + lock(); + queueConversion(job.source.tile, job.source.region, true); + unlock(); + } + 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 (writeOne(lane)) { + continue; + } + visible = false; + // Around the screen and other idle work only after a while without input + if (!idle || !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..3ebc83f --- /dev/null +++ b/Apps/Maps/main/Source/app/TileWorker.h @@ -0,0 +1,249 @@ +// 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 "../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 from an original first, then every tile on the card found by a PreparedScanner. + */ +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. + */ + bool relieveMemory(size_t bytes); + /** Takes the freed slots back when memory allows; true when none is missing any more. */ + bool restoreMemory(); + 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; } + 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); + /** 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) { idle = value; } + /** 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; + uint32_t heldGeneration; + TaskHandle_t task; + TileKey current; + bool active; + uint16_t* writeBuffer; + uint16_t* encodeBuffer; + TileConverter converter; + ByteWriter converted; + bool converting; + TileKey convertingTile; + bool prefetching; + std::atomic cancel; + }; + + struct Conversion { + TileKey tile; + int region; + }; + + 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; + + 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; + Found found; + TileFile source; + int cachedSourceZoom; + bool yielded; + }; + + 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); + static bool collectFromScan(const TileKey& tile, int region, void* context); + /** 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; + 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; + bool magnifiedOutdated = false; + std::atomic sdCache = true; + std::atomic idle = false; + 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..b24d2e5 --- /dev/null +++ b/Apps/Maps/main/Source/mvt/ByteInput.cpp @@ -0,0 +1,57 @@ +// 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) { + capacity = 0; + return false; + } + 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..ec5a8fa --- /dev/null +++ b/Apps/Maps/main/Source/mvt/ByteInput.h @@ -0,0 +1,107 @@ +// 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; + +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; } + /** 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..d544180 --- /dev/null +++ b/Apps/Maps/main/Source/mvt/MvtDecoder.cpp @@ -0,0 +1,547 @@ +// 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))); + } + if (namePool == nullptr) { + namePool = static_cast(largeAlloc(NAME_POOL_SIZE)); + } + if (chunk == nullptr) { + chunk = static_cast(largeAlloc(CHUNK_POINTS * sizeof(TilePoint))); + } + return reader.init() && valuePool != nullptr && namePool != nullptr && chunk != nullptr; +} + +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 = {}; + entry.rank = -1; + uint16_t code = 0; + int32_t rank = -1; + 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 <= static_cast(NAME_POOL_SIZE)) { + 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, "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 (!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 < VALUE_POOL_SIZE) { + 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) { + input = source; + 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 (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); + } else if (keyIndex == table.subclassKey && !isInteger) { + info.subclass = static_cast(code); + } 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.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; + } +} + +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..d56963b --- /dev/null +++ b/Apps/Maps/main/Source/mvt/MvtDecoder.h @@ -0,0 +1,111 @@ +// 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; + + MvtDecoder() = default; + ~MvtDecoder(); + MvtDecoder(const MvtDecoder&) = delete; + MvtDecoder& operator=(const MvtDecoder&) = delete; + + bool init(); + bool scan(ByteInput* input); + /** 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 nameKey = -1; + int16_t nameLatinKey = -1; + int16_t rankKey = -1; + uint32_t valueOffset = 0; + uint32_t valueCount = 0; + int32_t extent = 4096; + }; + + 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; + char* namePool = nullptr; + uint32_t namePoolUsed = 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..0663a6d --- /dev/null +++ b/Apps/Maps/main/Source/mvt/Schema.cpp @@ -0,0 +1,67 @@ +// 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 }, +}; + +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..fa33335 --- /dev/null +++ b/Apps/Maps/main/Source/mvt/Schema.h @@ -0,0 +1,81 @@ +// 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, + 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; + /** 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; +}; + +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..589f3ec --- /dev/null +++ b/Apps/Maps/main/Source/render/Compose.cpp @@ -0,0 +1,270 @@ +// 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 + +int composeAffine(uint16_t* view, int width, int height, const AffineView& transform, TileLookup lookup, void* context, uint16_t placeholder, + AffineScratch& scratch) { + // 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 (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; + } + + for (int y = 0; y < height; 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..1d61b25 --- /dev/null +++ b/Apps/Maps/main/Source/render/Compose.h @@ -0,0 +1,80 @@ +// 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. + */ +int composeAffine(uint16_t* view, int width, int height, const AffineView& transform, TileLookup lookup, void* context, uint16_t placeholder, + AffineScratch& scratch); + +} // 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..f65cae7 --- /dev/null +++ b/Apps/Maps/main/Source/render/LabelLayout.cpp @@ -0,0 +1,748 @@ +// 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. */ +bool labelFontCoversText(const char* text, uint16_t length) { + 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); + } + if (!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 +}; + +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)); + if (arena == nullptr || texts == nullptr || candidates == nullptr || linePoints == 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) }) { + if (block != nullptr) { + releaseMemory(block); + } + } + } + arena = nullptr; + texts = nullptr; + candidates = nullptr; + linePoints = nullptr; + placedCount = 0; + candidateCount = 0; +} + +void LabelLayout::setFonts(LabelFont* small, LabelFont* normal, LabelFont* large) { + 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; + 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; + } + return false; +} + +void LabelLayout::addSection(const uint8_t* section, size_t size, int count, const LabelTransform& transform) { + if (candidates == nullptr) { + return; + } + LabelReader reader; + reader.begin(section, size, count); + LabelRecord record {}; + while (candidateCount < MAX_CANDIDATES && reader.next(record)) { + Candidate candidate {}; + if (!wants(record, candidate.priority, candidate.fontSize, candidate.color)) { + continue; + } + // 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(record.name, record.nameLength)); + const char* name = latin ? record.nameLatin : record.name; + const uint16_t length = latin ? record.nameLatinLength : record.nameLength; + if (length == 0 || 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::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; + const float reach = candidate.line ? static_cast(LINE_REPEAT_PIXELS) : 120.0f; + for (int i = 0; i < placedCount; i++) { + const Placed& other = placed[i]; + if (other.line != candidate.line || 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 = textWidth(*font, texts + candidate.textOffset, candidate.textLength); + 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; +} + +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 = textWidth(*font, texts + candidate.textOffset, candidate.textLength); + 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) { + 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 + const float boxWidth = static_cast(textPixels) + 6.0f; + const float boxHeight = static_cast(font->lineHeight()) + 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 = text + candidate.textLength; + int penX16 = -textPixels * 8; + 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); + 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 { + 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); + } + } + } + } +} + +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..9d04a6e --- /dev/null +++ b/Apps/Maps/main/Source/render/LabelLayout.h @@ -0,0 +1,185 @@ +// 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; +}; + +/** + * 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 = 96; + static constexpr int MAX_CANDIDATES = 400; + 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; } + + /** 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. */ + void addSection(const uint8_t* section, size_t size, int count, const LabelTransform& transform); + /** 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; } + /** 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; } + 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; + }; + 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; + }; + + 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); + 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); + 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 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; + uint32_t (*clock)() = nullptr; + uint32_t spriteTime = 0; +}; + +} // 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..a835dc5 --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedLabels.cpp @@ -0,0 +1,134 @@ +// 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::Peak) || 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 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..4458a0c --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedLabels.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "../mvt/Schema.h" + +#include +#include + +namespace maps { + +enum class LabelKind : uint8_t { Place, Road, Water, Waterway, Peak }; + +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] {}; +}; + +/** 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..3b65991 --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedScanner.cpp @@ -0,0 +1,183 @@ +// 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; + } + if (PreparedScanner::isCurrent(path, tile, size)) { + return true; + } + if (!visit->callback(tile, visit->region, 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) { + FILE* file = fopen(preparedPath, "rb"); + if (file == nullptr) { + return false; + } + uint8_t bytes[PREPARED_HEADER_SIZE]; + const bool read = fread(bytes, 1, sizeof(bytes), file) == sizeof(bytes); + fclose(file); + PreparedHeader header {}; + return read && readPreparedHeader(bytes, sizeof(bytes), header) && isPreparedCurrent(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..569d395 --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedScanner.h @@ -0,0 +1,46 @@ +// 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. Returning false stops the step, it is looked at again next time. */ + using StaleCallback = bool (*)(const TileKey& tile, int region, 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 belongs to this original. */ + static bool isCurrent(const char* preparedPath, const TileKey& tile, uint32_t sourceSize); + +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..398bace --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedTile.cpp @@ -0,0 +1,642 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "PreparedTile.h" + +#include "../platform/Platform.h" +#include "../tiles/TileStore.h" +#include "PreparedLabels.h" + +#include + +#include +#include + +namespace maps { + +namespace { + +constexpr char MAGIC[4] = { 'T', 'M', 'P', 'T' }; +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; +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); + default: + return -1; + } +} + +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); + 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.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 header.version == PREPARED_FORMAT_VERSION && header.tile == tile && header.sourceSize == sourceSize && + header.extent == PREPARED_EXTENT; +} + +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 (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::convert(MvtDecoder& tileDecoder, ByteInput* input, const TileKey& sourceTile, uint32_t sourceSize, + const char* sourceVersion, bool deflate, ByteWriter& out, ConvertStats* stats) { + const uint32_t start = nowMs(); + decoder = &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(); + labelCount = 0; + failureText = ""; + if (!decoder->scan(input)) { + failureText = "reading the tile"; + return false; + } + Layer layers[static_cast(Stage::Count) + 5]; + 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; + // 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()) { + failureText = "no memory for the labels"; + return false; + } + + // The sections go straight into the output: one combined copy of them would double the memory a large tile needs + size_t 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(); + } + } + if (labelCount != 0) { + bodySize += encodeVarint(head, labelCount) + encodeVarint(head, labels.size()) + 1 + labels.size(); + } + + out.clear(); + out.putBytes(MAGIC, 4); + out.putU16(PREPARED_FORMAT_VERSION); + out.putU16(deflate ? PREPARED_FLAG_DEFLATED : 0); + out.put(static_cast(tile.z)); + out.put(0); + 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); + // 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); + tdefl_compressor* compressor = nullptr; + if (deflate) { + compressor = static_cast(largeAlloc(sizeof(tdefl_compressor))); + // 128 probes per search and a zlib header, so InflateReader recognises the stream + if (compressor == nullptr || tdefl_init(compressor, deflateCallback, &out, 128 | TDEFL_WRITE_ZLIB_HEADER) != TDEFL_STATUS_OKAY) { + largeFree(compressor); + failureText = "no 320 KB block of memory for the compressor"; + return false; + } + } + auto emit = [&](const void* bytes, size_t count) { + if (compressor == nullptr) { + out.putBytes(bytes, count); + return !out.failed(); + } + return tdefl_compress_buffer(compressor, bytes, count, TDEFL_NO_FLUSH) == TDEFL_STATUS_OKAY; + }; + bool ok = true; + 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 = emit(head, headSize) && emit(sections[i].bytes(), sections[i].size()); + } + if (ok && labelCount != 0) { + size_t headSize = 0; + head[headSize++] = PREPARED_LABEL_SECTION; + headSize += encodeVarint(head + headSize, labelCount); + headSize += encodeVarint(head + headSize, labels.size()); + ok = emit(head, headSize) && emit(labels.bytes(), labels.size()); + } + const uint8_t endMark = 0xFF; + ok = ok && emit(&endMark, 1); + if (compressor != nullptr) { + ok = ok && tdefl_compress_buffer(compressor, nullptr, 0, TDEFL_FINISH) == TDEFL_STATUS_DONE; + largeFree(compressor); + } + if (!ok || out.failed()) { + failureText = "no memory for the prepared tile"; + return false; + } + const auto stored = static_cast(out.size() - PREPARED_HEADER_SIZE); + out.setU32(28, stored); + if (stats != nullptr) { + *stats = counters; + stats->totalMs = nowMs() - start; + stats->bodyBytes = static_cast(bodySize); + stats->storedBytes = stored; + } + return !out.failed(); +} + +bool TileConverter::beginFeature(const FeatureInfo& info) { + counters.featuresIn++; + section = sectionOf(info.layer); + const int kind = labelKindOf(info.layer); + const bool hasLabel = kind >= 0 && info.name != nullptr && info.nameLength > 0; + // 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); + } + } + } + if (!drawn && !hasLabel) { + return false; + } + feature = info; + 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 int minimum = feature.geometryType == GEOMETRY_POINT ? 1 : 2; + if (kept >= minimum) { + writeLabel(quantised, kept); + } + } + 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) { + if (labels.size() >= LABEL_MAX_BYTES) { + return; + } + labels.put(labelKind); + labels.put(static_cast(feature.featureClass)); + labels.putVarint(feature.rank >= 0 ? static_cast(feature.rank) + 1 : 0); + labels.putVarint(feature.nameLength); + labels.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) { + labels.putVarint(feature.nameLatinLength); + labels.putBytes(feature.nameLatin, feature.nameLatinLength); + } else { + labels.putVarint(0); + } + labels.putVarint(static_cast(count)); + int32_t x = 0; + int32_t y = 0; + for (int i = 0; i < count; i++) { + labels.putZigzag(points[i * 2] - x); + labels.putZigzag(points[i * 2 + 1] - y); + x = points[i * 2]; + y = points[i * 2 + 1]; + } + 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..b3640d1 --- /dev/null +++ b/Apps/Maps/main/Source/render/PreparedTile.h @@ -0,0 +1,185 @@ +// 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. + */ +constexpr uint16_t PREPARED_FORMAT_VERSION = 3; +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; + +struct PreparedHeader { + uint16_t version; + uint16_t flags; + 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 was made by this format version 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); + +/** 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; +}; + +/** + * 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 tile of the vector tile in input to out. 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& 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); + + 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; + uint32_t labelCount = 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..e814de6 --- /dev/null +++ b/Apps/Maps/main/Source/render/TileRenderer.cpp @@ -0,0 +1,431 @@ +// 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; + } + + if (header.version == PREPARED_FORMAT_VERSION && 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/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..d507210 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/RegionMeta.cpp @@ -0,0 +1,212 @@ +// 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" + " \"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.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); + 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..25afdad --- /dev/null +++ b/Apps/Maps/main/Source/tiles/RegionMeta.h @@ -0,0 +1,48 @@ +// 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 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..2e9a39b --- /dev/null +++ b/Apps/Maps/main/Source/tiles/TilePaths.cpp @@ -0,0 +1,65 @@ +// 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 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..2525155 --- /dev/null +++ b/Apps/Maps/main/Source/tiles/TilePaths.h @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "TileMath.h" + +#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); + +/** 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/