Author SHA1 Message Date
Renovate Bot 9738fbe52e Update plugin com.android.application to v9.4.0 2026-09-07 02:01:03 +00:00
sora 64e0d5de03 Fix monochrome (no circle for now) 2026-09-06 20:40:28 +02:00
sora 2cf32128ba Cleanup and revert AGP to 9.3.2 2026-09-06 20:33:39 +02:00
sora 2b900afc30 Update CI/Build 2026-09-06 20:30:36 +02:00
sora d059d031bd Settings revamp 2026-09-06 20:28:38 +02:00
sora caff1a07cc Cleanup 2026-09-06 20:04:13 +02:00
sora fe766c8820 Custom Map Renderer 2026-09-06 19:53:57 +02:00
sora 1e6a374e96 BMAP handling 2026-09-06 19:44:08 +02:00
sora 02b92308d0 Migrate JS from SVG to BMAP 2026-09-06 19:40:50 +02:00
bot a9c8a89aac Merge pull request 'Update dependency mapshaper to v0.7.59' (#605) from renovate/mapshaper-0.x-lockfile into main 2026-09-06 04:01:33 +02:00
Renovate Bot a69d5644a5 Update dependency mapshaper to v0.7.59 2026-09-06 02:01:23 +00:00
bot eab348d187 Merge pull request 'Lock file maintenance' (#604) from renovate/lock-file-maintenance into main 2026-09-05 04:01:51 +02:00
Renovate Bot 1877455c70 Lock file maintenance 2026-09-05 02:01:49 +00:00
bot 0b6074aaba Merge pull request 'Update dependency mapshaper to v0.7.58' (#603) from renovate/mapshaper-0.x-lockfile into main 2026-09-05 04:01:30 +02:00
Renovate Bot 875d809189 Update dependency mapshaper to v0.7.58 2026-09-05 02:01:21 +00:00
bot 3960f5cb5d Merge pull request 'Update plugin com.android.application to v9.4.0' (#601) from renovate/com.android.application-9.x into main 2026-09-03 04:01:08 +02:00
Renovate Bot 07368a408c Update plugin com.android.application to v9.4.0 2026-09-03 02:01:05 +00:00
sora 844da3ac93 Update gradle/wrapper/gradle-wrapper.properties 2026-09-03 02:13:24 +02:00
bot af27e53150 Merge pull request 'Update plugin com.android.library to v9.4.0' (#602) from renovate/com.android.library-9.x into main 2026-09-02 04:02:55 +02:00
Renovate Bot 9bced78550 Update plugin com.android.library to v9.4.0 2026-09-02 02:02:24 +00:00
bot 0129758f74 Merge pull request 'Update dependency mapshaper to v0.7.56' (#600) from renovate/mapshaper-0.x-lockfile into main 2026-08-31 04:01:39 +02:00
Renovate Bot 97657eeb0d Update dependency mapshaper to v0.7.56 2026-08-31 02:01:23 +00:00
bot 99db62cada Merge pull request 'Lock file maintenance' (#599) from renovate/lock-file-maintenance into main 2026-08-30 04:02:42 +02:00
Renovate Bot 606ffe2aff Lock file maintenance 2026-08-30 02:02:36 +00:00
bot d3cf4d69d6 Merge pull request 'Update plugin com.mikepenz.aboutlibraries.plugin to v15.2.0' (#598) from renovate/com.mikepenz.aboutlibraries.plugin-15.x into main 2026-08-30 04:01:59 +02:00
Renovate Bot 1e4e24eef8 Update plugin com.mikepenz.aboutlibraries.plugin to v15.2.0 2026-08-30 02:01:53 +00:00
bot 3036ffcc76 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-core to v15.2.0' (#597) from renovate/com.mikepenz-aboutlibraries-core-15.x into main 2026-08-29 04:01:26 +02:00
Renovate Bot 1a98af2844 Update dependency com.mikepenz:aboutlibraries-core to v15.2.0 2026-08-29 02:01:24 +00:00
bot 7ecd655a53 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.2.0' (#596) from renovate/com.mikepenz-aboutlibraries-compose-m3-15.x into main 2026-08-29 04:01:15 +02:00
Renovate Bot d7f8939750 Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.2.0 2026-08-29 02:01:08 +00:00
bot e3be95449e Merge pull request 'Update dependency androidx.navigation:navigation-compose to v2.10.0' (#595) from renovate/androidx.navigation-navigation-compose-2.x into main 2026-08-27 04:01:11 +02:00
Renovate Bot 61a8ee5439 Update dependency androidx.navigation:navigation-compose to v2.10.0 2026-08-27 02:01:07 +00:00
bot f0da771089 Merge pull request 'Update plugin com.android.library to v9.3.2' (#593) from renovate/com.android.library-9.x into main 2026-08-26 04:01:38 +02:00
bot d39a750597 Merge pull request 'Update actions/setup-java action to v6' (#594) from renovate/actions-setup-java-6.x into main 2026-08-26 04:01:37 +02:00
Renovate Bot e32715554c Update actions/setup-java action to v6 2026-08-26 02:01:34 +00:00
Renovate Bot 731f84bf58 Update plugin com.android.library to v9.3.2 2026-08-26 02:01:32 +00:00
bot 1c3d2b62c6 Merge pull request 'Update plugin com.android.application to v9.3.2' (#592) from renovate/com.android.application-9.x into main 2026-08-25 04:01:12 +02:00
Renovate Bot 66a8b9a1a9 Update plugin com.android.application to v9.3.2 2026-08-25 02:01:09 +00:00
bot c165eacdeb Merge pull request 'Update plugin com.mikepenz.aboutlibraries.plugin to v15.1.1' (#591) from renovate/com.mikepenz.aboutlibraries.plugin-15.x into main 2026-08-23 04:01:27 +02:00
Renovate Bot 36cfb7dc05 Update plugin com.mikepenz.aboutlibraries.plugin to v15.1.1 2026-08-23 02:01:25 +00:00
bot 3ad9dfe8f4 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-core to v15.1.1' (#590) from renovate/com.mikepenz-aboutlibraries-core-15.x into main 2026-08-23 04:01:16 +02:00
Renovate Bot 82065515e6 Update dependency com.mikepenz:aboutlibraries-core to v15.1.1 2026-08-23 02:01:08 +00:00
bot 74b278e972 Merge pull request 'Update dependency mapshaper to v0.7.55' (#589) from renovate/mapshaper-0.x-lockfile into main 2026-08-22 04:01:59 +02:00
Renovate Bot f9ac6c35e5 Update dependency mapshaper to v0.7.55 2026-08-22 02:01:51 +00:00
bot 975c60df63 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.1.1' (#588) from renovate/com.mikepenz-aboutlibraries-compose-m3-15.x into main 2026-08-22 04:01:26 +02:00
Renovate Bot 8fed759a8a Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.1.1 2026-08-22 02:01:19 +00:00
bot ca4f4b0833 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.1.0' (#587) from renovate/com.mikepenz-aboutlibraries-compose-m3-15.x into main 2026-08-21 04:01:40 +02:00
bot 68f9501e3b Merge pull request 'Update dependency mapshaper to v0.7.54' (#586) from renovate/mapshaper-0.x-lockfile into main 2026-08-21 04:01:34 +02:00
Renovate Bot 76efae59cc Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.1.0 2026-08-21 02:01:32 +00:00
Renovate Bot 48c7516a66 Update dependency mapshaper to v0.7.54 2026-08-21 02:01:23 +00:00
bot cee26154b8 Merge pull request 'Update dependency gradle to v9.7.1' (#585) from renovate/gradle-9.x into main 2026-08-20 04:01:19 +02:00
Renovate Bot 48eba6c069 Update dependency gradle to v9.7.1 2026-08-20 02:01:07 +00:00
bot ab599e7189 Merge pull request 'Lock file maintenance' (#584) from renovate/lock-file-maintenance into main 2026-08-16 04:01:47 +02:00
Renovate Bot 8b498bdc99 Lock file maintenance 2026-08-16 02:01:45 +00:00
bot 52b589959f Merge pull request 'Update dependency mapshaper to v0.7.53' (#583) from renovate/mapshaper-0.x-lockfile into main 2026-08-16 04:01:27 +02:00
Renovate Bot 7ce8a93c9f Update dependency mapshaper to v0.7.53 2026-08-16 02:01:18 +00:00
bot 49a9bad969 Merge pull request 'Lock file maintenance' (#582) from renovate/lock-file-maintenance into main 2026-08-15 04:01:55 +02:00
Renovate Bot 92c41a264d Lock file maintenance 2026-08-15 02:01:51 +00:00
bot 9bdc5c9bdc Merge pull request 'Update dependency androidx.compose.ui:ui-tooling to v1.12.0' (#581) from renovate/androidx.compose.ui-ui-tooling-1.x into main 2026-08-14 04:01:22 +02:00
Renovate Bot f2047a62f4 Update dependency androidx.compose.ui:ui-tooling to v1.12.0 2026-08-14 02:01:17 +00:00
bot af8af0db31 Merge pull request 'Update dependency androidx.compose.material:material to v1.12.0' (#580) from renovate/androidx.compose.material-material-1.x into main 2026-08-13 04:01:14 +02:00
bot 3d312b7f6d Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.08.00' (#579) from renovate/androidx.compose-compose-bom-2026.x into main 2026-08-13 04:01:11 +02:00
Renovate Bot 788686fd59 Update dependency androidx.compose.material:material to v1.12.0 2026-08-13 02:01:10 +00:00
Renovate Bot e1368b9e16 Update dependency androidx.compose:compose-bom to v2026.08.00 2026-08-13 02:01:08 +00:00
bot 3900e74953 Merge pull request 'Update dependency mapshaper to v0.7.52' (#578) from renovate/mapshaper-0.x-lockfile into main 2026-08-12 04:02:10 +02:00
Renovate Bot 42f9b66ed0 Update dependency mapshaper to v0.7.52 2026-08-12 02:01:59 +00:00
bot fb162ff48f Merge pull request 'Lock file maintenance' (#577) from renovate/lock-file-maintenance into main 2026-08-09 04:01:24 +02:00
Renovate Bot 7c8b2f0f7d Lock file maintenance 2026-08-09 02:01:22 +00:00
bot c8f5e4d8e1 Merge pull request 'Lock file maintenance' (#576) from renovate/lock-file-maintenance into main 2026-08-08 04:01:57 +02:00
Renovate Bot a227f86d36 Lock file maintenance 2026-08-08 02:01:55 +00:00
bot e3c84a795c Merge pull request 'Update dependency @turf/turf to v7.4.0' (#573) from renovate/turf-turf-7.x-lockfile into main 2026-08-08 04:01:39 +02:00
Renovate Bot 32b62b5a22 Update dependency @turf/turf to v7.4.0 2026-08-08 02:01:34 +00:00
bot 6f747d9f85 Merge pull request 'Update dependency gradle to v9.7.0' (#575) from renovate/gradle-9.x into main 2026-08-07 04:01:37 +02:00
Renovate Bot 7de915832d Update dependency gradle to v9.7.0 2026-08-07 02:01:26 +00:00
bot 725a7d6282 Merge pull request 'Update dependency mapshaper to v0.7.51' (#574) from renovate/mapshaper-0.x-lockfile into main 2026-08-05 04:01:29 +02:00
Renovate Bot 65c0ed5d7a Update dependency mapshaper to v0.7.51 2026-08-05 02:01:18 +00:00
bot ed16b7dd21 Merge pull request 'Update dependency @turf/area to v7.4.0' (#572) from renovate/turf-area-7.x-lockfile into main 2026-08-04 04:01:20 +02:00
Renovate Bot 2e425ad97f Update dependency @turf/area to v7.4.0 2026-08-04 02:01:13 +00:00
bot 1399b98a73 Merge pull request 'Update dependency mapshaper to v0.7.50' (#571) from renovate/mapshaper-0.x-lockfile into main 2026-08-03 04:02:11 +02:00
Renovate Bot f404c70998 Update dependency mapshaper to v0.7.50 2026-08-03 02:01:54 +00:00
bot 91bbfc2db8 Merge pull request 'Lock file maintenance' (#570) from renovate/lock-file-maintenance into main 2026-08-01 04:13:49 +02:00
Renovate Bot 59d51fc35f Lock file maintenance 2026-08-01 02:03:24 +00:00
bot fd5e163055 Merge pull request 'Update dependency jsdom to v30.0.1' (#569) from renovate/jsdom-30.x-lockfile into main 2026-07-30 04:01:04 +02:00
Renovate Bot 5820e164b0 Update dependency jsdom to v30.0.1 2026-07-30 02:00:57 +00:00
bot 3a4f8b58ff Merge pull request 'Update dependency mapshaper to v0.7.49' (#568) from renovate/mapshaper-0.x-lockfile into main 2026-07-29 04:01:14 +02:00
Renovate Bot aa83c53db7 Update dependency mapshaper to v0.7.49 2026-07-29 02:01:04 +00:00
bot ceced8bfe5 Merge pull request 'Update dependency jsdom to v30' (#567) from renovate/jsdom-30.x into main 2026-07-28 04:01:03 +02:00
Renovate Bot b23f9f774c Update dependency jsdom to v30 2026-07-28 02:00:54 +00:00
bot 572bdaf9a7 Merge pull request 'Lock file maintenance' (#566) from renovate/lock-file-maintenance into main 2026-07-26 04:00:55 +02:00
Renovate Bot a481d20f8e Lock file maintenance 2026-07-26 02:00:53 +00:00
bot 74812d5528 Merge pull request 'Update plugin com.android.library to v9.3.1' (#565) from renovate/com.android.library-9.x into main 2026-07-25 04:01:09 +02:00
Renovate Bot 3e3c028fb8 Update plugin com.android.library to v9.3.1 2026-07-25 02:01:07 +00:00
bot 8373aec5a4 Merge pull request 'Update dependency mapshaper to v0.7.48' (#564) from renovate/mapshaper-0.x-lockfile into main 2026-07-25 04:00:57 +02:00
Renovate Bot 618e9693ae Update dependency mapshaper to v0.7.48 2026-07-25 02:00:48 +00:00
bot 3b2b18713f Merge pull request 'Update plugin com.android.application to v9.3.1' (#563) from renovate/com.android.application-9.x into main 2026-07-24 04:01:50 +02:00
bot 66d035546d Merge pull request 'Update dependency mapshaper to v0.7.47' (#562) from renovate/mapshaper-0.x-lockfile into main 2026-07-24 04:01:47 +02:00
Renovate Bot d725eeea7f Update plugin com.android.application to v9.3.1 2026-07-24 02:01:45 +00:00
Renovate Bot 0ceaa500e5 Update dependency mapshaper to v0.7.47 2026-07-24 02:01:28 +00:00
bot 6fc3b37076 Merge pull request 'Update dependency mapshaper to v0.7.46' (#561) from renovate/mapshaper-0.x-lockfile into main 2026-07-20 04:01:19 +02:00
Renovate Bot 90e7805363 Update dependency mapshaper to v0.7.46 2026-07-20 02:01:09 +00:00
bot de9b372543 Merge pull request 'Lock file maintenance' (#560) from renovate/lock-file-maintenance into main 2026-07-19 04:01:11 +02:00
Renovate Bot 0fa80e015b Lock file maintenance 2026-07-19 02:01:07 +00:00
bot a1e55680e6 Merge pull request 'Update plugin com.android.library to v9.3.0' (#559) from renovate/com.android.library-9.x into main 2026-07-18 04:01:06 +02:00
bot 2ea4481422 Merge pull request 'Update plugin com.mikepenz.aboutlibraries.plugin to v15.0.4' (#558) from renovate/com.mikepenz.aboutlibraries.plugin-15.x into main 2026-07-18 04:01:04 +02:00
Renovate Bot d0226d9bc8 Update plugin com.android.library to v9.3.0 2026-07-18 02:01:04 +00:00
bot 3ca6f59e0b Merge pull request 'Update dependency com.mikepenz:aboutlibraries-core to v15.0.4' (#557) from renovate/com.mikepenz-aboutlibraries-core-15.x into main 2026-07-18 04:01:03 +02:00
Renovate Bot 21841da60d Update plugin com.mikepenz.aboutlibraries.plugin to v15.0.4 2026-07-18 02:01:02 +00:00
Renovate Bot c78df673a8 Update dependency com.mikepenz:aboutlibraries-core to v15.0.4 2026-07-18 02:00:55 +00:00
bot 7bec552612 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.0.4' (#556) from renovate/com.mikepenz-aboutlibraries-compose-m3-15.x into main 2026-07-17 04:01:02 +02:00
Renovate Bot a2d2a8edd3 Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.0.4 2026-07-17 02:00:53 +00:00
bot 0876ae5955 Merge pull request 'Update plugin com.android.application to v9.3.0' (#555) from renovate/com.android.application-9.x into main 2026-07-16 04:01:01 +02:00
bot ab9e8f5063 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.10' (#554) from renovate/org.jetbrains.kotlin.plugin.serialization-2.x into main 2026-07-16 04:00:58 +02:00
Renovate Bot 864fe26c95 Update plugin com.android.application to v9.3.0 2026-07-16 02:00:58 +00:00
Renovate Bot 79ebc617a4 Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.10 2026-07-16 02:00:54 +00:00
bot ff84cce5ec Merge pull request 'Update plugin org.jetbrains.kotlin.android to v2.4.10' (#552) from renovate/org.jetbrains.kotlin.android-2.x into main 2026-07-15 04:01:13 +02:00
bot 953ce75b26 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.10' (#553) from renovate/org.jetbrains.kotlin.plugin.compose-2.x into main 2026-07-15 04:01:07 +02:00
Renovate Bot 7eb68a1436 Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.10 2026-07-15 02:01:05 +00:00
Renovate Bot ed6b4f99ee Update plugin org.jetbrains.kotlin.android to v2.4.10 2026-07-15 02:01:03 +00:00
bot c29590c51e Merge pull request 'Update dependency mapshaper to v0.7.45' (#551) from renovate/mapshaper-0.x-lockfile into main 2026-07-13 04:01:21 +02:00
Renovate Bot 544ffa82fd Update dependency mapshaper to v0.7.45 2026-07-13 02:01:09 +00:00
bot 133f0992f9 Merge pull request 'Lock file maintenance' (#550) from renovate/lock-file-maintenance into main 2026-07-11 04:01:54 +02:00
Renovate Bot deb2fd536f Lock file maintenance 2026-07-11 02:01:52 +00:00
bot 46586dc2e4 Merge pull request 'Update dependency mapshaper to v0.7.44' (#549) from renovate/mapshaper-0.x-lockfile into main 2026-07-11 04:01:32 +02:00
Renovate Bot b255ef907a Update dependency mapshaper to v0.7.44 2026-07-11 02:01:21 +00:00
soraefir cf9b1cc6e2 Better cascading stats 2026-07-09 20:52:27 +02:00
soraefir d78ef0b38b Update version name 2026-07-09 20:22:50 +02:00
soraefir 30eb5657ce Fix projection selection 2026-07-09 20:22:49 +02:00
soraefir 4085149e88 Improve settings and regional stats 2026-07-09 20:22:49 +02:00
bot 1788d734d2 Merge pull request 'Update dependency mapshaper to v0.7.41' (#548) from renovate/mapshaper-0.x-lockfile into main 2026-07-09 04:01:18 +02:00
Renovate Bot b53215e4ba Update dependency mapshaper to v0.7.41 2026-07-09 02:01:09 +00:00
bot df19f9640f Merge pull request 'Update dependency mapshaper to v0.7.40' (#547) from renovate/mapshaper-0.x-lockfile into main 2026-07-08 04:01:15 +02:00
Renovate Bot 31aa9630d0 Update dependency mapshaper to v0.7.40 2026-07-08 02:01:04 +00:00
bot b1019aeb0b Merge pull request 'Update dependency mapshaper to v0.7.39' (#546) from renovate/mapshaper-0.x-lockfile into main 2026-07-07 04:01:17 +02:00
Renovate Bot 63aa14e31a Update dependency mapshaper to v0.7.39 2026-07-07 02:01:05 +00:00
soraefir 2d87507fa4 Merge branch '9' 2026-07-06 21:52:27 +02:00
Ady bb8dc15554 fix app icon adapted to the user theme colors 2026-07-06 13:51:41 -04:00
Ady b3369f6390 fix StatsActivity title 2026-07-06 13:50:08 -04:00
bot 46df3b5dbe Merge pull request 'Update dependency mapshaper to v0.7.38' (#545) from renovate/mapshaper-0.x-lockfile into main 2026-07-06 04:01:48 +02:00
Renovate Bot 3ab93c7342 Update dependency mapshaper to v0.7.38 2026-07-06 02:01:35 +00:00
sora a8c4529ee8 Update app/build.gradle 2026-07-05 15:04:10 +02:00
bot e7a3f09547 Merge pull request 'Update dependency mapshaper to v0.7.37' (#544) from renovate/mapshaper-0.x-lockfile into main 2026-07-05 04:01:31 +02:00
Renovate Bot 579cf75ca2 Update dependency mapshaper to v0.7.37 2026-07-05 02:01:19 +00:00
bot 121b34c034 Merge pull request 'Lock file maintenance' (#543) from renovate/lock-file-maintenance into main 2026-07-04 04:01:37 +02:00
Renovate Bot 97014e9454 Lock file maintenance 2026-07-04 02:01:35 +00:00
bot 674106923f Merge pull request 'Update dependency mapshaper to v0.7.35' (#542) from renovate/mapshaper-0.x-lockfile into main 2026-07-03 04:01:22 +02:00
Renovate Bot e21dad8ed7 Update dependency mapshaper to v0.7.35 2026-07-03 02:01:13 +00:00
bot b2cca2332b Merge pull request 'Update dependency androidx.compose.ui:ui-tooling to v1.11.4' (#541) from renovate/androidx.compose.ui-ui-tooling-1.x into main 2026-07-03 04:01:00 +02:00
Renovate Bot 13ad82df95 Update dependency androidx.compose.ui:ui-tooling to v1.11.4 2026-07-03 02:00:56 +00:00
bot 2befea2325 Merge pull request 'Update dependency androidx.compose.material:material to v1.11.4' (#540) from renovate/androidx.compose.material-material-1.x into main 2026-07-02 04:00:59 +02:00
bot f45cc57049 Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.06.01' (#539) from renovate/androidx.compose-compose-bom-2026.x into main 2026-07-02 04:00:57 +02:00
Renovate Bot a7f05d5d80 Update dependency androidx.compose.material:material to v1.11.4 2026-07-02 02:00:56 +00:00
Renovate Bot 269e844a4c Update dependency androidx.compose:compose-bom to v2026.06.01 2026-07-02 02:00:54 +00:00
bot 818fc49e3d Merge pull request 'Update plugin com.mikepenz.aboutlibraries.plugin to v15.0.3' (#538) from renovate/com.mikepenz.aboutlibraries.plugin-15.x into main 2026-07-01 04:01:06 +02:00
bot 8a3f7f7621 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-core to v15.0.3' (#537) from renovate/com.mikepenz-aboutlibraries-core-15.x into main 2026-07-01 04:01:01 +02:00
Renovate Bot 8c50670a9d Update plugin com.mikepenz.aboutlibraries.plugin to v15.0.3 2026-07-01 02:01:00 +00:00
Renovate Bot 141ea67d2e Update dependency com.mikepenz:aboutlibraries-core to v15.0.3 2026-07-01 02:00:54 +00:00
soraefir 291fae7aec Fix hash 2026-06-30 07:48:24 +02:00
bot 7a04440eca Merge pull request 'Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.0.3' (#536) from renovate/com.mikepenz-aboutlibraries-compose-m3-15.x into main 2026-06-30 04:01:17 +02:00
Renovate Bot 691d2e4ea1 Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.0.3 2026-06-30 02:01:09 +00:00
bot dd5c9387f9 Merge pull request 'Lock file maintenance' (#535) from renovate/lock-file-maintenance into main 2026-06-28 04:01:16 +02:00
Renovate Bot 6038d132ec Lock file maintenance 2026-06-28 02:01:11 +00:00
bot 6a0ac3e5c7 Merge pull request 'Update plugin com.mikepenz.aboutlibraries.plugin to v15' (#534) from renovate/com.mikepenz.aboutlibraries.plugin-15.x into main 2026-06-27 04:01:17 +02:00
Renovate Bot 327a6550b4 Update plugin com.mikepenz.aboutlibraries.plugin to v15 2026-06-27 02:01:15 +00:00
bot 7027d0ffb1 Merge pull request 'Update dependency gradle to v9.6.1' (#533) from renovate/gradle-9.x into main 2026-06-27 04:01:07 +02:00
Renovate Bot 15b33b59ed Update dependency gradle to v9.6.1 2026-06-27 02:00:56 +00:00
bot 36787717ff Merge pull request 'Update dependency com.mikepenz:aboutlibraries-core to v15.0.2' (#532) from renovate/com.mikepenz-aboutlibraries-core-15.x into main 2026-06-27 04:00:54 +02:00
Renovate Bot c27aef23c0 Update dependency com.mikepenz:aboutlibraries-core to v15.0.2 2026-06-27 02:00:49 +00:00
bot a9e567d299 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.0.2' (#531) from renovate/com.mikepenz-aboutlibraries-compose-m3-15.x into main 2026-06-26 04:00:52 +02:00
Renovate Bot 468fb4ab13 Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15.0.2 2026-06-26 02:00:43 +00:00
bot e952de3c17 Merge pull request 'Update dependency mapshaper to v0.7.33' (#530) from renovate/mapshaper-0.x-lockfile into main 2026-06-24 04:01:11 +02:00
Renovate Bot da1793bc21 Update dependency mapshaper to v0.7.33 2026-06-24 02:01:02 +00:00
bot 650a59b1ac Merge pull request 'Update dependency mapshaper to v0.7.32' (#529) from renovate/mapshaper-0.x-lockfile into main 2026-06-23 04:01:22 +02:00
Renovate Bot 5a9284749c Update dependency mapshaper to v0.7.32 2026-06-23 02:01:11 +00:00
bot 981fc5ebf4 Merge pull request 'Update dependency com.mikepenz:aboutlibraries-core to v15' (#528) from renovate/com.mikepenz-aboutlibraries-core-15.x into main 2026-06-22 04:01:27 +02:00
Renovate Bot 78f5fe7c35 Update dependency com.mikepenz:aboutlibraries-core to v15 2026-06-22 02:01:18 +00:00
bot bdf2911a1c Merge pull request 'Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15' (#527) from renovate/com.mikepenz-aboutlibraries-compose-m3-15.x into main 2026-06-21 04:01:41 +02:00
Renovate Bot 40b2040f25 Update dependency com.mikepenz:aboutlibraries-compose-m3 to v15 2026-06-21 02:01:35 +00:00
bot 48c88d85a4 Merge pull request 'Update actions/checkout action to v7' (#526) from renovate/actions-checkout-7.x into main 2026-06-21 04:01:27 +02:00
Renovate Bot 727c828128 Update actions/checkout action to v7 2026-06-21 02:01:23 +00:00
bot cce6719158 Merge pull request 'Update dependency gradle to v9.6.0' (#525) from renovate/gradle-9.x into main 2026-06-20 04:02:45 +02:00
Renovate Bot 13a79baa2b Update dependency gradle to v9.6.0 2026-06-20 02:02:30 +00:00
bot c956fb9876 Merge pull request 'Update dependency mapshaper to v0.7.29' (#524) from renovate/mapshaper-0.x-lockfile into main 2026-06-20 04:01:49 +02:00
Renovate Bot 339ac468f3 Update dependency mapshaper to v0.7.29 2026-06-20 02:01:40 +00:00
bot f1eacee394 Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.06.00' (#523) from renovate/androidx.compose-compose-bom-2026.x into main 2026-06-19 04:02:06 +02:00
bot a000fb4a3d Merge pull request 'Update dependency mapshaper to v0.7.28' (#522) from renovate/mapshaper-0.x-lockfile into main 2026-06-19 04:02:03 +02:00
Renovate Bot 16ff2e1bc8 Update dependency androidx.compose:compose-bom to v2026.06.00 2026-06-19 02:02:03 +00:00
Renovate Bot 6ee16f02ab Update dependency mapshaper to v0.7.28 2026-06-19 02:01:54 +00:00
bot fd05d9b6f5 Merge pull request 'Update dependency androidx.compose.ui:ui-tooling to v1.11.3' (#521) from renovate/androidx.compose.ui-ui-tooling-1.x into main 2026-06-18 04:01:30 +02:00
bot eece41d0ba Merge pull request 'Update dependency androidx.compose.material:material to v1.11.3' (#520) from renovate/androidx.compose.material-material-1.x into main 2026-06-18 04:01:29 +02:00
Renovate Bot b609704cc1 Update dependency androidx.compose.ui:ui-tooling to v1.11.3 2026-06-18 02:01:27 +00:00
Renovate Bot 39436f473a Update dependency androidx.compose.material:material to v1.11.3 2026-06-18 02:01:23 +00:00
bot ce2721025a Merge pull request 'Update dependency mapshaper to v0.7.26' (#519) from renovate/mapshaper-0.x-lockfile into main 2026-06-16 04:02:04 +02:00
Renovate Bot 371a2aa35f Update dependency mapshaper to v0.7.26 2026-06-16 02:01:52 +00:00
bot 0f986616ae Merge pull request 'Update dependency mapshaper to v0.7.24' (#518) from renovate/mapshaper-0.x-lockfile into main 2026-06-15 04:01:52 +02:00
Renovate Bot eb948c2e59 Update dependency mapshaper to v0.7.24 2026-06-15 02:01:40 +00:00
bot 4fe43c5fc6 Merge pull request 'Lock file maintenance' (#517) from renovate/lock-file-maintenance into main 2026-06-14 04:02:19 +02:00
Renovate Bot eed23b35d4 Lock file maintenance 2026-06-14 02:02:17 +00:00
bot e9d46d0824 Merge pull request 'Update dependency mapshaper to v0.7.23' (#516) from renovate/mapshaper-0.x-lockfile into main 2026-06-14 04:01:58 +02:00
Renovate Bot ccd18f3717 Update dependency mapshaper to v0.7.23 2026-06-14 02:01:46 +00:00
bot 1e78f269c1 Merge pull request 'Lock file maintenance' (#515) from renovate/lock-file-maintenance into main 2026-06-13 04:02:05 +02:00
Renovate Bot 0740c00014 Lock file maintenance 2026-06-13 02:02:01 +00:00
bot 740526104b Merge pull request 'Lock file maintenance' (#514) from renovate/lock-file-maintenance into main 2026-06-07 04:04:22 +02:00
Renovate Bot f807fa62d2 Lock file maintenance 2026-06-07 02:04:13 +00:00
44 changed files with 5126 additions and 5504 deletions
+20 -3
View File
@@ -23,7 +23,7 @@ jobs:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -42,7 +42,7 @@ jobs:
run: git checkout -B "$BRANCH" run: git checkout -B "$BRANCH"
- name: set up JDK - name: set up JDK
uses: actions/setup-java@v5 uses: actions/setup-java@v6
with: with:
java-version: 21 java-version: 21
distribution: "temurin" distribution: "temurin"
@@ -50,10 +50,27 @@ jobs:
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6 uses: gradle/actions/setup-gradle@v6
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Build APK - name: Build APK
run: | run: |
VERSION_CODE=$(git rev-list --count HEAD) VERSION_CODE=$(git rev-list --count HEAD)
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE VERSION_BASE=$(git describe --tags --abbrev=0 | sed 's/^v//')
VERSION_DEV=$(git rev-list --count $(git describe --tags --abbrev=0)..HEAD)
if [ $VERSION_DEV -gt 0 ]; then
VERSION_NAME="${VERSION_BASE}.${VERSION_DEV}"
else
VERSION_NAME="${VERSION_BASE}"
fi
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
- name: Upload APK
uses: actions/upload-artifact@v7
with:
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
compression-level: 0
archive: false
- name: Release - name: Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
+2
View File
@@ -23,3 +23,5 @@ captures/
local.properties local.properties
keystore.properties keystore.properties
key.jks key.jks
# generated map intermediates; the packed .bmap in assets is what ships
mapdata/*.geojson
+13 -23
View File
@@ -1,8 +1,7 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0' id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.10'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0' id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10'
id 'com.mikepenz.aboutlibraries.plugin' version '14.2.1'
} }
android { android {
@@ -15,7 +14,7 @@ android {
applicationId 'net.helcel.beans' applicationId 'net.helcel.beans'
minSdk = 28 minSdk = 28
targetSdk = 37 targetSdk = 37
versionName "1.3b" versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.5"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
} }
signingConfigs { signingConfigs {
@@ -74,44 +73,35 @@ android {
kotlinCompilerExtensionVersion = "2.2.20" kotlinCompilerExtensionVersion = "2.2.20"
} }
androidResources {
// The app ships English strings only; without this the libraries drag
// in some eighty locales of their own.
localeFilters += ["en"]
}
lint { lint {
disable 'UsingMaterialAndMaterial3Libraries' disable 'UsingMaterialAndMaterial3Libraries'
} }
} }
aboutLibraries {
library {
exclusionPatterns = [~"androidx.*", ~"com.google.android.*", ~"org.jetbrains.*"]
}
}
dependencies { dependencies {
implementation 'androidx.compose.material3:material3:1.4.0' implementation 'androidx.compose.material3:material3:1.4.0'
implementation "androidx.compose.material:material:1.11.2" implementation "androidx.compose.material:material:1.12.0"
implementation 'androidx.compose.material:material-icons-extended:1.7.8' implementation 'androidx.compose.material:material-icons-extended:1.7.8'
implementation 'androidx.navigation:navigation-compose:2.9.8' implementation 'androidx.navigation:navigation-compose:2.10.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui'
implementation "androidx.activity:activity-ktx:1.13.0" implementation "androidx.activity:activity-ktx:1.13.0"
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'com.google.android.material:material:1.14.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
implementation 'com.mikepenz:aboutlibraries:14.2.1'
implementation 'com.mikepenz:aboutlibraries-compose-m3:14.2.1'
implementation 'com.mikepenz:aboutlibraries-core:14.2.1'
implementation platform('androidx.compose:compose-bom:2026.05.01') implementation platform('androidx.compose:compose-bom:2026.08.00')
debugImplementation 'androidx.compose.ui:ui-tooling:1.11.2' debugImplementation 'androidx.compose.ui:ui-tooling:1.12.0'
} }
tasks.configureEach { task -> tasks.configureEach { task ->
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 4.0 MiB

@@ -1,20 +1,20 @@
package net.helcel.beans.activity package net.helcel.beans.activity
import android.graphics.drawable.PictureDrawable
import android.os.Bundle import android.os.Bundle
import android.widget.ImageView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TopAppBar import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -22,29 +22,42 @@ import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Percent import androidx.compose.material.icons.filled.Percent
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.caverock.androidsvg.RenderOptions
import com.github.chrisbanes.photoview.PhotoView
import net.helcel.beans.BuildConfig import net.helcel.beans.BuildConfig
import net.helcel.beans.activity.sub.EditPlaceDialog
import net.helcel.beans.activity.sub.MapPickDialog
import net.helcel.beans.activity.sub.applyDirectVisit
import net.helcel.beans.activity.sub.commitVisitDialog
import net.helcel.beans.countries.GeoLoc
import net.helcel.beans.countries.GeoLocImporter import net.helcel.beans.countries.GeoLocImporter
import net.helcel.beans.countries.GeoLocTree
import net.helcel.beans.helper.Data import net.helcel.beans.helper.Data
import net.helcel.beans.helper.Settings import net.helcel.beans.helper.Settings
import net.helcel.beans.svg.CSSWrapper import net.helcel.beans.map.MapAssets
import net.helcel.beans.svg.SVGWrapper import net.helcel.beans.map.MapStyle
import net.helcel.beans.map.MapView
import net.helcel.beans.map.MapWorld
import net.helcel.beans.map.MapReader
class MainScreen : ComponentActivity() { class MainScreen : ComponentActivity() {
private lateinit var psvg: SVGWrapper private var world by mutableStateOf<MapWorld?>(null)
private lateinit var css: CSSWrapper private var loadToken = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -53,29 +66,38 @@ class MainScreen : ComponentActivity() {
Data.loadData(this, Int.MIN_VALUE) Data.loadData(this, Int.MIN_VALUE)
GeoLocImporter.importStates(this) GeoLocImporter.importStates(this)
refreshProjection()
setContent { setContent {
SysTheme { SysTheme {
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.primary).statusBarsPadding(),) { // Both bars: without the navigation inset the system buttons sit on top of
AppNavHost(psvg, css) // whatever is at the bottom of a screen, such as the About button.
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.primary)
.statusBarsPadding()
.navigationBarsPadding(),
) {
AppNavHost()
} }
} }
} }
refreshProjection()
} }
@Composable @Composable
fun AppNavHost(psvg: SVGWrapper, css: CSSWrapper) { fun AppNavHost() {
val navController = rememberNavController() val navController = rememberNavController()
NavHost(navController, startDestination = "main") { NavHost(navController, startDestination = "main") {
composable("main") { MainScreenC(psvg,css, navController) } composable("main") { MainScreenC(world, navController) }
composable("settings") { SettingsMainScreen { navController.navigate("main")} } composable("settings") { SettingsMainScreen { navController.navigate("main") } }
composable("edit") { EditScreen { navController.navigate("main") } } composable("edit") { EditScreen { navController.navigate("main") } }
composable("stats") { StatsScreen { navController.navigate("main") } } composable("stats") { StatsScreen { navController.navigate("main") } }
} }
} }
@Composable @Composable
fun MainScreenC(psvg: SVGWrapper,css: CSSWrapper, nav: NavHostController){ fun MainScreenC(world: MapWorld?, nav: NavHostController){
SysTheme { SysTheme {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -95,34 +117,87 @@ class MainScreen : ComponentActivity() {
) )
} }
) { innerPadding -> ) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) { Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
MapScreen(psvg, css) if (world == null) LoadingMap() else MapScreen(world)
} }
} }
} }
} }
@Composable @Composable
fun MapScreen(psvg: SVGWrapper, css: CSSWrapper) { fun LoadingMap() {
Box { Box(
val opt: RenderOptions = RenderOptions.create() modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background),
opt.css(css.get()) contentAlignment = Alignment.Center,
val drawable = remember(psvg, css) { ) {
PictureDrawable(psvg.get()?.renderToPicture(opt)) CircularProgressIndicator(
} color = MaterialTheme.colors.primary,
AndroidView(factory = { ctx -> strokeWidth = 4.dp,
PhotoView(ctx).apply { modifier = Modifier.size(50.dp),
setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null) )
setImageDrawable(drawable)
maximumScale = 32f
scaleType = ImageView.ScaleType.FIT_CENTER
}
}, modifier = Modifier.fillMaxSize())
} }
} }
@Composable
fun MapScreen(world: MapWorld) {
val ctx = LocalContext.current
val visits by Data.visits.visitsFlow.collectAsState()
val groups by Data.groups.groupsFlow.collectAsState()
val land = MaterialTheme.colors.onBackground.toArgb()
val background = MaterialTheme.colors.background.toArgb()
val style = remember(visits, groups, land, background) {
MapStyle.build(ctx, land, background)
}
val touchRadius = Settings.getTouchRadius(ctx)
var candidates by remember { mutableStateOf<List<GeoLoc>>(emptyList()) }
var showColor by remember { mutableStateOf(false) }
// A place is either applied straight away, or the colour dialog picks
// the group for it, exactly as it does from the edit list.
fun select(loc: GeoLoc) {
if (!applyDirectVisit(ctx, loc)) showColor = true
}
if (candidates.isNotEmpty()) {
MapPickDialog(
candidates = candidates,
onPick = { candidates = emptyList(); select(it) },
onDismiss = { candidates = emptyList() },
)
}
if (showColor) {
EditPlaceDialog(false) { cleared ->
showColor = false
commitVisitDialog(cleared)
}
}
AndroidView(
factory = { MapView(it) },
update = { view ->
view.world = world
view.style = style
view.touchRadiusDp = touchRadius
// Always the tree, even for a single hit: landing on a region is
// just as often a way of reaching the country around it.
view.onPick = { picks ->
val locs = picks.mapNotNull { GeoLocTree.find(it.code) }
if (locs.isNotEmpty()) candidates = locs
}
},
modifier = Modifier.fillMaxSize()
)
}
/** Reloads the map asset, on a worker thread since it is a few megabytes. */
fun refreshProjection() { fun refreshProjection() {
psvg = SVGWrapper(this) val asset = MapAssets.assetFor(this)
css = CSSWrapper(this) val token = ++loadToken
world = null
Thread {
val parsed = assets.open(asset).use { MapReader.read(it) }
runOnUiThread { if (token == loadToken) world = parsed }
}.start()
} }
} }
@@ -26,6 +26,7 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.Slider
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TopAppBar import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -37,6 +38,7 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -48,10 +50,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.preference.PreferenceManager
import net.helcel.beans.R
import net.helcel.beans.countries.GeoLocImporter
import net.helcel.beans.helper.Settings
import androidx.core.content.edit import androidx.core.content.edit
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -60,18 +58,23 @@ import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.beans.R
import net.helcel.beans.activity.sub.AboutScreen import net.helcel.beans.activity.sub.AboutScreen
import net.helcel.beans.activity.sub.EditPlaceColorDialog import net.helcel.beans.activity.sub.EditPlaceColorDialog
import net.helcel.beans.activity.sub.EditPlaceDialog import net.helcel.beans.countries.GeoLocImporter
import net.helcel.beans.activity.sub.LicenseScreen import net.helcel.beans.helper.DEFAULT_TOUCH_RADIUS
import net.helcel.beans.helper.Data import net.helcel.beans.helper.Data
import net.helcel.beans.helper.defaultPreferences
import net.helcel.beans.helper.MAX_TOUCH_RADIUS
import net.helcel.beans.helper.Settings
import kotlin.math.roundToInt
@Composable @Composable
fun SysTheme( fun SysTheme(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val prefs = PreferenceManager.getDefaultSharedPreferences(context) val prefs = defaultPreferences(context)
val themeKey = prefs.getString(stringResource(R.string.key_theme), stringResource(R.string.system)) val themeKey = prefs.getString(stringResource(R.string.key_theme), stringResource(R.string.system))
val darkTheme = when (themeKey) { val darkTheme = when (themeKey) {
stringResource(R.string.system) -> isSystemInDarkTheme() stringResource(R.string.system) -> isSystemInDarkTheme()
@@ -100,28 +103,22 @@ fun SysTheme(
isLight = !darkTheme, isLight = !darkTheme,
) )
MaterialTheme( // Both, because the app mixes the two: most of it is Material 2, while the
colors = m2colors, // dividers are Material 3 and would otherwise fall back to the default
content = content // light scheme.
) androidx.compose.material3.MaterialTheme(colorScheme = colorScheme) {
} MaterialTheme(
colors = m2colors,
content = content
@Composable )
fun settingsNav(): NavHostController {
val navController = rememberNavController()
NavHost(navController, startDestination= "settings"){
composable("settings"){SettingsScreen(navController)}
composable("licenses"){ LicenseScreen() }
composable("about"){ AboutScreen() }
} }
return navController
} }
@Preview @Preview
@Composable @Composable
fun SettingsMainScreen(onExit: ()->Unit = {}) { fun SettingsMainScreen(onExit: ()->Unit = {}) {
val nav: NavHostController = settingsNav() val nav: NavHostController = rememberNavController()
SysTheme { SysTheme {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -141,169 +138,64 @@ fun SettingsMainScreen(onExit: ()->Unit = {}) {
) )
} }
) { innerPadding -> ) { innerPadding ->
// One host, inside the scaffold. Building it here and again as a
// default argument left two of them: settings drawn twice, and
// only the inner one ever navigating.
Box(modifier = Modifier.padding(innerPadding)) { Box(modifier = Modifier.padding(innerPadding)) {
SettingsScreen() NavHost(nav, startDestination = "settings") {
composable("settings") { SettingsScreen(nav) }
composable("about") { AboutScreen() }
}
} }
} }
} }
} }
@Composable @Composable
fun SettingsScreen(navController: NavHostController = settingsNav()) { fun SettingsScreen(navController: NavHostController) {
val context = LocalContext.current val context = LocalContext.current
val prefs = PreferenceManager.getDefaultSharedPreferences(context) val prefs = defaultPreferences(context)
val keyTheme = stringResource(R.string.key_theme) val keyTheme = stringResource(R.string.key_theme)
val defaultTheme = stringResource(R.string.system) val defaultTheme = stringResource(R.string.system)
val keyProjection = stringResource(R.string.key_projection) val keyProjection = stringResource(R.string.key_projection)
val keyGroup = stringResource(R.string.key_group) val keyGroup = stringResource(R.string.key_group)
val keyRegional = stringResource(R.string.key_regional)
val keyCascadeStats = stringResource(R.string.key_cascade_stats)
val keyTouchRadius = stringResource(R.string.key_touch_radius)
val offString = stringResource(R.string.off) val offString = stringResource(R.string.off)
val onString = stringResource(R.string.on)
var showEdit by remember { mutableStateOf(false) }
var theme by remember { mutableStateOf(prefs.getString(keyTheme, defaultTheme)!!) } var theme by remember { mutableStateOf(prefs.getString(keyTheme, defaultTheme)!!) }
var projection by remember { mutableStateOf(prefs.getString(keyProjection, "default")!!) } var projection by remember { mutableStateOf(prefs.getString(keyProjection, "default")!!) }
var groups by remember { mutableStateOf(prefs.getString(keyGroup,offString)!!) } var groups by remember { mutableStateOf(prefs.getString(keyGroup, offString)!!) }
var regional by remember { mutableStateOf(prefs.getString(keyRegional, offString)!!) }
if(showEdit) var cascadeStats by remember { mutableStateOf(prefs.getString(keyCascadeStats, offString)!!) }
EditPlaceDialog(true) { var touchRadius by remember {
showEdit = false mutableIntStateOf(prefs.getInt(keyTouchRadius, DEFAULT_TOUCH_RADIUS))
val g = Data.selected_group
if (it && g != null) {
Data.visits.reassignAllVisitedToGroup(g.key)
Data.saveData()
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.background(MaterialTheme.colors.background)
) {
item {
Text(
"Theme", style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
)
MultiPreference(arrayOf(stringResource(R.string.system),stringResource(R.string.light),stringResource(R.string.dark)), theme) { newTheme ->
theme = newTheme
prefs.edit { putString(keyTheme, newTheme) }
}
HorizontalDivider()
}
item {
Text(
"Map Projection",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 16.dp)
)
MultiPreference(arrayOf(stringResource(R.string.mercator), stringResource(R.string.azimuthalequidistant)), projection) { newProj ->
projection = newProj
prefs.edit { putString(keyProjection, newProj) }
Settings.refreshProjection()
}
HorizontalDivider()
}
item {
Text(
"Groups",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 16.dp)
)
var showDialog by remember{mutableStateOf(false)}
if(showDialog){
EditPlaceColorDialog(
deleteMode = true,
onDismiss = {
val g = Data.selected_group
if (g != null) {
Data.visits.reassignAllVisitedToGroup(g.key)
Data.saveData()
}
showDialog = false
})
}
MultiPreference(
arrayOf(stringResource(R.string.on), stringResource(R.string.off)),
groups
) { key ->
if (key == offString) {
showDialog=true
}
groups = key
prefs.edit { putString(keyGroup, key) }
}
HorizontalDivider()
}
item {
Text(
text = "Regional",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier
.padding(top = 16.dp)
.clickable(onClick = {}),
)
RegionalScreen()
HorizontalDivider()
}
item{
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri -> Data.doImport(context, uri) }
)
Row(
modifier = Modifier.fillMaxWidth()
) {
Button(onClick = {
launcher.launch(arrayOf("*/*"))
}, modifier = Modifier
.fillMaxWidth(fraction = 0.4f)
.padding(vertical = 8.dp)) {
Text("Import")
}
Spacer(
modifier = Modifier.fillMaxWidth(0.4f)
)
Button(onClick = {
Data.doExport(context)
}, modifier = Modifier
.fillMaxWidth(fraction = 1f)
.padding(vertical = 8.dp)) {
Text("Export")
}
}
HorizontalDivider()
}
item {
PreferenceButton("Licenses") {
if (navController.currentDestination?.route != "licenses")
navController.navigate("licenses")
}
PreferenceButton("About") {
if (navController.currentDestination?.route != "about")
navController.navigate("about")
}
}
}
} }
@Composable var showGroupDialog by remember { mutableStateOf(false) }
fun RegionalScreen() { var showRegionalDialog by remember { mutableStateOf(false) }
val context = LocalContext.current var showLoad by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val keyRegional = stringResource(R.string.key_regional) if (showGroupDialog) {
val offString = stringResource(R.string.off) EditPlaceColorDialog(
val onString = stringResource(R.string.on) deleteMode = true,
val prefs = PreferenceManager.getDefaultSharedPreferences(context) onDismiss = {
var selected by remember { mutableStateOf(prefs.getString(keyRegional, offString)!!)} val g = Data.selected_group
var regional by remember{ mutableStateOf(prefs.getString(keyRegional, offString)!!)} if (g != null) {
var showDialog by remember{mutableStateOf(false)} Data.visits.reassignAllVisitedToGroup(g.key)
var showLoad by remember{mutableStateOf(false)} Data.groups.keepOnly(g.key)
Data.saveData()
groups = offString
prefs.edit { putString(keyGroup, offString) }
}
showGroupDialog = false
})
}
if(showDialog) if (showRegionalDialog) {
Dialog( Dialog(
content = { content = {
Column( Column(
@@ -312,26 +204,27 @@ fun RegionalScreen() {
MaterialTheme.colors.background, MaterialTheme.colors.background,
RoundedCornerShape(corner = CornerSize(16.dp)) RoundedCornerShape(corner = CornerSize(16.dp))
) )
.padding(16.dp),){ .padding(16.dp),
Text(style=MaterialTheme.typography.caption, text= stringResource(R.string.delete_regions)) ) {
Text(
style = MaterialTheme.typography.caption,
text = stringResource(R.string.delete_regions)
)
Button(onClick = { Button(onClick = {
GeoLocImporter.clearStates() GeoLocImporter.clearStates()
regional= selected regional = offString
prefs.edit { prefs.edit { putString(keyRegional, offString) }
putString( showRegionalDialog = false
keyRegional, }) {
regional Text(stringResource(R.string.ok))
)
}
showDialog=false
}){
Text(stringResource(R.string.ok))
} }
} }
}, },
onDismissRequest = { showDialog=false } onDismissRequest = { showRegionalDialog = false }
) )
if(showLoad){ }
if (showLoad) {
Dialog( Dialog(
content = { content = {
CircularProgressIndicator( CircularProgressIndicator(
@@ -343,41 +236,185 @@ fun RegionalScreen() {
onDismissRequest = {} onDismissRequest = {}
) )
} }
val scope = rememberCoroutineScope()
MultiPreference(arrayOf(stringResource(R.string.on),stringResource(R.string.off)),regional) { key -> LazyColumn(
when (key) { modifier = Modifier
offString -> { showDialog=true .fillMaxSize()
selected=key .padding(16.dp)
.background(MaterialTheme.colors.background)
) {
item {
Text(
stringResource(R.string.key_theme),
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
)
MultiPreference(
arrayOf(
stringResource(R.string.system),
stringResource(R.string.light),
stringResource(R.string.dark)
), theme
) { newTheme ->
theme = newTheme
prefs.edit { putString(keyTheme, newTheme) }
}
HorizontalDivider()
}
item {
Text(
stringResource(R.string.key_projection),
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 16.dp)
)
MultiPreference(
arrayOf(
stringResource(R.string.mercator),
stringResource(R.string.azimuthalequidistant),
stringResource(R.string.loximuthal),
),
projection
) { newProj ->
projection = newProj
prefs.edit { putString(keyProjection, newProj) }
Settings.refreshProjection()
}
HorizontalDivider()
}
item {
SettingSwitch(
label = stringResource(R.string.key_group),
subtitle = stringResource(R.string.key_group_desc),
isChecked = groups == onString,
onCheckedChange = { isChecked ->
if (!isChecked) {
Data.selected_group = null
showGroupDialog = true
} else {
groups = onString
prefs.edit { putString(keyGroup, onString) }
} }
onString -> { }
regional = key )
prefs.edit { putString(keyRegional, key) } HorizontalDivider()
showLoad=true }
item {
SettingSwitch(
label = stringResource(R.string.key_regional),
subtitle = stringResource(R.string.key_regional_desc),
isChecked = regional == onString,
onCheckedChange = { isChecked ->
if (isChecked) {
regional = onString
prefs.edit { putString(keyRegional, onString) }
showLoad = true
scope.launch { scope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
GeoLocImporter.importStates(context, true) GeoLocImporter.importStates(context, true)
} }
showLoad = false showLoad = false
} }
} else {
showRegionalDialog = true
} }
} }
)
HorizontalDivider()
}
item {
SettingSwitch(
label = stringResource(R.string.pref_cascade_stats),
subtitle = stringResource(R.string.pref_cascade_stats_desc),
isChecked = cascadeStats == onString,
onCheckedChange = { isChecked ->
cascadeStats = if (isChecked) onString else offString
prefs.edit { putString(keyCascadeStats, cascadeStats) }
}
)
HorizontalDivider()
}
item {
Text(
stringResource(R.string.pref_touch_radius),
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 16.dp)
)
Text(
stringResource(R.string.pref_touch_radius_desc),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Slider(
value = touchRadius.toFloat(),
onValueChange = { touchRadius = it.roundToInt() },
onValueChangeFinished = { prefs.edit { putInt(keyTouchRadius, touchRadius) } },
valueRange = 0f..MAX_TOUCH_RADIUS.toFloat(),
steps = MAX_TOUCH_RADIUS - 1,
modifier = Modifier.weight(1f)
)
Text(
stringResource(R.string.pref_touch_radius_value, touchRadius),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(start = 12.dp)
)
} }
HorizontalDivider()
}
item {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri -> Data.doImport(context, uri) }
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Button(
onClick = {
launcher.launch(arrayOf("*/*"))
}, modifier = Modifier
.weight(1f)
) {
Text(stringResource(R.string.action_import))
}
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
Data.doExport(context)
}, modifier = Modifier
.weight(1f)
) {
Text(stringResource(R.string.action_export))
}
}
HorizontalDivider()
}
item {
PreferenceButton(stringResource(R.string.about)) {
if (navController.currentDestination?.route != "about")
navController.navigate("about")
}
}
}
} }
@Composable @Composable
fun MultiPreference(list: Array<String>, selected: String, onSelected: (String) -> Unit) { fun MultiPreference(list: Array<String>, selected: String, onSelected: (String) -> Unit) {
Column(Modifier.padding(2.dp)) { Column(Modifier.padding(vertical = 2.dp)) {
list.forEach { value -> list.forEach { value ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(36.dp) .height(42.dp)
.clickable { onSelected(value) }) { .clickable { onSelected(value) }) {
RadioButton(selected = selected == value, onClick = { onSelected(value) }) RadioButton(selected = selected == value, onClick = { onSelected(value) })
Text( Text(
value, modifier = Modifier.padding(start = 8.dp), value, modifier = Modifier.padding(start = 12.dp),
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground, color = MaterialTheme.colors.onBackground,
) )
} }
@@ -393,3 +430,38 @@ fun PreferenceButton(text: String, onClick: () -> Unit) {
Text(text) Text(text)
} }
} }
@Composable
fun SettingSwitch(
label: String,
subtitle: String? = null,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!isChecked) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text=label,
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
androidx.compose.material.Switch(
checked = isChecked,
onCheckedChange = onCheckedChange
)
}
}
@@ -20,6 +20,7 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -32,12 +33,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.helcel.beans.R import net.helcel.beans.R
import net.helcel.beans.countries.Country
import net.helcel.beans.countries.GeoLoc
import net.helcel.beans.countries.GeoLoc.LocType import net.helcel.beans.countries.GeoLoc.LocType
import net.helcel.beans.countries.World import net.helcel.beans.countries.World
import net.helcel.beans.helper.AUTO_GROUP import net.helcel.beans.helper.AUTO_GROUP
import net.helcel.beans.helper.Data import net.helcel.beans.helper.Data
import net.helcel.beans.helper.Groups import net.helcel.beans.helper.Groups
import net.helcel.beans.helper.Settings.isRegional import net.helcel.beans.helper.NO_GROUP
import net.helcel.beans.helper.Settings
import net.helcel.beans.helper.Theme.getContrastColor import net.helcel.beans.helper.Theme.getContrastColor
private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE) private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE)
@@ -46,7 +50,7 @@ private val MODE_LIST = listOf(LocType.WORLD, LocType.COUNTRY, LocType.STATE)
fun StatsScreen( fun StatsScreen(
onExit: ()-> Unit onExit: ()-> Unit
) { ) {
val modes = if (isRegional(LocalContext.current)) MODE_LIST else MODE_LIST.take(2) val modes = if (Settings.isRegional(LocalContext.current)) MODE_LIST else MODE_LIST.take(2)
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
var countMode by remember { mutableStateOf(true) } var countMode by remember { mutableStateOf(true) }
@@ -57,7 +61,7 @@ fun StatsScreen(
TopAppBar( TopAppBar(
title = { title = {
Row(verticalAlignment = Alignment.CenterVertically){ Row(verticalAlignment = Alignment.CenterVertically){
Text(text=stringResource(R.string.action_edit), modifier = Modifier.weight(1f)) Text(text=stringResource(R.string.action_stat), modifier = Modifier.weight(1f))
Button(onClick = { countMode = !countMode }) { Button(onClick = { countMode = !countMode }) {
Text(if (countMode) "Count" else "Area") Text(if (countMode) "Count" else "Area")
} }
@@ -115,26 +119,83 @@ fun StatsList(activeMode: LocType, countMode: Boolean) {
@Composable @Composable
fun StatsRow(group: Groups.Group, activeMode: LocType, countMode: Boolean) { fun StatsRow(group: Groups.Group, activeMode: LocType, countMode: Boolean) {
val context = LocalContext.current
val isCascadeStats = remember { Settings.isCascadeStats(context) }
val visits by Data.visits.visitsFlow.collectAsState()
val visited = remember(group, activeMode) { val continents = remember { World.WWW.children.toList() }
Data.visits.getVisitedByValue(group.key) val countries = remember {
World.WWW.children.flatMap {
if (it is Country) listOf(it) else it.children
}
} }
val count = when (activeMode) { val visitedItems = remember(group, activeMode, isCascadeStats, visits) {
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.size val list = when (activeMode) {
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.size LocType.WORLD -> continents
LocType.STATE -> World.WWW.children.flatMap { a->a.children.flatMap { b->b.children.filter { c->c.code in visited } } }.size LocType.COUNTRY -> countries
else -> 0 LocType.STATE -> countries.flatMap { it.children }
else -> emptyList()
}
fun getExplicitColor(l: GeoLoc): Int? {
val c = visits.getOrDefault(l.code, 0)
return if (c != NO_GROUP && c != AUTO_GROUP) c else null
}
fun getEffectiveGroup(l: GeoLoc): Int {
getExplicitColor(l)?.let { return it }
if (isCascadeStats) {
// Parent -> Child (closest explicit ancestor)
val country = countries.find { it.children.contains(l) }
val countryColor = country?.let { getExplicitColor(it) }
if (countryColor != null) return countryColor
val continent = continents.find {
it.children.contains(l) || (country != null && it.children.contains(country))
}
val continentColor = continent?.let { getExplicitColor(it) }
if (continentColor != null) return continentColor
val worldColor = getExplicitColor(World.WWW)
if (worldColor != null) return worldColor
// Child -> Parent (most common explicit descendant)
val descendants = mutableListOf<GeoLoc>()
fun collect(curr: GeoLoc) {
curr.children.forEach {
descendants.add(it)
collect(it)
}
}
collect(l)
val descendantColors = descendants.mapNotNull { getExplicitColor(it) }
if (descendantColors.isNotEmpty()) {
return descendantColors.groupBy { it }.maxByOrNull { it.value.size }?.key ?: AUTO_GROUP
}
} else {
if (l.type == LocType.WORLD || l.type == LocType.COUNTRY) {
val descendants = mutableListOf<GeoLoc>()
fun collect(curr: GeoLoc) {
curr.children.forEach { descendants.add(it); collect(it) }
}
collect(l)
val dc = descendants.mapNotNull { getExplicitColor(it) }
if (dc.isNotEmpty()) return dc.groupBy { it }.maxByOrNull { it.value.size }?.key ?: AUTO_GROUP
}
}
return AUTO_GROUP
}
list.filter { getEffectiveGroup(it) == group.key }
} }
val area = when (activeMode) { val count = visitedItems.size
LocType.WORLD -> World.WWW.children.filter { it.code in visited }.sumOf { it.area } val area = visitedItems.sumOf { it.area.toLong() }
LocType.COUNTRY -> World.WWW.children.flatMap { it.children.filter { c -> c.code in visited } }.sumOf { it.area }
LocType.STATE -> World.WWW.children.flatMap { a->a.children.flatMap { b->b.children.filter { c->c.code in visited } } }.sumOf { it.area }
else -> 0
}
val displayValue = if (countMode) count.toString() else stringResource(R.string.number_with_unit, area, "km²") val displayValue = if (countMode) count.toString() else stringResource(R.string.number_with_unit, area.toInt(), "km²")
val backgroundColor = group.color.color val backgroundColor = group.color.color
val textColor = getContrastColor(backgroundColor) val textColor = getContrastColor(backgroundColor)
@@ -1,6 +1,7 @@
package net.helcel.beans.activity.sub package net.helcel.beans.activity.sub
import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -54,6 +55,44 @@ fun EditPlaceScreenPreview(){
EditPlaceScreen(Group.EEE) EditPlaceScreen(Group.EEE)
} }
/**
* Applies a place straight away where a single group makes the choice obvious,
* as tapping the checkbox does. Returns false when the group still has to be
* picked from the colour dialog.
*/
fun applyDirectVisit(ctx: Context, loc: GeoLoc): Boolean {
Data.selected_geoloc = loc
Data.selected_group = null
if (Data.groups.size() != 1 || !Settings.isSingleGroup(ctx)) return false
val current = Data.visits.getVisited(loc)
Data.visits.setVisited(
loc,
if (current == NO_GROUP || current == AUTO_GROUP) Data.groups.getUniqueEntry()!!.key
else if (loc.children.any { Data.visits.getVisited(it) != NO_GROUP }) AUTO_GROUP
else NO_GROUP,
)
syncVisited()
Data.saveData()
Data.selected_geoloc = null
return true
}
/** Stores whatever the colour dialog came back with. */
fun commitVisitDialog(cleared: Boolean) {
if (cleared) {
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
syncVisited()
Data.saveData()
}
if (Data.selected_group != null && Data.selected_geoloc != null) {
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
syncVisited()
Data.saveData()
}
Data.selected_geoloc = null
Data.selected_group = null
}
fun syncVisited(loc: GeoLoc?=World.WWW): Boolean { fun syncVisited(loc: GeoLoc?=World.WWW): Boolean {
var changed = false var changed = false
loc?.children?.forEach { tt -> loc?.children?.forEach { tt ->
@@ -122,22 +161,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
if(showEdit) if(showEdit)
EditPlaceDialog(false) { EditPlaceDialog(false) {
showEdit = false showEdit = false
if (it) { commitVisitDialog(it)
Data.visits.setVisited(Data.selected_geoloc, NO_GROUP)
syncVisited()
Data.saveData()
if (Data.selected_geoloc!=null && Data.selected_geoloc!!.children.any { itc-> Data.visits.getVisited(itc) != NO_GROUP }) {
Data.clearing_geoloc = Data.selected_geoloc
}
}
if (Data.selected_group != null && Data.selected_geoloc != null) {
Data.visits.setVisited(Data.selected_geoloc, Data.selected_group!!.key)
syncVisited()
Data.saveData()
}
Data.selected_geoloc = null
Data.selected_group = null
} }
Column { Column {
@@ -167,22 +191,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
tabs.add(loc) tabs.add(loc)
} }
}, { }, {
Data.selected_geoloc = loc if (!applyDirectVisit(ctx, loc)) showEdit = true
if (Data.groups.size() == 1 && Settings.isSingleGroup(ctx)) {
Data.visits.setVisited(Data.selected_geoloc,
if (it != ToggleableState.On) Data.groups.getUniqueEntry()!!.key
else if(Data.selected_geoloc?.children?.any{ itc->
Data.visits.getVisited(itc)!= NO_GROUP } == true) AUTO_GROUP
else NO_GROUP
)
Data.saveData()
Data.selected_group = null
} else {
Data.selected_group = null
showEdit=true
}
syncVisited()
Data.saveData()
}) })
} }
@@ -196,7 +205,7 @@ fun EditPlaceScreen(loc: GeoLoc, onExit:()->Unit={}) {
fun GeoLocRow( fun GeoLocRow(
loc: GeoLoc, loc: GeoLoc,
onClick: () -> Unit, onClick: () -> Unit,
onCheckedChange: (ToggleableState) -> Unit onToggle: () -> Unit,
) { ) {
val visits by Data.visits.visitsFlow.collectAsState() val visits by Data.visits.visitsFlow.collectAsState()
val checked by remember(visits, loc) { val checked by remember(visits, loc) {
@@ -235,7 +244,7 @@ fun GeoLocRow(
TriStateCheckbox( TriStateCheckbox(
state = checked, state = checked,
onClick= { onCheckedChange(checked) }, onClick = onToggle,
colors = CheckboxDefaults.colors( colors = CheckboxDefaults.colors(
checkedColor = color, checkedColor = color,
), ),
@@ -1,45 +0,0 @@
package net.helcel.beans.activity.sub
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.mikepenz.aboutlibraries.ui.compose.DefaultChipColors
import com.mikepenz.aboutlibraries.ui.compose.DefaultLibraryColors
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import net.helcel.beans.R
import net.helcel.beans.activity.SysTheme
@Preview
@Composable
fun LicenseScreen() {
val libraries = produceLibraries(R.raw.aboutlibraries)
SysTheme {
LibrariesContainer(
libraries = libraries.value,
modifier = Modifier.fillMaxSize(),
colors = DefaultLibraryColors(
libraryBackgroundColor = MaterialTheme.colors.background,
libraryContentColor = MaterialTheme.colors.onBackground,
licenseChipColors = DefaultChipColors(
containerColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.onPrimary,
),
versionChipColors = DefaultChipColors(
containerColor = MaterialTheme.colors.secondary,
contentColor = MaterialTheme.colors.onSecondary,
),
fundingChipColors = DefaultChipColors(
containerColor = MaterialTheme.colors.secondary,
contentColor = MaterialTheme.colors.onSecondary,
),
dialogConfirmButtonColor = MaterialTheme.colors.primary,
dialogBackgroundColor = MaterialTheme.colors.onPrimary,
dialogContentColor = MaterialTheme.colors.primary,
)
)
}
}
@@ -0,0 +1,157 @@
package net.helcel.beans.activity.sub
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import net.helcel.beans.R
import net.helcel.beans.activity.SysTheme
import net.helcel.beans.countries.GeoLoc
import net.helcel.beans.countries.GeoLocTree
import net.helcel.beans.helper.AUTO_GROUP
import net.helcel.beans.helper.Data
import net.helcel.beans.helper.NO_GROUP
private class PickRow(val loc: GeoLoc, val depth: Int)
/**
* Turns the shapes a tap landed near into an indented list.
*
* Each candidate brings its continent and country along, so a tap that is
* ambiguous between two regions can still be answered with "the whole country"
* or "the whole continent" instead of only the regions themselves.
*/
private fun rowsFor(candidates: List<GeoLoc>): List<PickRow> {
val rows = LinkedHashMap<String, PickRow>()
candidates.forEach { candidate ->
GeoLocTree.chain(candidate).forEachIndexed { depth, loc ->
rows.getOrPut(loc.code) { PickRow(loc, depth) }
}
}
return rows.values.toList()
}
@Composable
fun MapPickDialog(
candidates: List<GeoLoc>,
onPick: (GeoLoc) -> Unit,
onDismiss: () -> Unit,
) {
val visits by Data.visits.visitsFlow.collectAsState()
val rows = remember(candidates) { rowsFor(candidates) }
SysTheme {
Dialog(
onDismissRequest = onDismiss,
content = {
Column(
modifier = Modifier
.background(
MaterialTheme.colors.background,
RoundedCornerShape(corner = CornerSize(16.dp)),
)
.padding(16.dp),
) {
Text(
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
text = stringResource(R.string.select_place),
)
Text(
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onBackground,
text = stringResource(R.string.select_place_sub),
)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp),
) {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(rows, key = { it.loc.code }) { row ->
val group = visits.getOrElse(row.loc.code) { NO_GROUP }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(row.loc) }
.background(
Color(88, 88, 88, 88),
RoundedCornerShape(corner = CornerSize(16.dp)),
)
.padding(
start = 8.dp + 16.dp * row.depth,
top = 8.dp,
end = 8.dp,
bottom = 8.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(16.dp)
.background(
if (group == NO_GROUP || group == AUTO_GROUP) {
MaterialTheme.colors.onBackground
} else {
Color(
Data.groups.getGroupFromKey(group).color.color
)
},
CircleShape,
),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body2,
text = row.loc.fullName,
// Long names wrap inside the row rather
// than running off the end of it.
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
}
},
)
}
}
@@ -12,6 +12,7 @@ object GeoLocImporter {
fun importStates(ctx: Context, force: Boolean = false) { fun importStates(ctx: Context, force: Boolean = false) {
if (!Settings.isRegional(ctx) and !force) { if (!Settings.isRegional(ctx) and !force) {
GeoLocTree.rebuild()
return return
} }
val fs = BufferedReader(InputStreamReader(ctx.assets.open("geoloc_state.txt"))) val fs = BufferedReader(InputStreamReader(ctx.assets.open("geoloc_state.txt")))
@@ -26,6 +27,7 @@ object GeoLocImporter {
} }
} }
} }
GeoLocTree.rebuild()
} }
fun clearStates() { fun clearStates() {
@@ -39,6 +41,7 @@ object GeoLocImporter {
} }
country.children.clear() country.children.clear()
} }
GeoLocTree.rebuild()
Data.saveData() Data.saveData()
} }
} }
@@ -0,0 +1,50 @@
package net.helcel.beans.countries
/**
* Code to place lookup, plus the chain of parents above a place.
*
* Regions only exist once [GeoLocImporter] has read them, so this is rebuilt
* whenever that set changes rather than derived once and cached forever.
*/
object GeoLocTree {
private var index: Map<String, GeoLoc> = emptyMap()
private var parents: Map<String, GeoLoc> = emptyMap()
fun rebuild() {
val byCode = HashMap<String, GeoLoc>()
val parent = HashMap<String, GeoLoc>()
byCode[World.WWW.code] = World.WWW
World.WWW.children.forEach { child ->
byCode[child.code] = child
parent[child.code] = World.WWW
child.children.forEach { country ->
byCode[country.code] = country
parent[country.code] = child
country.children.forEach { state ->
byCode[state.code] = state
parent[state.code] = country
}
}
}
index = byCode
parents = parent
}
fun find(code: String): GeoLoc? {
if (index.isEmpty()) rebuild()
return index[code]
}
/** [loc] and everything above it, world first, so it reads as a path. */
fun chain(loc: GeoLoc): List<GeoLoc> {
if (index.isEmpty()) rebuild()
val path = ArrayList<GeoLoc>(4)
var current: GeoLoc? = loc
while (current != null && current != World.WWW) {
path.add(current)
current = parents[current.code]
}
return path.reversed()
}
}
@@ -8,7 +8,6 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import net.helcel.beans.R import net.helcel.beans.R
import net.helcel.beans.countries.GeoLoc import net.helcel.beans.countries.GeoLoc
import java.util.HashMap import java.util.HashMap
@@ -23,7 +22,6 @@ object Data {
var selected_group : Groups.Group? = null var selected_group : Groups.Group? = null
var selected_geoloc: GeoLoc? = null var selected_geoloc: GeoLoc? = null
var clearing_geoloc: GeoLoc? = null
private val groupsSerial = Groups.GroupsSerializer() private val groupsSerial = Groups.GroupsSerializer()
private val visitsSerial = Visits.VisitsSerializer() private val visitsSerial = Visits.VisitsSerializer()
@@ -31,7 +29,7 @@ object Data {
private lateinit var sharedPreferences: SharedPreferences private lateinit var sharedPreferences: SharedPreferences
fun loadData(ctx: Context, id:Int) { fun loadData(ctx: Context, id:Int) {
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ctx) sharedPreferences = defaultPreferences(ctx)
val groupsString = sharedPreferences.getString("groups_$id",null) val groupsString = sharedPreferences.getString("groups_$id",null)
val visitsString = sharedPreferences.getString("visits_$id",null) val visitsString = sharedPreferences.getString("visits_$id",null)
@@ -43,6 +43,13 @@ class Groups(val id: Int, @SerialName("grps") private val groups: HashMap<Int, G
updateFlow() updateFlow()
} }
fun keepOnly(key: Int) {
val keep = groups[key] ?: return
groups.clear()
groups[key] = keep
updateFlow()
}
private fun updateFlow() { private fun updateFlow() {
_groupsFlow.value = groups.values.toList() _groupsFlow.value = groups.values.toList()
} }
@@ -0,0 +1,12 @@
package net.helcel.beans.helper
import android.content.Context
import android.content.SharedPreferences
/**
* The preference file opened directly, under the name and mode androidx's
* PreferenceManager used. Settings written by earlier versions still load, and
* the app no longer pulls in the preference UI framework for one call.
*/
fun defaultPreferences(ctx: Context): SharedPreferences =
ctx.getSharedPreferences(ctx.packageName + "_preferences", Context.MODE_PRIVATE)
@@ -2,17 +2,26 @@ package net.helcel.beans.helper
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import net.helcel.beans.R import net.helcel.beans.R
import net.helcel.beans.activity.MainScreen import net.helcel.beans.activity.MainScreen
/**
* Default distance, in dp, around a tap that still counts as hitting a place.
* Kept small because the radius is measured on screen: zoomed out to the whole
* world, even a few dp reach across several countries.
*/
const val DEFAULT_TOUCH_RADIUS = 4
/** Largest tap radius the setting offers. */
const val MAX_TOUCH_RADIUS = 48
object Settings { object Settings {
private lateinit var sp: SharedPreferences private lateinit var sp: SharedPreferences
private lateinit var mainActivity: MainScreen private lateinit var mainActivity: MainScreen
fun start(ctx: MainScreen) { fun start(ctx: MainScreen) {
mainActivity = ctx mainActivity = ctx
sp = PreferenceManager.getDefaultSharedPreferences(ctx) sp = defaultPreferences(ctx)
} }
fun isSingleGroup(ctx: Context): Boolean { fun isSingleGroup(ctx: Context): Boolean {
@@ -29,6 +38,21 @@ object Settings {
) )
} }
/**
* How far around a tap, in dp, the map looks for other places. Anything
* within it is offered as a candidate instead of painting straight away.
*/
fun getTouchRadius(ctx: Context): Float {
return sp.getInt(ctx.getString(R.string.key_touch_radius), DEFAULT_TOUCH_RADIUS).toFloat()
}
fun isCascadeStats(ctx: Context): Boolean {
return getBooleanValue(
ctx,
sp.getString(ctx.getString(R.string.key_cascade_stats), ctx.getString(R.string.off))
)
}
fun getStatPref(ctx: Context): String? { fun getStatPref(ctx: Context): String? {
return sp.getString( return sp.getString(
ctx.getString(R.string.key_stats), ctx.getString(R.string.key_stats),
@@ -0,0 +1,23 @@
package net.helcel.beans.map
import android.content.Context
import net.helcel.beans.R
import net.helcel.beans.helper.defaultPreferences
/** Picks the map asset matching the projection the user chose. */
object MapAssets {
fun assetFor(ctx: Context): String {
val preferences = defaultPreferences(ctx)
return when (
preferences.getString(
ctx.getString(R.string.key_projection),
ctx.getString(R.string.mercator),
)
) {
ctx.getString(R.string.azimuthalequidistant) -> "aeqd01.bmap"
ctx.getString(R.string.loximuthal) -> "loxim01.bmap"
else -> "webmercator01.bmap"
}
}
}
@@ -0,0 +1,133 @@
package net.helcel.beans.map
import android.graphics.Path
import android.graphics.RectF
import java.io.InputStream
private const val VERSION = 2
/**
* Reads the packed map assets produced by `packmap.js`.
*
* The maps used to ship as SVG, which spends around four megabytes per
* projection spelling coordinates out in decimal ASCII inside XML. The packed
* form says the same thing in a fraction of that, and reading it is a straight
* scan over a byte array instead of an XML parse with a float scanner on top.
*
* The format is documented in `packmap.js`; the two have to agree.
*/
object MapReader {
fun read(input: InputStream): MapWorld {
val reader = ByteReader(input.readBytes())
reader.readHeader()
val width = reader.unsigned() * reader.scale
val height = reader.unsigned() * reader.scale
val count = reader.unsigned()
val shapes = ArrayList<MapShape>(count)
repeat(count) { shapes.add(reader.shape()) }
return MapWorld(width, height, shapes)
}
}
private class ByteReader(private val data: ByteArray) {
private var pos = 0
/** One grid step in user units, read from the header. */
var scale = 1f
private set
fun readHeader() {
require(
data.size > 5 &&
data[0] == 'B'.code.toByte() && data[1] == 'M'.code.toByte() &&
data[2] == 'A'.code.toByte() && data[3] == 'P'.code.toByte()
) { "not a packed map" }
require(data[4].toInt() == VERSION) { "unsupported packed map version ${data[4]}" }
pos = 5
scale = 1f / unsigned()
}
fun unsigned(): Int {
var result = 0
var shift = 0
while (true) {
val byte = data[pos++].toInt()
result = result or ((byte and 0x7F) shl shift)
if (byte and 0x80 == 0) return result
shift += 7
}
}
/** Zigzag, because deltas run both ways and are almost always tiny. */
fun signed(): Int {
val value = unsigned()
return if (value and 1 == 1) -((value ushr 1) + 1) else value ushr 1
}
fun flag(): Boolean = data[pos++].toInt() != 0
fun text(): String {
val length = unsigned()
val value = String(data, pos, length, Charsets.UTF_8)
pos += length
return value
}
fun shape(): MapShape {
val code = text()
val isState = flag()
val ringCount = unsigned()
val rings = ArrayList<FloatArray>(ringCount)
var nonZero: Path? = null
var evenOdd: Path? = null
var minX = Float.MAX_VALUE
var minY = Float.MAX_VALUE
var maxX = -Float.MAX_VALUE
var maxY = -Float.MAX_VALUE
repeat(ringCount) {
val points = unsigned()
val useEvenOdd = flag()
val ring = FloatArray(points * 2)
// Each point is a delta from the one before it, the first from the
// origin, so the whole ring decodes in one pass.
var x = 0
var y = 0
for (i in 0 until points) {
x += signed()
y += signed()
val fx = x * scale
val fy = y * scale
ring[i * 2] = fx
ring[i * 2 + 1] = fy
if (fx < minX) minX = fx
if (fx > maxX) maxX = fx
if (fy < minY) minY = fy
if (fy > maxY) maxY = fy
}
rings.add(ring)
val path = if (useEvenOdd) {
evenOdd ?: Path().also { it.fillType = Path.FillType.EVEN_ODD; evenOdd = it }
} else {
nonZero ?: Path().also { nonZero = it }
}
path.moveTo(ring[0], ring[1])
var i = 2
while (i < ring.size) {
path.lineTo(ring[i], ring[i + 1])
i += 2
}
path.close()
}
return MapShape(
code, isState, rings,
RectF(minX, minY, maxX, maxY),
nonZero, evenOdd,
)
}
}
@@ -0,0 +1,103 @@
package net.helcel.beans.map
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.RectF
import kotlin.math.min
import kotlin.math.roundToInt
/** Draws a [MapWorld] onto a canvas through a pan/zoom transform. */
class MapRenderer(private val map: MapWorld) {
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val inverse = Matrix()
private val values = FloatArray(9)
private val visible = RectF()
/** False when the border is too faint to be worth a draw call at all. */
private var strokeVisible = true
/**
* Paints the map into [canvas], mapping user units through [transform].
* Shapes outside the canvas are skipped, which is what makes drawing cheap
* once the map is zoomed in.
*/
fun draw(canvas: Canvas, transform: Matrix, style: MapStyle) {
// SRC rather than blended: the bitmap is reused between renders.
canvas.drawColor(style.background, PorterDuff.Mode.SRC)
if (!transform.invert(inverse)) return
visible.set(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat())
inverse.mapRect(visible)
// Widths are given in device pixels, so undo the zoom the canvas is
// about to apply to them, then let them grow slowly with it.
transform.getValues(values)
val scale = values[Matrix.MSCALE_X]
val perPixel = 1f / scale
val fitted = min(canvas.width / map.width, canvas.height / map.height)
val zoom = (scale / fitted).coerceAtLeast(1f)
val regionPx = strokeWidthAt(zoom, REGION_STROKE_STOPS)
val countryPx = strokeWidthAt(zoom, COUNTRY_STROKE_STOPS)
canvas.save()
canvas.concat(transform)
setStroke(style.background, if (style.regional) regionPx else countryPx, perPixel)
for ((code, color) in style.fills) {
val shape = map.byCode[code] ?: continue
if (!RectF.intersects(shape.bounds, visible)) continue
fill.color = color
drawShape(canvas, shape.nonZero, true)
drawShape(canvas, shape.evenOdd, true)
}
// With regions on, the country outlines come back on top as the thicker
// borders between them.
if (style.regional) {
setStroke(style.background, countryPx, perPixel)
for (shape in map.countries) {
if (!RectF.intersects(shape.bounds, visible)) continue
drawShape(canvas, shape.nonZero, false)
drawShape(canvas, shape.evenOdd, false)
}
}
canvas.restore()
}
/**
* Sets a border width given in device pixels.
*
* Anything thinner than a pixel cannot be drawn as such: the rasteriser
* turns it into a one pixel hairline at full strength, which is why every
* border below that width used to come out the same weight no matter how
* far out the map was. Below a pixel the width is carried as opacity
* instead, so a quarter-pixel border reads as a quarter as dark.
*/
private fun setStroke(color: Int, devicePx: Float, perPixel: Float) {
strokeVisible = devicePx > 0.004f
if (!strokeVisible) return
stroke.color = color
if (devicePx < 1f) {
stroke.strokeWidth = perPixel
stroke.alpha = (devicePx * 255f).roundToInt().coerceIn(0, 255)
} else {
stroke.strokeWidth = perPixel * devicePx
stroke.alpha = 255
}
}
private fun drawShape(canvas: Canvas, path: Path?, filled: Boolean) {
if (path == null) return
if (filled) canvas.drawPath(path, fill)
if (strokeVisible) canvas.drawPath(path, stroke)
}
}
@@ -0,0 +1,115 @@
package net.helcel.beans.map
import android.content.Context
import net.helcel.beans.countries.GeoLoc
import net.helcel.beans.countries.World
import net.helcel.beans.helper.AUTO_GROUP
import net.helcel.beans.helper.Data
import net.helcel.beans.helper.NO_GROUP
import net.helcel.beans.helper.Settings
import kotlin.math.ln
/**
* Border width in device pixels at each zoom, interpolated in between.
*
* Zoom counts how far in the map is from its fitted size, so 1 is the whole
* world on screen and 64 is as far in as it goes. The stops step by fours and
* are interpolated on a log axis, so the weight changes evenly as you pinch
* rather than in a rush at one end.
*
* Region borders start at nothing on purpose: a sub-national border says very
* little with the whole world in view, and drawing three and a half thousand of
* them only muddies the land.
*
* zoom 1x 4x 16x 64x
* region 0.0 0.5 1.0 2.0
* country 1.0 1.5 3.0 6.0
*/
val STROKE_ZOOM_STOPS = floatArrayOf(1f, 4f, 16f, 64f)
val REGION_STROKE_STOPS = floatArrayOf(0f, 0.5f, 1f, 2f)
val COUNTRY_STROKE_STOPS = floatArrayOf(1f, 1.5f, 3f, 6f)
/** The width for [zoom], straight-line between the stops on a log axis. */
fun strokeWidthAt(zoom: Float, widths: FloatArray): Float {
if (zoom <= STROKE_ZOOM_STOPS.first()) return widths.first()
for (i in 1 until STROKE_ZOOM_STOPS.size) {
if (zoom <= STROKE_ZOOM_STOPS[i]) {
val low = STROKE_ZOOM_STOPS[i - 1]
val t = ln(zoom / low) / ln(STROKE_ZOOM_STOPS[i] / low)
return widths[i - 1] + t * (widths[i] - widths[i - 1])
}
}
return widths.last()
}
/**
* The colour every shape is drawn with, resolved once per render.
*
* This replaces the stylesheet the map used to be rendered through. A place
* inherits the colour of its parent, so painting a whole continent still shows
* up on each country inside it, and [fills] ends up holding exactly the shapes
* that get drawn — which is also exactly what a tap may land on.
*/
class MapStyle(
val regional: Boolean,
val land: Int,
val background: Int,
val fills: Map<String, Int>,
) {
companion object {
fun build(ctx: Context, land: Int, background: Int): MapStyle {
val regional = Settings.isRegional(ctx)
val fills = HashMap<String, Int>()
World.WWW.children.forEach { child ->
if (child.type == GeoLoc.LocType.COUNTRY) {
// A country hanging straight off the world, such as Antarctica.
addCountry(fills, child, colorOf(World.WWW, regional), regional, land)
} else {
val continent = colorOf(child, regional) ?: colorOf(World.WWW, regional)
child.children.forEach { addCountry(fills, it, continent, regional, land) }
}
}
return MapStyle(regional, land, background, fills)
}
private fun addCountry(
fills: HashMap<String, Int>,
country: GeoLoc,
inherited: Int?,
regional: Boolean,
land: Int,
) {
val color = colorOf(country, regional) ?: inherited
// Regions are only drawn for countries that actually have some; the
// rest keep their outline filled so the map stays complete.
if (regional && country.children.isNotEmpty()) {
country.children.forEach { state ->
fills[state.code] = colorOf(state, regional) ?: color ?: land
}
} else {
fills[country.code] = color ?: land
}
}
/** The group colour assigned to [loc], or `null` when it has none of its own. */
private fun colorOf(loc: GeoLoc, regional: Boolean): Int? {
val key = Data.visits.getVisited(loc)
val group = Data.groups.getGroupFromKey(key)
if (group.key != NO_GROUP) return group.color.color
// A place marked automatically stands in for its children. That only
// needs a colour of its own where the children are not drawn, and
// never on a continent, which would swallow the whole map.
val isParent = loc.type == GeoLoc.LocType.GROUP ||
loc.type == GeoLoc.LocType.CUSTOM_GROUP ||
loc.type == GeoLoc.LocType.WORLD
if (key == AUTO_GROUP && !regional && !isParent) {
return Data.groups.getGroupFromPos(0).second.color.color
}
return null
}
}
}
@@ -0,0 +1,345 @@
package net.helcel.beans.map
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import android.widget.OverScroller
import androidx.core.graphics.createBitmap
import kotlin.math.min
/** A shape a tap landed on or near, with how far away it was in user units. */
class MapPick(val code: String, val distance: Float)
/** How far past the fitted map a pinch may zoom in. */
private const val MAX_ZOOM = 64f
/** How much a double tap zooms in by. */
private const val DOUBLE_TAP_ZOOM = 3f
/** Time a gesture has to settle before the map is redrawn sharp again. */
private const val RENDER_DELAY_MS = 80L
/** How long the map may stay stretched while a gesture keeps going. */
private const val MAX_STALE_MS = 500L
/**
* Most candidates a single tap will offer. A wide radius over a crowded part of
* the map can reach dozens of places, which is more of a list than anyone wants
* to read; the nearest few are what the tap was plausibly aiming at.
*/
private const val MAX_CANDIDATES = 16
/**
* The map itself: pan, pinch and tap over a [MapWorld].
*
* Drawing every country as vectors costs far too much to do on each frame, so a
* render lands in an off-screen bitmap on a worker thread and gestures just move
* that bitmap around. Once the gesture settles the map is drawn again at the new
* zoom, which is what keeps it sharp all the way in.
*/
@SuppressLint("ViewConstructor")
class MapView(context: Context) : View(context) {
var world: MapWorld? = null
set(value) {
if (field === value) return
field = value
renderer = value?.let(::MapRenderer)
fitToView()
requestRender(0L)
}
@Volatile
var style: MapStyle? = null
set(value) {
if (field === value) return
field = value
requestRender(0L)
}
/** How far from a tap, in dp, other shapes still count as candidates. */
var touchRadiusDp: Float = 4f
/** Called on a tap with every nearby shape, nearest first. */
var onPick: ((List<MapPick>) -> Unit)? = null
@Volatile
private var renderer: MapRenderer? = null
private val scroller = OverScroller(context)
private val transform = Matrix()
private val inverse = Matrix()
private val values = FloatArray(9)
private val point = FloatArray(2)
private var minScale = 1f
private var maxScale = 1f
private val blitPaint = Paint(Paint.FILTER_BITMAP_FLAG)
private val blit = Matrix()
private val lock = Any()
private var front: Bitmap? = null
private var back: Bitmap? = null
private val frontMatrix = Matrix()
private val frontValues = FloatArray(9)
private val pendingMatrix = Matrix()
private var pendingWidth = 0
private var pendingHeight = 0
@Volatile private var lastRenderAt = 0L
private val renderThread = HandlerThread("map-render").apply { start() }
private val renderHandler = Handler(renderThread.looper)
private val renderTask = Runnable { render() }
private val scaleDetector = ScaleGestureDetector(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val scale = currentScale()
val target = (scale * detector.scaleFactor).coerceIn(minScale, maxScale)
transform.postScale(target / scale, target / scale, detector.focusX, detector.focusY)
clamp()
invalidate()
return true
}
},
)
private val gestureDetector = GestureDetector(
context,
object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
scroller.forceFinished(true)
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
val map = world ?: return false
transform.getValues(values)
val scale = values[Matrix.MSCALE_X]
val x = values[Matrix.MTRANS_X].toInt()
val y = values[Matrix.MTRANS_Y].toInt()
val w = map.width * scale
val h = map.height * scale
scroller.forceFinished(true)
scroller.fling(
x, y, velocityX.toInt(), velocityY.toInt(),
if (w > width) (width - w).toInt() else x, if (w > width) 0 else x,
if (h > height) (height - h).toInt() else y, if (h > height) 0 else y,
)
postInvalidateOnAnimation()
return true
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
transform.postTranslate(-distanceX, -distanceY)
clamp()
invalidate()
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
pick(e.x, e.y)
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
val scale = currentScale()
val target = if (scale >= maxScale * 0.99f) {
minScale
} else {
min(scale * DOUBLE_TAP_ZOOM, maxScale)
}
transform.postScale(target / scale, target / scale, e.x, e.y)
clamp()
invalidate()
return true
}
},
)
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
gestureDetector.onTouchEvent(event)
return true
}
override fun computeScroll() {
if (!scroller.computeScrollOffset()) return
transform.getValues(values)
values[Matrix.MTRANS_X] = scroller.currX.toFloat()
values[Matrix.MTRANS_Y] = scroller.currY.toFloat()
transform.setValues(values)
postInvalidateOnAnimation()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
fitToView()
requestRender(0L)
}
override fun onDraw(canvas: Canvas) {
val current = style
canvas.drawColor(current?.background ?: 0)
var stale = true
synchronized(lock) {
val bitmap = front
if (bitmap != null && !bitmap.isRecycled && frontMatrix.invert(blit)) {
blit.postConcat(transform)
canvas.drawBitmap(bitmap, blit, blitPaint)
transform.getValues(values)
stale = !values.contentEquals(frontValues)
}
}
// Scheduling only ever happens from here or from a setter, both on the
// main thread, so a render never starts while its bitmap is on screen.
if (stale) {
val overdue = SystemClock.uptimeMillis() - lastRenderAt > MAX_STALE_MS
requestRender(if (overdue) 0L else RENDER_DELAY_MS)
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
scroller.forceFinished(true)
renderHandler.removeCallbacks(renderTask)
renderThread.quitSafely()
// Dropped rather than recycled: a render may still be part way through
// one of them, and the collector is happy to take them from here.
synchronized(lock) {
front = null
back = null
}
}
/** Centres the map in the view and works out how far it may be zoomed. */
private fun fitToView() {
val map = world ?: return
if (width == 0 || height == 0 || map.width <= 0f || map.height <= 0f) return
val scale = min(width / map.width, height / map.height)
minScale = scale
maxScale = scale * MAX_ZOOM
transform.setScale(scale, scale)
transform.postTranslate(
(width - map.width * scale) / 2f,
(height - map.height * scale) / 2f,
)
}
private fun currentScale(): Float {
transform.getValues(values)
return values[Matrix.MSCALE_X]
}
/** Keeps the map from being dragged away from the viewport. */
private fun clamp() {
val map = world ?: return
transform.getValues(values)
val scale = values[Matrix.MSCALE_X]
val w = map.width * scale
val h = map.height * scale
values[Matrix.MTRANS_X] = if (w <= width) {
(width - w) / 2f
} else {
values[Matrix.MTRANS_X].coerceIn(width - w, 0f)
}
values[Matrix.MTRANS_Y] = if (h <= height) {
(height - h) / 2f
} else {
values[Matrix.MTRANS_Y].coerceIn(height - h, 0f)
}
transform.setValues(values)
}
private fun requestRender(delay: Long) {
if (width == 0 || height == 0 || renderer == null || style == null) return
synchronized(lock) {
pendingMatrix.set(transform)
pendingWidth = width
pendingHeight = height
}
renderHandler.removeCallbacks(renderTask)
renderHandler.postDelayed(renderTask, delay)
}
private fun render() {
val matrix = Matrix()
val w: Int
val h: Int
synchronized(lock) {
matrix.set(pendingMatrix)
w = pendingWidth
h = pendingHeight
}
if (w <= 0 || h <= 0) return
val current = renderer ?: return
val currentStyle = style ?: return
var target = back
if (target == null || target.width != w || target.height != h) {
target?.recycle()
target = createBitmap(w, h)
back = target
}
current.draw(Canvas(target), matrix, currentStyle)
synchronized(lock) {
back = front
front = target
frontMatrix.set(matrix)
matrix.getValues(frontValues)
}
lastRenderAt = SystemClock.uptimeMillis()
postInvalidate()
}
/** Reports every shape within the tap radius of ([x], [y]), nearest first. */
private fun pick(x: Float, y: Float) {
val map = world ?: return
val current = style ?: return
val callback = onPick ?: return
if (!transform.invert(inverse)) return
point[0] = x
point[1] = y
inverse.mapPoints(point)
val radius = touchRadiusDp * resources.displayMetrics.density / currentScale()
val picks = ArrayList<MapPick>()
for (code in current.fills.keys) {
val shape = map.byCode[code] ?: continue
val bounds = shape.bounds
if (point[0] < bounds.left - radius || point[0] > bounds.right + radius) continue
if (point[1] < bounds.top - radius || point[1] > bounds.bottom + radius) continue
val distance = shape.distanceTo(point[0], point[1])
if (distance <= radius) picks.add(MapPick(code, distance))
}
if (picks.isEmpty()) return
picks.sortBy { it.distance }
callback(if (picks.size > MAX_CANDIDATES) picks.subList(0, MAX_CANDIDATES) else picks)
}
}
@@ -0,0 +1,86 @@
package net.helcel.beans.map
import android.graphics.Path
import android.graphics.RectF
import kotlin.math.sqrt
/**
* One named piece of map geometry: a country outline or a single region.
*
* The geometry is kept twice on purpose. [nonZero] and [evenOdd] are what the
* renderer hands to the canvas, while [rings] keeps the raw points because
* hit testing needs the distance to an edge, which a [Path] cannot give us.
*/
class MapShape(
val code: String,
val isState: Boolean,
val rings: List<FloatArray>,
val bounds: RectF,
val nonZero: Path?,
val evenOdd: Path?,
) {
/** True when ([x], [y]) falls inside the shape. Holes and lakes count as outside. */
fun contains(x: Float, y: Float): Boolean {
if (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom) return false
// Even-odd crossing count over every ring at once: rings never overlap, so
// an island still reads as inside and a hole still reads as outside.
var inside = false
for (ring in rings) {
var j = ring.size - 2
var i = 0
while (i < ring.size) {
val yi = ring[i + 1]
val yj = ring[j + 1]
if ((yi > y) != (yj > y)) {
val xi = ring[i]
val xj = ring[j]
if (x < xi + (y - yi) * (xj - xi) / (yj - yi)) inside = !inside
}
j = i
i += 2
}
}
return inside
}
/** Distance from ([x], [y]) to the shape, `0` when the point is inside it. */
fun distanceTo(x: Float, y: Float): Float {
if (contains(x, y)) return 0f
var best = Float.MAX_VALUE
for (ring in rings) {
var j = ring.size - 2
var i = 0
while (i < ring.size) {
val d = segmentDistanceSq(x, y, ring[j], ring[j + 1], ring[i], ring[i + 1])
if (d < best) best = d
j = i
i += 2
}
}
return if (best == Float.MAX_VALUE) Float.MAX_VALUE else sqrt(best)
}
private fun segmentDistanceSq(
px: Float, py: Float,
ax: Float, ay: Float,
bx: Float, by: Float,
): Float {
val dx = bx - ax
val dy = by - ay
val len = dx * dx + dy * dy
val t = if (len <= 0f) 0f else (((px - ax) * dx + (py - ay) * dy) / len).coerceIn(0f, 1f)
val cx = px - (ax + t * dx)
val cy = py - (ay + t * dy)
return cx * cx + cy * cy
}
}
/** The whole map, in SVG user units, as a flat set of named shapes. */
class MapWorld(val width: Float, val height: Float, val shapes: List<MapShape>) {
val byCode: Map<String, MapShape> = shapes.associateBy { it.code }
/** Country outlines, keyed by ISO code. Drawn as borders when regions are on. */
val countries: List<MapShape> = shapes.filter { !it.isState }
}
@@ -1,75 +0,0 @@
package net.helcel.beans.svg
import android.content.Context
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.toArgb
import androidx.core.graphics.drawable.toDrawable
import net.helcel.beans.countries.World
import net.helcel.beans.helper.AUTO_GROUP
import net.helcel.beans.helper.Data.groups
import net.helcel.beans.helper.Data.visits
import net.helcel.beans.helper.NO_GROUP
import net.helcel.beans.helper.Settings
import net.helcel.beans.helper.Theme.colorToHex6
class CSSWrapper(private val ctx: Context) {
private val continents: String = World.WWW.children.joinToString(",") { "#${it.code}2" }
private val countries: String = World.WWW.children.joinToString(",") { itt ->
itt.children.joinToString(",") { "#${it.code}2" }
}
private val regional: String = World.WWW.children.joinToString(",") { itt ->
itt.children.joinToString(",") { "#${it.code}1" }
}
@Composable
fun getBaseColors() : Pair<String, String> {
val colorForeground = colorToHex6(MaterialTheme.colors.onBackground.toArgb().toDrawable())
val colorBackground = colorToHex6(MaterialTheme.colors.background.toArgb().toDrawable())
return Pair(colorForeground, colorBackground)
}
private var customCSS: String = ""
init {
refresh()
}
private fun refresh() {
val id = if (Settings.isRegional(ctx)) "1" else "2"
customCSS = visits.getVisitedByValue().map { (k, v) ->
(if (groups.getGroupFromKey(k).key != NO_GROUP) {
v
} else if (!Settings.isRegional(ctx) && k == AUTO_GROUP) {
v.filter { it !in World.WWW.children.map { it1 -> it1.code } }
} else {
emptyList()
}).takeIf { it.isNotEmpty() }
?.joinToString(",") { "#${it}$id,#${it}" } + "{fill:${
if (k == AUTO_GROUP) colorToHex6(groups.getGroupFromPos(0).second.color)
else colorToHex6(groups.getGroupFromKey(k).color)
};}"
}.joinToString("")
}
@Composable
fun get(): String {
val (colorForeground,colorBackground) = getBaseColors()
refresh()
return if (Settings.isRegional(ctx)) {
val countryRegionalCSS: String =
"svg{fill:$colorForeground;stroke:$colorBackground;stroke-width:0.01;}" +
"$continents,$countries{fill:none;stroke:$colorBackground;stroke-width:0.1;}"
countryRegionalCSS + customCSS
} else {
val countryOnlyCSS: String =
"svg{fill:$colorForeground;stroke:$colorBackground;stroke-width:0.1;}" +
"${regional}{display:none;}"
countryOnlyCSS + customCSS
}
}
}
@@ -1,28 +0,0 @@
package net.helcel.beans.svg
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import com.caverock.androidsvg.SVG
import net.helcel.beans.R
class SVGWrapper(ctx: Context) {
private val sharedPreferences: SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(ctx)
private val svgFile = when (sharedPreferences.getString(
ctx.getString(R.string.key_projection),
ctx.getString(R.string.mercator)
)) {
ctx.getString(R.string.azimuthalequidistant) -> "aeqd01.svg"
ctx.getString(R.string.loximuthal) -> "loxim01.svg"
ctx.getString(R.string.mercator) -> "webmercator01.svg"
else -> "webmercator01.svg"
}
private var svg: SVG? = SVG.getFromAsset(ctx.assets, svgFile)
fun get(): SVG? {
return svg
}
}
@@ -7,21 +7,6 @@
android:scaleY="0.8918919" android:scaleY="0.8918919"
android:translateX="6.918919" android:translateX="6.918919"
android:translateY="6.918919"> android:translateY="6.918919">
<group
android:scaleX="1.5"
android:scaleY="1.5"
android:translateX="10"
android:translateY="10">
<path
android:pathData="M36,36m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="@color/blue"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
</group>
<group <group
android:scaleX="1.25" android:scaleX="1.25"
android:scaleY="1.25" android:scaleY="1.25"
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/> <monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>
+1 -4
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Beans" parent="Theme.MaterialComponents.DayNight.NoActionBar"> <style name="Theme.Beans" parent="android:Theme.Material.NoActionBar">
<!-- Status bar color in dark mode --> <!-- Status bar color in dark mode -->
<item name="android:statusBarColor">@color/blue</item> <item name="android:statusBarColor">@color/blue</item>
<!-- Window background in dark mode --> <!-- Window background in dark mode -->
@@ -8,8 +8,5 @@
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item> <item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="colorPrimary">@color/blue</item>
<item name="colorPrimaryDark">@color/blue</item>
<item name="colorAccent">@color/blue</item>
</style> </style>
</resources> </resources>
+20 -3
View File
@@ -13,20 +13,37 @@
<string name="key_stats">Statistics</string> <string name="key_stats">Statistics</string>
<string name="counters">Prefer counters (#)</string> <string name="counters">Prefer counters (#)</string>
<string name="percentages">Prefer percentages (%)</string> <string name="percentages">Prefer percentages (%)</string>
<string name="licenses">Licenses</string>
<string name="key_group">Groups</string> <string name="key_group">Groups</string>
<string name="key_regional">Regional</string> <string name="key_regional">Regional mode</string>
<string name="key_regional_desc">Enable tracking of states and provinces</string>
<string name="key_cascade_stats">key_cascade_stats</string>
<string name="pref_cascade_stats">Cascade statistics</string>
<string name="pref_cascade_stats_desc">Include parents and children in the counts</string>
<string name="key_group_desc">Enable multiple colors</string>
<string name="key_touch_radius">key_touch_radius</string>
<string name="pref_touch_radius">Tap radius</string>
<string name="pref_touch_radius_desc">How far around a tap on the map other places should be considered</string>
<string name="pref_touch_radius_value">%1$d dp</string>
<string name="about">About</string> <string name="about">About</string>
<string name="pref_category_appearance">Appearance</string>
<string name="pref_category_features">Features</string>
<string name="pref_category_data">Data</string>
<string name="action_import">Import</string>
<string name="action_export">Export</string>
<string name="import_desc">Load data from a previously exported file.</string>
<string name="export_desc">Save your current data to a file for backup or transfer.</string>
<string name="key_projection_desc">Select how the map is projected onto a 2D surface.</string>
<string name="beans_is_foss">Beans is free and open source software, licensed under the GNU General Public License (version 3 or later)</string> <string name="beans_is_foss">Beans is free and open source software, licensed under the GNU General Public License (version 3 or later)</string>
<string name="beans_repo_uri">https://github.com/helcel-net/beans</string> <string name="beans_repo_uri">https://github.com/helcel-net/beans</string>
<string name="beans_repo">Project repository: %1$s\n Feel free to report issues or contribute.</string> <string name="beans_repo">Project repository: %1$s\n Feel free to report issues or contribute.</string>
<string name="foss_licenses">Free and open source dependencies and licenses</string>
<string name="about_beans">About the Beans application</string> <string name="about_beans">About the Beans application</string>
<string name="edit_group">Select the group to assign.</string> <string name="edit_group">Select the group to assign.</string>
<string name="edit_group_sub">Long press on a group to edit its name and color.</string> <string name="edit_group_sub">Long press on a group to edit its name and color.</string>
<string name="select_group">Select the group to keep.</string> <string name="select_group">Select the group to keep.</string>
<string name="select_group_sub">All others will be deleted and its mappings reassigned to the group you choose here.</string> <string name="select_group_sub">All others will be deleted and its mappings reassigned to the group you choose here.</string>
<string name="delete_group">Are your sure you want to delete this group and remove all its country mappings?</string> <string name="delete_group">Are your sure you want to delete this group and remove all its country mappings?</string>
<string name="select_place">Select a place</string>
<string name="select_place_sub">Tap the place to color, or a larger one that contains it.</string>
<string name="delete_regions">Are you sure you want to disable regions and reassign all regional mappings to the corresponding countries?</string> <string name="delete_regions">Are you sure you want to disable regions and reassign all regional mappings to the corresponding countries?</string>
<string name="name">Name</string> <string name="name">Name</string>
+1 -5
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Beans" parent="Theme.MaterialComponents.DayNight.NoActionBar"> <style name="Theme.Beans" parent="android:Theme.Material.Light.NoActionBar">
<!-- Status bar color --> <!-- Status bar color -->
<item name="android:statusBarColor">@color/blue</item> <item name="android:statusBarColor">@color/blue</item>
<!-- Window background - shows through statusBarsPadding() area --> <!-- Window background - shows through statusBarsPadding() area -->
@@ -8,9 +8,5 @@
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item> <item name="android:windowDrawsSystemBarBackgrounds">true</item>
<!-- Optional: Primary colors for the theme -->
<item name="colorPrimary">@color/blue</item>
<item name="colorPrimaryDark">@color/blue</item>
<item name="colorAccent">@color/blue</item>
</style> </style>
</resources> </resources>
+3 -3
View File
@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '9.2.1' apply false id 'com.android.application' version '9.4.0' apply false
id 'com.android.library' version '9.2.1' apply false id 'com.android.library' version '9.3.2' apply false
id 'org.jetbrains.kotlin.android' version '2.4.0' apply false id 'org.jetbrains.kotlin.android' version '2.4.10' apply false
} }
+1 -51
View File
@@ -1,10 +1,8 @@
#!/bin/node #!/bin/node
import {readFileSync,writeFileSync,existsSync} from 'fs' import {readFileSync,existsSync} from 'fs'
import area from '@turf/area' import area from '@turf/area'
import * as turf from '@turf/turf' import * as turf from '@turf/turf'
import * as path from 'path';
import { JSDOM } from 'jsdom';
const countries = const countries =
@@ -162,52 +160,4 @@ async function run(){
} }
} }
function fixSvg(svgPath) {
const countryToRegion = {};
for (const [region, countries] of Object.entries(groups)) {
countries.forEach(country => countryToRegion[country] = region);
}
const absoluteInputPath = path.resolve(svgPath);
if (!existsSync(absoluteInputPath)) {
throw new Error(`Input file not found at: ${absoluteInputPath}`);
}
const svgContent = readFileSync(absoluteInputPath, 'utf8');
const dom = new JSDOM(svgContent, { contentType: 'image/svg+xml' });
const document = dom.window.document;
const svgRoot = document.querySelector('svg');
if (!svgRoot) {
throw new Error("Invalid or empty SVG structure encountered.");
}
if (svgRoot.getAttribute('data-processed') === 'true') {
console.log(`Skipping: File at "${svgPath}" has already been processed.`);
return;
}
const elementGroups = Array.from(svgRoot.querySelectorAll('g'));
elementGroups.forEach(group => {
const currentId = group.getAttribute('id') || '';
const baseIsoCode = currentId.replace(/\d+$/, '');
const regionKey = countryToRegion[baseIsoCode] || 'XXXX';
let regionGroup = svgRoot.querySelector(`g[id="${regionKey}"]`);
if (!regionGroup) {
regionGroup = document.createElementNS('http://w3.org', 'g');
regionGroup.setAttribute('id', regionKey);
svgRoot.appendChild(regionGroup);
}
regionGroup.appendChild(group);
});
svgRoot.setAttribute('data-processed', 'true');
const absoluteOutputPath = path.resolve(svgPath);
let cleanXmlString = svgRoot.outerHTML;
cleanXmlString = cleanXmlString.replace(/[\r\n]+/g, '');
cleanXmlString = cleanXmlString.replace(/xmlns="http:\/\/w3\.org"\s?/g, '');
cleanXmlString = cleanXmlString.replace(/xmlns="http:\/\/www\.w3\.org\/2000\/svg"\s?/g, '');
cleanXmlString = cleanXmlString.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
writeFileSync(absoluteOutputPath, cleanXmlString, 'utf8');
}
run() run()
fixSvg("./app/src/main/assets/loxim01.svg")
fixSvg("./app/src/main/assets/webmercator01.svg")
fixSvg("./app/src/main/assets/aeqd01.svg")
+18 -57
View File
@@ -1,7 +1,5 @@
#!/bin/bash #!/bin/bash
LOCAL_SVG_PATH="app/src/main/assets/"
GADM_VERSION="4.1" GADM_VERSION="4.1"
GADM_BASEPATH="https://geodata.ucdavis.edu/gadm" GADM_BASEPATH="https://geodata.ucdavis.edu/gadm"
@@ -80,43 +78,7 @@ download_1() {
} }
toSVG_0() { generate_map() {
local input_files=("ATA")
for country in "${countries[@]}"
do
input_file="./temp/0/${country}.json"
if [ -f "$input_file" ]; then
input_files+=("$input_file")
else
echo "Input file $input_file not found."
fi
done
"$mapshaper" -i combine-files ${input_files[@]} -proj webmercator -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/mercator0.svg svg-data=GID_0,COUNTRY id-field=GID_0
"$mapshaper" -i combine-files ${input_files[@]} -proj aeqd +lat_0=90 -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/aeqd0.svg svg-data=GID_0,COUNTRY id-field=GID_0
}
toSVG_1() {
input_files=("ATA")
for country in "${countries[@]}"
do
input_file="./temp/1/${country}.json"
# input_file="./temp/1/gadm41_${country}_1.json"
if [ -f "$input_file" ]; then
input_files+=("$input_file")
else
echo "Input file $input_file not found."
fi
done
"$mapshaper" -i combine-files ${input_files[@]} -proj webmercator -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/mercator1.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
"$mapshaper" -i combine-files ${input_files[@]} -proj aeqd +lat_0=90 -simplify 0.005 weighted keep-shapes resolution=1200x1200 -o ./app/src/main/assets/aeqd1.svg svg-data=GID_0,COUNTRY,GID,NAME id-field=GID
}
generate_svg_map() {
local OUT_FILE="$1" # First argument: Output destination path local OUT_FILE="$1" # First argument: Output destination path
local PROJ_ARGS="$2" # Second argument: Projection parameters local PROJ_ARGS="$2" # Second argument: Projection parameters
shift 2 # Remove the first two arguments, leaving only the files shift 2 # Remove the first two arguments, leaving only the files
@@ -125,6 +87,9 @@ generate_svg_map() {
echo "Generating: $OUT_FILE using projection [$PROJ_ARGS]" echo "Generating: $OUT_FILE using projection [$PROJ_ARGS]"
echo "Processing ${#FILES_TO_RUN[@]} files..." echo "Processing ${#FILES_TO_RUN[@]} files..."
# Each feature is tagged with the place it belongs to and whether it is a
# country outline or one of its regions. packmap.js groups on that; nothing
# downstream needs the features split into layers.
local JS_PIPELINE_LOGIC=" local JS_PIPELINE_LOGIC="
const conflicts = { const conflicts = {
'Z01': 'IND', 'Z04': 'IND', 'Z05': 'IND', 'Z07': 'IND', 'Z09': 'IND', 'Z01': 'IND', 'Z04': 'IND', 'Z05': 'IND', 'Z07': 'IND', 'Z09': 'IND',
@@ -133,32 +98,27 @@ generate_svg_map() {
}; };
let rawCode = GID_0 || 'UNK'; let rawCode = GID_0 || 'UNK';
let cCode = conflicts[rawCode] ? conflicts[rawCode] : rawCode; let cCode = conflicts[rawCode] ? conflicts[rawCode] : rawCode;
// Antarctica's boundary file carries no country code at either level.
if (cCode === 'UNK') cCode = 'ATA';
let isGidMissing = (!GID || GID === 'undefined' || GID === 'null' || GID === ''); let isGidMissing = (!GID || GID === 'undefined' || GID === 'null' || GID === '');
if (isGidMissing) { CODE = isGidMissing ? cCode : GID;
COUNTRY_GROUP = cCode+'2'; LEVEL = isGidMissing ? 0 : 1;
} else {
COUNTRY_GROUP = cCode+'1';
}
" "
"$mapshaper" -i "${FILES_TO_RUN[@]}" combine-files \ "$mapshaper" -i "${FILES_TO_RUN[@]}" combine-files \
-snap \ -snap \
-merge-layers force\ -merge-layers force\
-proj $PROJ_ARGS densify \ -proj $PROJ_ARGS densify \
-simplify 2% weighted keep-shapes \ -simplify 10% weighted keep-shapes \
-filter-islands min-area=0 \ -filter-islands min-area=0 \
-sort 'this.area' ascending \ -sort 'this.area' ascending \
-each "$JS_PIPELINE_LOGIC" \ -each "$JS_PIPELINE_LOGIC" \
-split COUNTRY_GROUP \
-o "$OUT_FILE" \ -o "$OUT_FILE" \
format=svg \ format=geojson
id-field=GID \
precision=0.1 \
target=*
} }
toSVG_01() { build_maps() {
input_files=() input_files=()
input_files+=("./temp/1/ATA.json") input_files+=("./temp/1/ATA.json")
@@ -178,9 +138,12 @@ toSVG_01() {
fi fi
done done
generate_svg_map "./app/src/main/assets/loxim01.svg" "loxim" "${input_files[@]}" generate_map "./mapdata/loxim01.geojson" "loxim" "${input_files[@]}"
generate_svg_map "./app/src/main/assets/webmercator01.svg" "webmercator" "${input_files[@]}" generate_map "./mapdata/webmercator01.geojson" "webmercator" "${input_files[@]}"
generate_svg_map "./app/src/main/assets/aeqd01.svg" "aeqd +lat_0=90" "${input_files[@]}" generate_map "./mapdata/aeqd01.geojson" "aeqd +lat_0=90" "${input_files[@]}"
# The app reads the packed form; the GeoJSON above is only an intermediate.
node packmap.js ./mapdata/loxim01.geojson ./mapdata/webmercator01.geojson ./mapdata/aeqd01.geojson
} }
do_1() { do_1() {
@@ -199,8 +162,6 @@ do_0() {
} }
# do_0 # do_0
# do_1 # do_1
# toSVG_0 build_maps
# toSVG_1
toSVG_01
#CUW, CCK, XCL, CXR, IOT, BVT, ABW, FLK, GIB, HMD, KIR, SXM, MDV, MCO, NIU, NFK, PCN, MAF, SGS, VAT #CUW, CCK, XCL, CXR, IOT, BVT, ABW, FLK, GIB, HMD, KIR, SXM, MDV, MCO, NIU, NFK, PCN, MAF, SGS, VAT
+1 -2
View File
@@ -1,7 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500
Vendored
+2 -2
View File
@@ -20,7 +20,7 @@
############################################################################## ##############################################################################
# #
# Gradle start up script for POSIX generated by Gradle. # gradlew start up script for POSIX generated by Gradle.
# #
# Important for running: # Important for running:
# #
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh Gradle # ksh gradlew
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
Vendored
+2 -2
View File
@@ -19,7 +19,7 @@
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem gradlew startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2
@rem Execute Gradle @rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command @rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
-325
View File
@@ -1,325 +0,0 @@
var continents = {
XAD:"EEE",
ALA:"EEE",
ALB:"EEE",
AND:"EEE",
AUT:"EEE",
BLR:"EEE",
BEL:"EEE",
BIH:"EEE",
BGR:"EEE",
HRV:"EEE",
CYP:"EEE",
CZE:"EEE",
DNK:"EEE",
EST:"EEE",
FRO:"EEE",
FIN:"EEE",
FRA:"EEE",
DEU:"EEE",
GIB:"EEE",
GRC:"EEE",
GGY:"EEE",
HUN:"EEE",
ISL:"EEE",
IRL:"EEE",
IMN:"EEE",
ITA:"EEE",
JEY:"EEE",
KAZ:"EEE",
XKO:"EEE",
LVA:"EEE",
LIE:"EEE",
LTU:"EEE",
LUX:"EEE",
MLT:"EEE",
MDA:"EEE",
MCO:"EEE",
MNE:"EEE",
NLD:"EEE",
MKD:"EEE",
NOR:"EEE",
POL:"EEE",
PRT:"EEE",
ROU:"EEE",
RUS:"EEE",
SMR:"EEE",
SRB:"EEE",
SVK:"EEE",
SVN:"EEE",
ESP:"EEE",
SJM:"EEE",
SWE:"EEE",
CHE:"EEE",
UKR:"EEE",
GBR:"EEE",
VAT:"EEE",
AFG:"ABB",
ARM:"ABB",
AZE:"ABB",
BHR:"ABB",
BGD:"ABB",
BTN:"ABB",
IOT:"ABB",
BRN:"ABB",
KHM:"ABB",
CCK:"ABB",
CHN:"ABB",
CXR:"ABB",
GEO:"ABB",
HKG:"ABB",
IND:"ABB",
IDN:"ABB",
IRN:"ABB",
IRQ:"ABB",
ISR:"ABB",
JPN:"ABB",
JOR:"ABB",
KWT:"ABB",
KGZ:"ABB",
LAO:"ABB",
LBN:"ABB",
MAC:"ABB",
MYS:"ABB",
MDV:"ABB",
MNG:"ABB",
MMR:"ABB",
NPL:"ABB",
PRK:"ABB",
OMN:"ABB",
PAK:"ABB",
PSE:"ABB",
PHL:"ABB",
QAT:"ABB",
SAU:"ABB",
SGP:"ABB",
KOR:"ABB",
LKA:"ABB",
SYR:"ABB",
TWN:"ABB",
TJK:"ABB",
THA:"ABB",
TLS:"ABB",
TUR:"ABB",
TKM:"ABB",
ARE:"ABB",
UZB:"ABB",
VNM:"ABB",
YEM:"ABB",
ZNC:"ABB",
DZA:"FFF",
AGO:"FFF",
BDI:"FFF",
BEN:"FFF",
BWA:"FFF",
BFA:"FFF",
BDI:"FFF",
CPV:"FFF",
CMR:"FFF",
CAF:"FFF",
TCD:"FFF",
COM:"FFF",
COG:"FFF",
COD:"FFF",
CIV:"FFF",
DJI:"FFF",
EGY:"FFF",
GNQ:"FFF",
ERI:"FFF",
ETH:"FFF",
ATF:"FFF",
GAB:"FFF",
GMB:"FFF",
GHA:"FFF",
GIN:"FFF",
GNB:"FFF",
KEN:"FFF",
LSO:"FFF",
LBR:"FFF",
LBY:"FFF",
MDG:"FFF",
MWI:"FFF",
MLI:"FFF",
MRT:"FFF",
MUS:"FFF",
MYT:"FFF",
MAR:"FFF",
MOZ:"FFF",
NAM:"FFF",
NER:"FFF",
NGA:"FFF",
COD:"FFF",
REU:"FFF",
RWA:"FFF",
STP:"FFF",
SEN:"FFF",
SYC:"FFF",
SLE:"FFF",
SOM:"FFF",
ZAF:"FFF",
SSD:"FFF",
SHN:"FFF",
SDN:"FFF",
SWZ:"FFF",
TZA:"FFF",
TGO:"FFF",
TUN:"FFF",
UGA:"FFF",
COD:"FFF",
ZMB:"FFF",
ZWE:"FFF",
ESH:"FFF",
ABW:"NNN",
AIA:"NNN",
ATG:"NNN",
BHS:"NNN",
BRB:"NNN",
BLZ:"NNN",
BMU:"NNN",
BES:"NNN",
VGB:"NNN",
CAN:"NNN",
CYM:"NNN",
XCL:"NNN",
CRI:"NNN",
CUB:"NNN",
CUW:"NNN",
DMA:"NNN",
DOM:"NNN",
SLV:"NNN",
GRL:"NNN",
GRD:"NNN",
GLP:"NNN",
GTM:"NNN",
HTI:"NNN",
HND:"NNN",
JAM:"NNN",
MTQ:"NNN",
MEX:"NNN",
MSR:"NNN",
ANT:"NNN",
CUW:"NNN",
NIC:"NNN",
PAN:"NNN",
PRI:"NNN",
BLM:"NNN",
KNA:"NNN",
LCA:"NNN",
MAF:"NNN",
SPM:"NNN",
VCT:"NNN",
SXM:"NNN",
TTO:"NNN",
TCA:"NNN",
USA:"NNN",
UMI:"NNN",
VIR:"NNN",
ARG:"SRR",
BOL:"SRR",
BRA:"SRR",
CHL:"SRR",
COL:"SRR",
ECU:"SRR",
FLK:"SRR",
GUF:"SRR",
GUY:"SRR",
PRY:"SRR",
PER:"SRR",
SUR:"SRR",
URY:"SRR",
VEN:"SRR",
ASM:"UUU",
AUS:"UUU",
COK:"UUU",
FJI:"UUU",
PYF:"UUU",
GUM:"UUU",
KIR:"UUU",
MHL:"UUU",
FSM:"UUU",
NRU:"UUU",
NCL:"UUU",
NZL:"UUU",
NIU:"UUU",
NFK:"UUU",
MNP:"UUU",
PLW:"UUU",
PNG:"UUU",
PCN:"UUU",
WSM:"UUU",
SLB:"UUU",
TKL:"UUU",
TON:"UUU",
TUV:"UUU",
VUT:"UUU",
WLF:"UUU",
ATA:"XXX",
BVT:"XXX",
HMD:"XXX",
SGS:"XXX",
}
import fs from "fs"
import {JSDOM} from "jsdom"
function groupByContinent(svgFilePath) {
// Read the SVG file
fs.readFile(svgFilePath, 'utf-8', (err, data) => {
if (err) {
console.error('Error reading SVG file:', err);
return;
}
// Create a virtual DOM with JSDOM
const dom = new JSDOM(data);
const document = dom.window.document;
// Select the root <svg> element
const svgElement = document.querySelector('svg');
// Group <g> elements representing countries by continent
const world = {}; // Renamed continents object to 'world'
svgElement.querySelectorAll('g').forEach(countryGroup => {
const countryId = countryGroup.getAttribute('id');
if (countryId!= null){
const continentId = continents[countryId.replace(/\d/g, "")] || "XXX"; // Remove numbers from country ID to get continent ID
if (!world[continentId]) {
world[continentId] = document.createElementNS('http://www.w3.org/2000/svg', 'g');
world[continentId].setAttribute('id', continentId);
}
world[continentId].innerHTML += countryGroup.outerHTML;
}else{
console.log(countryId,countryGroup.outerHTML)
}
});
// Create new <g> elements for each continent
svgElement.innerHTML = '';
for (const continentId in world) {
svgElement.appendChild(world[continentId]);
}
// Output the modified SVG
fs.writeFile(svgFilePath, svgElement.outerHTML, 'utf-8', err => {
if (err) {
console.error('Error writing to SVG file:', err);
return;
}
console.log('SVG file updated successfully.');
});
});
}
// Example usage:
const svgFileBasePath = "./app/src/main/assets/" ;
groupByContinent(svgFileBasePath+ "webmercator01.svg");
groupByContinent(svgFileBasePath+ "aeqd01.svg");
groupByContinent(svgFileBasePath+ "loxim01.svg");
+3 -1
View File
@@ -1,8 +1,10 @@
{ {
"scripts": {
"pack": "node packmap.js mapdata/loxim01.geojson mapdata/webmercator01.geojson mapdata/aeqd01.geojson"
},
"dependencies": { "dependencies": {
"@turf/area": "^7.3.5", "@turf/area": "^7.3.5",
"@turf/turf": "^7.3.5", "@turf/turf": "^7.3.5",
"jsdom": "^29.1.1",
"mapshaper": "^0.7.22" "mapshaper": "^0.7.22"
}, },
"type": "module" "type": "module"
+232
View File
@@ -0,0 +1,232 @@
#!/bin/node
/**
* Packs the generated map data into the compact binary the app reads.
*
* The app used to ship SVG, which spends about four megabytes per projection
* spelling coordinates out in decimal ASCII inside XML, and needed the map
* parsed as a document at startup. Nothing needs a document: the app wants a
* list of places and the outlines that belong to them, which is what this
* writes.
*
* Input is mapshaper's projected GeoJSON, where each feature carries:
* CODE the place it belongs to, e.g. "FRA" or "FRA_CE"
* LEVEL 0 for a country outline, 1 for one of its regions
*
* Output format. All integers are LEB128 varints and coordinates are in grid
* steps, on a canvas MAP_WIDTH user units across:
*
* "BMAP" 0x02
* precision uvarint, grid steps per user unit
* width, height uvarint, in grid steps
* shapeCount uvarint
* per shape:
* codeLength, code uvarint + UTF-8 bytes
* flags 1 byte, bit 0 = is a region
* ringCount uvarint
* per ring:
* pointCount uvarint
* flags 1 byte, bit 0 = even-odd fill
* pointCount pairs of dx, dy zigzag varint, from the previous point,
* starting from the origin on each ring
*/
import { readFileSync, writeFileSync } from 'fs'
/** Canvas width in user units. Stroke widths in the app are relative to it. */
const MAP_WIDTH = 800
/** Blank edge left around the map, as mapshaper's own SVG output used to. */
const MARGIN = 1
/**
* Grid steps per user unit. Coordinates are snapped to this, so it sets how
* fine the map can be; it is written into the header rather than agreed by
* convention, so raising it only means regenerating the assets.
*/
const PRECISION = 20
function uvarint(bytes, value) {
while (value >= 0x80) {
bytes.push((value & 0x7f) | 0x80)
value >>>= 7
}
bytes.push(value)
}
function svarint(bytes, value) {
uvarint(bytes, value < 0 ? -value * 2 - 1 : value * 2)
}
/** Every polygon of a feature, outer rings and holes alike. */
function ringsOf(geometry) {
if (!geometry) return []
if (geometry.type === 'Polygon') return [geometry.coordinates]
if (geometry.type === 'MultiPolygon') return geometry.coordinates
return []
}
/**
* Groups features by the place they belong to. A country outline and its
* regions arrive as separate features; the code is what ties them together.
*/
function collect(features) {
const shapes = []
const byCode = new Map()
for (const feature of features) {
const code = feature.properties?.CODE
if (!code) continue
const polygons = ringsOf(feature.geometry)
if (polygons.length === 0) continue
let shape = byCode.get(code)
if (!shape) {
shape = { code, isState: feature.properties.LEVEL === 1, rings: [], seen: new Set() }
byCode.set(code, shape)
shapes.push(shape)
}
// A polygon with holes only reads correctly under an even-odd fill, which
// is the rule mapshaper's SVG output used to pick for the same reason.
const evenOdd = polygons.some((polygon) => polygon.length > 1)
for (const polygon of polygons) {
for (const ring of polygon) {
if (ring.length >= 3) shape.rings.push({ ring, evenOdd })
}
}
}
return shapes
}
/** Twice the signed area of a closed ring of `x, y` pairs. */
function shoelace(points) {
let sum = 0
const count = points.length / 2
for (let i = 0, j = count - 1; i < count; j = i++) {
sum += points[j * 2] * points[i * 2 + 1] - points[i * 2] * points[j * 2 + 1]
}
return sum
}
/**
* Snaps every ring to the output grid and throws away what that makes
* redundant: points that land on the one before them, and rings left too small
* to enclose anything. The simplification upstream works to about this
* precision, so a good share of the raw points say nothing once rounded.
*/
function quantize(shapes, canvas) {
const kept = []
for (const shape of shapes) {
const seen = new Set()
const rings = []
for (const { ring, evenOdd } of shape.rings) {
const points = []
let px = null
let py = null
for (const [rx, ry] of ring) {
const x = canvas.x(rx)
const y = canvas.y(ry)
if (x === px && y === py) continue
points.push(x, y)
px = x
py = y
}
if (points.length < 6) continue
// Coordinates are integers now, so any ring that still encloses area
// encloses at least half a grid square. One that comes out at exactly
// zero is a sliver the rounding flattened, and it would draw nothing.
if (shoelace(points) === 0) continue
// Antarctica arrives twice over, once per admin level, because its source
// carries no country code and both levels land on ATA. A ring counted
// twice cancels itself out in the even-odd test the app hit tests with,
// which would leave the shape impossible to tap.
const key = points.join(',')
if (seen.has(key)) continue
seen.add(key)
rings.push({ points, evenOdd })
}
if (rings.length > 0) kept.push({ code: shape.code, isState: shape.isState, rings })
}
// By code, which puts a country next to its own regions. They are neighbours
// on the map, so their coordinates are similar, and the compressor in the APK
// gets noticeably more out of the file for it.
return kept.sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0))
}
/** Projected metres in, user units on a MAP_WIDTH canvas out, y flipped. */
function fitToCanvas(shapes) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const shape of shapes) {
for (const { ring } of shape.rings) {
for (const [x, y] of ring) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
}
const scale = (MAP_WIDTH - 2 * MARGIN) / (maxX - minX)
return {
width: MAP_WIDTH,
height: 2 * MARGIN + (maxY - minY) * scale,
x: (v) => Math.round((MARGIN + (v - minX) * scale) * PRECISION),
y: (v) => Math.round((MARGIN + (maxY - v) * scale) * PRECISION),
}
}
function pack(path) {
const geojson = JSON.parse(readFileSync(path, 'utf8'))
const collected = collect(geojson.features ?? [])
const canvas = fitToCanvas(collected)
const shapes = quantize(collected, canvas)
const bytes = [0x42, 0x4d, 0x41, 0x50, 0x02] // "BMAP" v2
uvarint(bytes, PRECISION)
uvarint(bytes, Math.round(canvas.width * PRECISION))
uvarint(bytes, Math.round(canvas.height * PRECISION))
uvarint(bytes, shapes.length)
let rings = 0
let points = 0
for (const shape of shapes) {
const code = Buffer.from(shape.code, 'utf8')
uvarint(bytes, code.length)
for (const byte of code) bytes.push(byte)
bytes.push(shape.isState ? 1 : 0)
uvarint(bytes, shape.rings.length)
for (const { points: ring, evenOdd } of shape.rings) {
uvarint(bytes, ring.length / 2)
bytes.push(evenOdd ? 1 : 0)
let px = 0
let py = 0
for (let i = 0; i < ring.length; i += 2) {
svarint(bytes, ring[i] - px)
svarint(bytes, ring[i + 1] - py)
px = ring[i]
py = ring[i + 1]
}
rings++
points += ring.length / 2
}
}
const out = path.replace(/^.*\//, './app/src/main/assets/').replace(/\.geojson$/, '.bmap')
writeFileSync(out, Buffer.from(bytes))
console.log(
`${out}: ${shapes.length} shapes, ${rings} rings, ${points} points, ` +
`${canvas.width}x${canvas.height.toFixed(1)}, ${bytes.length} bytes`,
)
}
const inputs = process.argv.slice(2)
if (inputs.length === 0) {
console.error('usage: node packmap.js <map>.geojson ...')
process.exit(1)
}
inputs.forEach(pack)
+3246 -4569
View File
File diff suppressed because it is too large Load Diff