feat(map): every country on earth, picked continent first
Map questions can now be on the world's countries besides Japan's prefectures: a new question chooses its map above it. The world is Natural Earth's 1:50m countries (public domain), as they are on the ground, one region per ISO 3166-1 code (239; Gibraltar and the US Outlying Islands from the 1:10m layer, which has them), named in every language a studyset can be in from the Unicode CLDR (Unicode License v3, its notice in the licence list). tool/maps/world_countries.py builds it; the outline code it shares with the Japan script moved to outlines.py, which still writes Japan's file byte for byte. The 1.8 MB file is parsed off the screen's isolate. Picking goes in layers on both maps: on the whole map one taps a group (a continent, or Kantō, Kansai …; chips name them too) and the map flies there, then one taps the region; Back returns. A group of one (Hokkaidō, Okinawa) and what is in none (Antarctica) are picked at once. A country in two groups (Russia, Türkiye, Cyprus) opens the one nearer the tap. Oceania's frame runs past 180° so the Pacific stays in one piece. What is still too small to hit gets a ring, 48 dp of reach as Material's touch target: on the ring it is picked; near it, where the tap could mean it or its neighbours, the map zooms in rather than guess. Pinching and a mouse's wheel zoom as before. Names show in the app's language, beside them the map's own (Japanese for Japan) or the studyset's (キプロス beside Cyprus in a Japanese studyset). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
25e73c8c80
commit
e5e0de62b3
26 changed files with 2289 additions and 283 deletions
|
|
@ -1,18 +1,34 @@
|
||||||
# Map data
|
# Map data
|
||||||
|
|
||||||
`japan_prefectures.geojson` derives from **Natural Earth**
|
Both maps derive from **Natural Earth** (<https://www.naturalearthdata.com/>),
|
||||||
(<https://www.naturalearthdata.com/>), *Admin 1 – States, Provinces*, 1:10m
|
release v5.1.2. Natural Earth's map data is in the public domain:
|
||||||
cultural vectors, release v5.1.2. Natural Earth's map data is in the public
|
|
||||||
domain:
|
|
||||||
|
|
||||||
> All versions of Natural Earth raster and vector map data found on this
|
> All versions of Natural Earth raster and vector map data found on this
|
||||||
> website are in the public domain. You may use the maps in any manner,
|
> website are in the public domain. You may use the maps in any manner,
|
||||||
> including modifying the content and design, electronic dissemination, and
|
> including modifying the content and design, electronic dissemination, and
|
||||||
> offset printing.
|
> offset printing.
|
||||||
|
|
||||||
`tool/maps/japan_prefectures.py` builds the file: it keeps Japan's 47
|
## Japan's prefectures
|
||||||
prefectures, simplifies their outlines (Douglas–Peucker, 0.006°), drops islands
|
|
||||||
too small to see (each prefecture keeps its largest), and rounds coordinates to
|
`japan_prefectures.geojson` derives from *Admin 1 – States, Provinces*, 1:10m
|
||||||
4 decimals. The outlines are an approximation for display, not survey-accurate
|
cultural vectors. `tool/maps/japan_prefectures.py` builds the file: it keeps
|
||||||
data. Regions are named by their ISO 3166-2 codes (`JP-01` … `JP-47`), with
|
Japan's 47 prefectures, simplifies their outlines (Douglas–Peucker, 0.006°),
|
||||||
Natural Earth's English and Japanese names.
|
drops islands too small to see (each prefecture keeps its largest), and rounds
|
||||||
|
coordinates to 4 decimals. The outlines are an approximation for display, not
|
||||||
|
survey-accurate data. Regions are named by their ISO 3166-2 codes (`JP-01` …
|
||||||
|
`JP-47`), with Natural Earth's English and Japanese names.
|
||||||
|
|
||||||
|
## Countries of the world
|
||||||
|
|
||||||
|
`world_countries.geojson` derives from *Admin 0 – Countries*, 1:50m cultural
|
||||||
|
vectors (Gibraltar and the US Outlying Islands, which it leaves out, from the
|
||||||
|
1:10m layer). `tool/maps/world_countries.py` builds the file: one region per
|
||||||
|
ISO 3166-1 code (239), areas without a code of their own joined to the country
|
||||||
|
ISO counts them under, outlines kept as Natural Earth generalised them (islands
|
||||||
|
under 0.001 square degrees dropped), coordinates rounded to 3 decimals. Borders
|
||||||
|
are drawn as Natural Earth draws them, as they are on the ground.
|
||||||
|
|
||||||
|
The countries' names come from the **Unicode Common Locale Data Repository**
|
||||||
|
(CLDR, <https://cldr.unicode.org/>), release 48.2.3, in every language a
|
||||||
|
studyset can be in. CLDR is under the Unicode License v3, whose notice is in
|
||||||
|
`LICENSE-CLDR.txt` and in the app's licence list.
|
||||||
|
|
|
||||||
41
halkyon_learn_flutter/assets/maps/LICENSE-CLDR.txt
Normal file
41
halkyon_learn_flutter/assets/maps/LICENSE-CLDR.txt
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
UNICODE LICENSE V3
|
||||||
|
|
||||||
|
COPYRIGHT AND PERMISSION NOTICE
|
||||||
|
|
||||||
|
Copyright © 2015-2024 Unicode, Inc.
|
||||||
|
|
||||||
|
NOTICE TO USER: Carefully read the following legal agreement. BY
|
||||||
|
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
|
||||||
|
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
|
||||||
|
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
|
||||||
|
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
copy of data files and any associated documentation (the "Data Files") or
|
||||||
|
software and any associated documentation (the "Software") to deal in the
|
||||||
|
Data Files or Software without restriction, including without limitation
|
||||||
|
the rights to use, copy, modify, merge, publish, distribute, and/or sell
|
||||||
|
copies of the Data Files or Software, and to permit persons to whom the
|
||||||
|
Data Files or Software are furnished to do so, provided that either (a)
|
||||||
|
this copyright and permission notice appear with all copies of the Data
|
||||||
|
Files or Software, or (b) this copyright and permission notice appear in
|
||||||
|
associated Documentation.
|
||||||
|
|
||||||
|
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
||||||
|
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||||
|
THIRD PARTY RIGHTS.
|
||||||
|
|
||||||
|
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
|
||||||
|
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
|
||||||
|
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||||
|
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
|
||||||
|
FILES OR SOFTWARE.
|
||||||
|
|
||||||
|
Except as contained in this notice, the name of a copyright holder shall
|
||||||
|
not be used in advertising or otherwise to promote the sale, use or other
|
||||||
|
dealings in these Data Files or Software without prior written
|
||||||
|
authorization of the copyright holder.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: Unicode-3.0
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -923,8 +923,32 @@
|
||||||
"studysetResetDone": "Fortschritt zurückgesetzt",
|
"studysetResetDone": "Fortschritt zurückgesetzt",
|
||||||
|
|
||||||
"mapUnknown": "Diese Landkarte braucht eine neuere Version der App.",
|
"mapUnknown": "Diese Landkarte braucht eine neuere Version der App.",
|
||||||
"mapPickRegion": "Tippe auf die Region, die die Frage beantwortet; zoome mit zwei Fingern oder durch Scrollen.",
|
"mapPickRegion": "Wähle einen Teil der Karte, dann tippe auf die Region, die die Frage beantwortet. Winzige Orte haben einen Ring; zoome mit zwei Fingern oder durch Scrollen.",
|
||||||
"mapNonePicked": "Noch keine Region gewählt",
|
"mapNonePicked": "Noch keine Region gewählt",
|
||||||
|
"composerLabelMap": "Karte",
|
||||||
|
"mapChoiceJapan": "Japans Präfekturen",
|
||||||
|
"mapChoiceWorld": "Länder der Welt",
|
||||||
|
"mapNameJapan": "Japan",
|
||||||
|
"mapNameWorld": "Welt",
|
||||||
|
"mapGroupHokkaido": "Hokkaidō",
|
||||||
|
"mapGroupTohoku": "Tōhoku",
|
||||||
|
"mapGroupKanto": "Kantō",
|
||||||
|
"mapGroupChubu": "Chūbu",
|
||||||
|
"mapGroupKansai": "Kansai",
|
||||||
|
"mapGroupChugoku": "Chūgoku",
|
||||||
|
"mapGroupShikoku": "Shikoku",
|
||||||
|
"mapGroupKyushu": "Kyūshū",
|
||||||
|
"mapGroupOkinawa": "Okinawa",
|
||||||
|
"mapGroupEurope": "Europa",
|
||||||
|
"mapGroupAfrica": "Afrika",
|
||||||
|
"mapGroupWestAsia": "Westasien",
|
||||||
|
"mapGroupSouthAsia": "Zentral- und Südasien",
|
||||||
|
"mapGroupEastAsia": "Ostasien",
|
||||||
|
"mapGroupSoutheastAsia": "Südostasien",
|
||||||
|
"mapGroupNorthAmerica": "Nordamerika",
|
||||||
|
"mapGroupCentralAmerica": "Mittelamerika und Karibik",
|
||||||
|
"mapGroupSouthAmerica": "Südamerika",
|
||||||
|
"mapGroupOceania": "Ozeanien",
|
||||||
"composerPlaceholderQuestion": "Frage hinzufügen",
|
"composerPlaceholderQuestion": "Frage hinzufügen",
|
||||||
"composerLabelRegion": "Antwort",
|
"composerLabelRegion": "Antwort",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1028,8 +1028,32 @@
|
||||||
"studysetResetDone": "Progress reset",
|
"studysetResetDone": "Progress reset",
|
||||||
|
|
||||||
"mapUnknown": "This map needs a newer version of the app.",
|
"mapUnknown": "This map needs a newer version of the app.",
|
||||||
"mapPickRegion": "Tap the region that answers the question; pinch or scroll to zoom.",
|
"mapPickRegion": "Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; pinch or scroll to zoom.",
|
||||||
"mapNonePicked": "No region picked yet",
|
"mapNonePicked": "No region picked yet",
|
||||||
|
"composerLabelMap": "Map",
|
||||||
|
"mapChoiceJapan": "Japan's prefectures",
|
||||||
|
"mapChoiceWorld": "Countries of the world",
|
||||||
|
"mapNameJapan": "Japan",
|
||||||
|
"mapNameWorld": "World",
|
||||||
|
"mapGroupHokkaido": "Hokkaidō",
|
||||||
|
"mapGroupTohoku": "Tōhoku",
|
||||||
|
"mapGroupKanto": "Kantō",
|
||||||
|
"mapGroupChubu": "Chūbu",
|
||||||
|
"mapGroupKansai": "Kansai",
|
||||||
|
"mapGroupChugoku": "Chūgoku",
|
||||||
|
"mapGroupShikoku": "Shikoku",
|
||||||
|
"mapGroupKyushu": "Kyūshū",
|
||||||
|
"mapGroupOkinawa": "Okinawa",
|
||||||
|
"mapGroupEurope": "Europe",
|
||||||
|
"mapGroupAfrica": "Africa",
|
||||||
|
"mapGroupWestAsia": "Western Asia",
|
||||||
|
"mapGroupSouthAsia": "Central and South Asia",
|
||||||
|
"mapGroupEastAsia": "East Asia",
|
||||||
|
"mapGroupSoutheastAsia": "Southeast Asia",
|
||||||
|
"mapGroupNorthAmerica": "North America",
|
||||||
|
"mapGroupCentralAmerica": "Central America and the Caribbean",
|
||||||
|
"mapGroupSouthAmerica": "South America",
|
||||||
|
"mapGroupOceania": "Oceania",
|
||||||
"composerPlaceholderQuestion": "Add question",
|
"composerPlaceholderQuestion": "Add question",
|
||||||
"composerLabelRegion": "Answer",
|
"composerLabelRegion": "Answer",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5449,7 +5449,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @mapPickRegion.
|
/// No description provided for @mapPickRegion.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Tap the region that answers the question; pinch or scroll to zoom.'**
|
/// **'Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; pinch or scroll to zoom.'**
|
||||||
String get mapPickRegion;
|
String get mapPickRegion;
|
||||||
|
|
||||||
/// No description provided for @mapNonePicked.
|
/// No description provided for @mapNonePicked.
|
||||||
|
|
@ -5458,6 +5458,150 @@ abstract class AppLocalizations {
|
||||||
/// **'No region picked yet'**
|
/// **'No region picked yet'**
|
||||||
String get mapNonePicked;
|
String get mapNonePicked;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelMap.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Map'**
|
||||||
|
String get composerLabelMap;
|
||||||
|
|
||||||
|
/// No description provided for @mapChoiceJapan.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Japan\'s prefectures'**
|
||||||
|
String get mapChoiceJapan;
|
||||||
|
|
||||||
|
/// No description provided for @mapChoiceWorld.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Countries of the world'**
|
||||||
|
String get mapChoiceWorld;
|
||||||
|
|
||||||
|
/// No description provided for @mapNameJapan.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Japan'**
|
||||||
|
String get mapNameJapan;
|
||||||
|
|
||||||
|
/// No description provided for @mapNameWorld.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'World'**
|
||||||
|
String get mapNameWorld;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupHokkaido.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hokkaidō'**
|
||||||
|
String get mapGroupHokkaido;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupTohoku.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tōhoku'**
|
||||||
|
String get mapGroupTohoku;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupKanto.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Kantō'**
|
||||||
|
String get mapGroupKanto;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupChubu.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Chūbu'**
|
||||||
|
String get mapGroupChubu;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupKansai.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Kansai'**
|
||||||
|
String get mapGroupKansai;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupChugoku.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Chūgoku'**
|
||||||
|
String get mapGroupChugoku;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupShikoku.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shikoku'**
|
||||||
|
String get mapGroupShikoku;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupKyushu.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Kyūshū'**
|
||||||
|
String get mapGroupKyushu;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupOkinawa.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Okinawa'**
|
||||||
|
String get mapGroupOkinawa;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupEurope.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Europe'**
|
||||||
|
String get mapGroupEurope;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupAfrica.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Africa'**
|
||||||
|
String get mapGroupAfrica;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupWestAsia.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Western Asia'**
|
||||||
|
String get mapGroupWestAsia;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupSouthAsia.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Central and South Asia'**
|
||||||
|
String get mapGroupSouthAsia;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupEastAsia.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'East Asia'**
|
||||||
|
String get mapGroupEastAsia;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupSoutheastAsia.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Southeast Asia'**
|
||||||
|
String get mapGroupSoutheastAsia;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupNorthAmerica.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'North America'**
|
||||||
|
String get mapGroupNorthAmerica;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupCentralAmerica.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Central America and the Caribbean'**
|
||||||
|
String get mapGroupCentralAmerica;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupSouthAmerica.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'South America'**
|
||||||
|
String get mapGroupSouthAmerica;
|
||||||
|
|
||||||
|
/// No description provided for @mapGroupOceania.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Oceania'**
|
||||||
|
String get mapGroupOceania;
|
||||||
|
|
||||||
/// No description provided for @composerPlaceholderQuestion.
|
/// No description provided for @composerPlaceholderQuestion.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -3414,11 +3414,83 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapPickRegion =>
|
String get mapPickRegion =>
|
||||||
'Tippe auf die Region, die die Frage beantwortet; zoome mit zwei Fingern oder durch Scrollen.';
|
'Wähle einen Teil der Karte, dann tippe auf die Region, die die Frage beantwortet. Winzige Orte haben einen Ring; zoome mit zwei Fingern oder durch Scrollen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapNonePicked => 'Noch keine Region gewählt';
|
String get mapNonePicked => 'Noch keine Region gewählt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelMap => 'Karte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapChoiceJapan => 'Japans Präfekturen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapChoiceWorld => 'Länder der Welt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapNameJapan => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapNameWorld => 'Welt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupHokkaido => 'Hokkaidō';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupTohoku => 'Tōhoku';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupKanto => 'Kantō';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupChubu => 'Chūbu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupKansai => 'Kansai';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupChugoku => 'Chūgoku';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupShikoku => 'Shikoku';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupKyushu => 'Kyūshū';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupOkinawa => 'Okinawa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupEurope => 'Europa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupAfrica => 'Afrika';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupWestAsia => 'Westasien';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupSouthAsia => 'Zentral- und Südasien';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupEastAsia => 'Ostasien';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupSoutheastAsia => 'Südostasien';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupNorthAmerica => 'Nordamerika';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupCentralAmerica => 'Mittelamerika und Karibik';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupSouthAmerica => 'Südamerika';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupOceania => 'Ozeanien';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerPlaceholderQuestion => 'Frage hinzufügen';
|
String get composerPlaceholderQuestion => 'Frage hinzufügen';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3388,11 +3388,83 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapPickRegion =>
|
String get mapPickRegion =>
|
||||||
'Tap the region that answers the question; pinch or scroll to zoom.';
|
'Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; pinch or scroll to zoom.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapNonePicked => 'No region picked yet';
|
String get mapNonePicked => 'No region picked yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelMap => 'Map';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapChoiceJapan => 'Japan\'s prefectures';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapChoiceWorld => 'Countries of the world';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapNameJapan => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapNameWorld => 'World';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupHokkaido => 'Hokkaidō';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupTohoku => 'Tōhoku';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupKanto => 'Kantō';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupChubu => 'Chūbu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupKansai => 'Kansai';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupChugoku => 'Chūgoku';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupShikoku => 'Shikoku';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupKyushu => 'Kyūshū';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupOkinawa => 'Okinawa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupEurope => 'Europe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupAfrica => 'Africa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupWestAsia => 'Western Asia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupSouthAsia => 'Central and South Asia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupEastAsia => 'East Asia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupSoutheastAsia => 'Southeast Asia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupNorthAmerica => 'North America';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupCentralAmerica => 'Central America and the Caribbean';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupSouthAmerica => 'South America';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapGroupOceania => 'Oceania';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerPlaceholderQuestion => 'Add question';
|
String get composerPlaceholderQuestion => 'Add question';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/core/instance/instance_choice.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/instance/instance_store.dart';
|
import 'package:halkyon_learn_flutter/src/core/instance/instance_store.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/app_fonts.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/app_fonts.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/cldr_license.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/kanji/data/kanjivg_license.dart';
|
import 'package:halkyon_learn_flutter/src/feature/kanji/data/kanjivg_license.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -21,6 +22,7 @@ Future<void> main() async {
|
||||||
Bloc.observer = const AppBlocObserver();
|
Bloc.observer = const AppBlocObserver();
|
||||||
registerAppFontLicenses();
|
registerAppFontLicenses();
|
||||||
registerKanjiVgLicense();
|
registerKanjiVgLicense();
|
||||||
|
registerCldrLicense();
|
||||||
// The instance the build names (`--dart-define=SERVER_URL=…`, or
|
// The instance the build names (`--dart-define=SERVER_URL=…`, or
|
||||||
// `assets/config.json`): the first offered, and the one installs from
|
// `assets/config.json`): the first offered, and the one installs from
|
||||||
// before the choice go on with.
|
// before the choice go on with.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repositor
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_names.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||||
|
|
@ -493,13 +494,18 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
_sent.copyWith(
|
_sent.copyWith(
|
||||||
answers: [
|
answers: [
|
||||||
CardAnswer(
|
CardAnswer(
|
||||||
content: RichContent.plain(region.name),
|
content: RichContent.plain(
|
||||||
|
regionName(context, region),
|
||||||
|
),
|
||||||
correct: true,
|
correct: true,
|
||||||
region: region.code,
|
region: region.code,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// A region of one map is none of another's.
|
||||||
|
onMapChosen: (map) =>
|
||||||
|
_change(_sent.copyWith(map: map, answers: [])),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
if (content.explanation case final explanation?) ...[
|
if (content.explanation case final explanation?) ...[
|
||||||
|
|
@ -564,19 +570,41 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A map question's answer: the region to point at, picked on the map.
|
/// A map question's answer: the map, Japan's prefectures or the world's
|
||||||
|
/// countries, and the region to point at, picked on it.
|
||||||
class const _MapAnswer({
|
class const _MapAnswer({
|
||||||
required final CardContent content,
|
required final CardContent content,
|
||||||
required final ValueChanged<MapRegion> onPicked,
|
required final ValueChanged<MapRegion> onPicked,
|
||||||
|
required final ValueChanged<String> onMapChosen,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final picked = content.answers.firstOrNull?.region;
|
final picked = content.answers.firstOrNull?.region;
|
||||||
|
final mapId = content.map ?? defaultMapId;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
Text(l10n.composerLabelMap, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<String>(
|
||||||
|
segments: [
|
||||||
|
for (final id in bundledMaps.keys)
|
||||||
|
ButtonSegment(
|
||||||
|
value: id,
|
||||||
|
label: Text(switch (id) {
|
||||||
|
'japan' => l10n.mapChoiceJapan,
|
||||||
|
'world' => l10n.mapChoiceWorld,
|
||||||
|
_ => id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {mapId},
|
||||||
|
showSelectedIcon: false,
|
||||||
|
onSelectionChanged: (chosen) => onMapChosen(chosen.single),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(l10n.composerLabelRegion, style: theme.textTheme.labelLarge),
|
Text(l10n.composerLabelRegion, style: theme.textTheme.labelLarge),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -587,7 +615,7 @@ class const _MapAnswer({
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
MapOf(
|
MapOf(
|
||||||
mapId: content.map ?? defaultMapId,
|
mapId: mapId,
|
||||||
builder: (context, map) => Column(
|
builder: (context, map) => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -599,10 +627,14 @@ class const _MapAnswer({
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
switch (map.region(picked)) {
|
switch (map.region(picked)) {
|
||||||
final region? =>
|
final region? => switch (regionOtherName(
|
||||||
region.localName == null
|
context,
|
||||||
? region.name
|
map,
|
||||||
: '${region.name} · ${region.localName}',
|
region,
|
||||||
|
)) {
|
||||||
|
final other? => '${regionName(context, region)} · $other',
|
||||||
|
null => regionName(context, region),
|
||||||
|
},
|
||||||
null => l10n.mapNonePicked,
|
null => l10n.mapNonePicked,
|
||||||
},
|
},
|
||||||
style: theme.textTheme.titleSmall,
|
style: theme.textTheme.titleSmall,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Adds the Unicode CLDR's notice to the app's licence list, beside the
|
||||||
|
/// packages' own: the world map's country names come from it, and its
|
||||||
|
/// licence asks that its notice go with them.
|
||||||
|
void registerCldrLicense() => LicenseRegistry.addLicense(() async* {
|
||||||
|
yield LicenseEntryWithLineBreaks(const [
|
||||||
|
'Unicode CLDR',
|
||||||
|
], await rootBundle.loadString('assets/maps/LICENSE-CLDR.txt'));
|
||||||
|
});
|
||||||
|
|
@ -2,17 +2,36 @@ import 'dart:convert';
|
||||||
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
|
||||||
/// Reads the map [id] from GeoJSON as the app's map script writes it: a
|
/// Reads the map [id] from GeoJSON as the app's map scripts write it: a
|
||||||
/// feature per region, with `code`, `name` and `name_ja`, and a polygon or
|
/// feature per region, with its `code`, its names (`names` by language, or
|
||||||
/// several.
|
/// `name` in English and `name_ja`), perhaps a `label` point, and a polygon
|
||||||
|
/// or several; a feature without a code is land no region holds. The map
|
||||||
|
/// is divided into [groups] and has [language] as its own, if any.
|
||||||
///
|
///
|
||||||
/// Throws a [FormatException] for anything else.
|
/// Throws a [FormatException] for anything else.
|
||||||
GeoMap parseGeoMap(String id, String geojson) {
|
GeoMap parseGeoMap(
|
||||||
|
String id,
|
||||||
|
String geojson, {
|
||||||
|
List<MapGroup> groups = const [],
|
||||||
|
String? language,
|
||||||
|
}) {
|
||||||
final json = jsonDecode(geojson);
|
final json = jsonDecode(geojson);
|
||||||
if (json case {'features': final List<Object?> features}) {
|
if (json case {'features': final List<Object?> features}) {
|
||||||
|
final regions = <MapRegion>[];
|
||||||
|
final land = <Polygon>[];
|
||||||
|
for (final feature in features) {
|
||||||
|
if (feature case {'properties': {'code': _}}) {
|
||||||
|
regions.add(_region(feature));
|
||||||
|
} else {
|
||||||
|
land.addAll(_polygons(feature));
|
||||||
|
}
|
||||||
|
}
|
||||||
return GeoMap(
|
return GeoMap(
|
||||||
id: id,
|
id: id,
|
||||||
regions: [for (final feature in features) _region(feature)],
|
regions: regions,
|
||||||
|
land: land,
|
||||||
|
groups: groups,
|
||||||
|
language: language,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw const FormatException('Not a map.');
|
throw const FormatException('Not a map.');
|
||||||
|
|
@ -20,30 +39,47 @@ GeoMap parseGeoMap(String id, String geojson) {
|
||||||
|
|
||||||
MapRegion _region(Object? feature) {
|
MapRegion _region(Object? feature) {
|
||||||
if (feature case {
|
if (feature case {
|
||||||
'properties': {'code': final String code, 'name': final String name},
|
'properties':
|
||||||
|
{'code': final String code} && final Map<Object?, Object?> properties,
|
||||||
|
}) {
|
||||||
|
return MapRegion(
|
||||||
|
code: code,
|
||||||
|
names: switch (properties) {
|
||||||
|
{'names': final Map<Object?, Object?> names} => {
|
||||||
|
for (final MapEntry(:key, :value) in names.entries)
|
||||||
|
if ((key, value) case (final String tag, final String name))
|
||||||
|
tag: name,
|
||||||
|
},
|
||||||
|
{'name': final String name} => {
|
||||||
|
'en': name,
|
||||||
|
if (properties['name_ja'] case final String japanese) 'ja': japanese,
|
||||||
|
},
|
||||||
|
_ => throw FormatException('The region $code has no name.'),
|
||||||
|
},
|
||||||
|
label: switch (properties['label']) {
|
||||||
|
[final num lon, final num lat] => (lon.toDouble(), lat.toDouble()),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
polygons: _polygons(feature),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw const FormatException('Not a region.');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Polygon> _polygons(Object? feature) {
|
||||||
|
if (feature case {
|
||||||
'geometry': {
|
'geometry': {
|
||||||
'type': final String type,
|
'type': final String type,
|
||||||
'coordinates': final List<Object?> coordinates,
|
'coordinates': final List<Object?> coordinates,
|
||||||
},
|
},
|
||||||
}) {
|
}) {
|
||||||
final localName = switch (feature) {
|
return switch (type) {
|
||||||
{'properties': {'name_ja': final String localName}} => localName,
|
'Polygon' => [_polygon(coordinates)],
|
||||||
_ => null,
|
'MultiPolygon' => [for (final polygon in coordinates) _polygon(polygon)],
|
||||||
|
_ => throw FormatException('An area of the unknown kind $type.'),
|
||||||
};
|
};
|
||||||
return MapRegion(
|
|
||||||
code: code,
|
|
||||||
name: name,
|
|
||||||
localName: localName,
|
|
||||||
polygons: switch (type) {
|
|
||||||
'Polygon' => [_polygon(coordinates)],
|
|
||||||
'MultiPolygon' => [
|
|
||||||
for (final polygon in coordinates) _polygon(polygon),
|
|
||||||
],
|
|
||||||
_ => throw FormatException('A region of the unknown kind $type.'),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw const FormatException('Not a region.');
|
throw const FormatException('Not an area.');
|
||||||
}
|
}
|
||||||
|
|
||||||
Polygon _polygon(Object? json) => switch (json) {
|
Polygon _polygon(Object? json) => switch (json) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,27 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_parser.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_parser.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/map_groups.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
|
||||||
/// The maps the app bundles, by the id cards name them with: their asset.
|
/// A map the app bundles: its asset, the groups it is picked from first,
|
||||||
const bundledMaps = {'japan': 'assets/maps/japan_prefectures.geojson'};
|
/// and its own language, if any.
|
||||||
|
typedef BundledMap = ({String asset, List<MapGroup> groups, String? language});
|
||||||
|
|
||||||
|
/// The maps the app bundles, by the id cards name them with, in the order a
|
||||||
|
/// new map question offers them.
|
||||||
|
const bundledMaps = <String, BundledMap>{
|
||||||
|
'japan': (
|
||||||
|
asset: 'assets/maps/japan_prefectures.geojson',
|
||||||
|
groups: japanGroups,
|
||||||
|
language: 'ja',
|
||||||
|
),
|
||||||
|
'world': (
|
||||||
|
asset: 'assets/maps/world_countries.geojson',
|
||||||
|
groups: worldGroups,
|
||||||
|
language: null,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
/// The map a new map question is drawn on.
|
/// The map a new map question is drawn on.
|
||||||
const defaultMapId = 'japan';
|
const defaultMapId = 'japan';
|
||||||
|
|
@ -19,13 +37,24 @@ class GeoMapRepository([final AssetBundle? _bundle]) {
|
||||||
/// The map [id]; null for one this app doesn't bundle, e.g. one a newer
|
/// The map [id]; null for one this app doesn't bundle, e.g. one a newer
|
||||||
/// version added.
|
/// version added.
|
||||||
Future<GeoMap?> load(String id) async {
|
Future<GeoMap?> load(String id) async {
|
||||||
final asset = bundledMaps[id];
|
final bundled = bundledMaps[id];
|
||||||
if (asset == null) {
|
if (bundled == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return _maps[id] ??= parseGeoMap(
|
if (_maps[id] case final map?) {
|
||||||
id,
|
return map;
|
||||||
await (_bundle ?? rootBundle).loadString(asset),
|
}
|
||||||
|
final geojson = await (_bundle ?? rootBundle).loadString(bundled.asset);
|
||||||
|
// Apart from the screen's isolate: the world's countries are almost
|
||||||
|
// 2 MB of JSON, which would hold up a phone's frames.
|
||||||
|
return _maps[id] ??= await compute(
|
||||||
|
(_) => parseGeoMap(
|
||||||
|
id,
|
||||||
|
geojson,
|
||||||
|
groups: bundled.groups,
|
||||||
|
language: bundled.language,
|
||||||
|
),
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
|
||||||
|
// The parts a map is picked from first, so a region too small to hit on
|
||||||
|
// the whole map (Liechtenstein, Tokyo) is picked where it is large enough.
|
||||||
|
|
||||||
|
/// Japan's eight traditional regions, Okinawa apart from Kyūshū: on the
|
||||||
|
/// whole map it is a few specks far from the rest, and alone it is picked
|
||||||
|
/// at once. Each frames its prefectures' outlines, except where far islands
|
||||||
|
/// would shrink the rest: Kantō without the Ogasawara Islands, Kyūshū
|
||||||
|
/// without the Amami Islands.
|
||||||
|
const japanGroups = [
|
||||||
|
MapGroup(id: 'hokkaido', members: {'JP-01'}),
|
||||||
|
MapGroup(
|
||||||
|
id: 'tohoku',
|
||||||
|
members: {'JP-02', 'JP-03', 'JP-04', 'JP-05', 'JP-06', 'JP-07'},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'kanto',
|
||||||
|
members: {'JP-08', 'JP-09', 'JP-10', 'JP-11', 'JP-12', 'JP-13', 'JP-14'},
|
||||||
|
frame: (138.3, 34.6, 141.0, 37.2),
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'chubu',
|
||||||
|
members: {
|
||||||
|
'JP-15', 'JP-16', 'JP-17', 'JP-18', 'JP-19', //
|
||||||
|
'JP-20', 'JP-21', 'JP-22', 'JP-23',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'kansai',
|
||||||
|
members: {'JP-24', 'JP-25', 'JP-26', 'JP-27', 'JP-28', 'JP-29', 'JP-30'},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'chugoku',
|
||||||
|
members: {'JP-31', 'JP-32', 'JP-33', 'JP-34', 'JP-35'},
|
||||||
|
),
|
||||||
|
MapGroup(id: 'shikoku', members: {'JP-36', 'JP-37', 'JP-38', 'JP-39'}),
|
||||||
|
MapGroup(
|
||||||
|
id: 'kyushu',
|
||||||
|
members: {'JP-40', 'JP-41', 'JP-42', 'JP-43', 'JP-44', 'JP-45', 'JP-46'},
|
||||||
|
frame: (128.5, 30.9, 132.2, 34.8),
|
||||||
|
),
|
||||||
|
MapGroup(id: 'okinawa', members: {'JP-47'}),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The world's continents, Asia and the Americas split where one frame
|
||||||
|
/// would leave their small countries too small. A country lying in two (as
|
||||||
|
/// Cyprus, Türkiye and Russia) is in both. Antarctica and the French and
|
||||||
|
/// Australian islands near it are in none: picked at once on the whole map.
|
||||||
|
/// Oceania's frame runs past 180° to hold the Pacific in one piece.
|
||||||
|
const worldGroups = [
|
||||||
|
MapGroup(
|
||||||
|
id: 'europe',
|
||||||
|
frame: (-25, 34, 45, 71.5),
|
||||||
|
members: {
|
||||||
|
'AD', 'AL', 'AM', 'AT', 'AX', 'AZ', 'BA', 'BE', 'BG', 'BY', //
|
||||||
|
'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FO', 'FR',
|
||||||
|
'GB', 'GE', 'GG', 'GI', 'GR', 'HR', 'HU', 'IE', 'IM', 'IS',
|
||||||
|
'IT', 'JE', 'LI', 'LT', 'LU', 'LV', 'MC', 'MD', 'ME', 'MK',
|
||||||
|
'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'RS', 'RU', 'SE', 'SI',
|
||||||
|
'SK', 'SM', 'TR', 'UA', 'VA', 'XK',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'africa',
|
||||||
|
frame: (-26, -36, 64, 38),
|
||||||
|
members: {
|
||||||
|
'AO', 'BF', 'BI', 'BJ', 'BW', 'CD', 'CF', 'CG', 'CI', 'CM', //
|
||||||
|
'CV', 'DJ', 'DZ', 'EG', 'EH', 'ER', 'ET', 'GA', 'GH', 'GM',
|
||||||
|
'GN', 'GQ', 'GW', 'KE', 'KM', 'LR', 'LS', 'LY', 'MA', 'MG',
|
||||||
|
'ML', 'MR', 'MU', 'MW', 'MZ', 'NA', 'NE', 'NG', 'RW', 'SC',
|
||||||
|
'SD', 'SH', 'SL', 'SN', 'SO', 'SS', 'ST', 'SZ', 'TD', 'TG',
|
||||||
|
'TN', 'TZ', 'UG', 'ZA', 'ZM', 'ZW',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'westAsia',
|
||||||
|
frame: (25, 12, 63.5, 43.5),
|
||||||
|
members: {
|
||||||
|
'AE', 'AM', 'AZ', 'BH', 'CY', 'GE', 'IL', 'IQ', 'IR', 'JO', //
|
||||||
|
'KW', 'LB', 'OM', 'PS', 'QA', 'SA', 'SY', 'TR', 'YE',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'southAsia',
|
||||||
|
frame: (46, -8, 98, 56),
|
||||||
|
members: {
|
||||||
|
'AF', 'BD', 'BT', 'IN', 'IO', 'IR', 'KG', 'KZ', 'LK', 'MV', //
|
||||||
|
'NP', 'PK', 'TJ', 'TM', 'UZ',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'eastAsia',
|
||||||
|
frame: (73, 18, 148, 58),
|
||||||
|
members: {'CN', 'HK', 'JP', 'KP', 'KR', 'MN', 'MO', 'RU', 'TW'},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'southeastAsia',
|
||||||
|
frame: (92, -11.5, 141.5, 29),
|
||||||
|
members: {
|
||||||
|
'BN', 'ID', 'KH', 'LA', 'MM', 'MY', 'PH', 'SG', 'TH', 'TL', 'VN', //
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'northAmerica',
|
||||||
|
frame: (-170, 14, -50, 84),
|
||||||
|
members: {'BM', 'CA', 'GL', 'MX', 'PM', 'US'},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'centralAmerica',
|
||||||
|
frame: (-92.5, 7, -59, 27.5),
|
||||||
|
members: {
|
||||||
|
'AG', 'AI', 'AW', 'BB', 'BL', 'BS', 'BZ', 'CR', 'CU', 'CW', //
|
||||||
|
'DM', 'DO', 'GD', 'GT', 'HN', 'HT', 'JM', 'KN', 'KY', 'LC',
|
||||||
|
'MF', 'MS', 'MX', 'NI', 'PA', 'PR', 'SV', 'SX', 'TC', 'TT',
|
||||||
|
'VC', 'VG', 'VI',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'southAmerica',
|
||||||
|
frame: (-92, -56, -26, 13.5),
|
||||||
|
members: {
|
||||||
|
'AR', 'BO', 'BR', 'CL', 'CO', 'EC', 'FK', 'GS', 'GY', 'PE', //
|
||||||
|
'PY', 'SR', 'UY', 'VE',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MapGroup(
|
||||||
|
id: 'oceania',
|
||||||
|
frame: (110, -50, 235, 30),
|
||||||
|
members: {
|
||||||
|
'AS', 'AU', 'CK', 'FJ', 'FM', 'GU', 'KI', 'MH', 'MP', 'NC', //
|
||||||
|
'NF', 'NR', 'NU', 'NZ', 'PF', 'PG', 'PN', 'PW', 'SB', 'TO',
|
||||||
|
'TV', 'UM', 'VU', 'WF', 'WS',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The world's regions in no group, picked at once on the whole map.
|
||||||
|
const worldUngrouped = {'AQ', 'HM', 'TF'};
|
||||||
|
|
||||||
|
/// The name of the group [id] in the app's language.
|
||||||
|
String mapGroupName(AppLocalizations l10n, String id) => switch (id) {
|
||||||
|
'hokkaido' => l10n.mapGroupHokkaido,
|
||||||
|
'tohoku' => l10n.mapGroupTohoku,
|
||||||
|
'kanto' => l10n.mapGroupKanto,
|
||||||
|
'chubu' => l10n.mapGroupChubu,
|
||||||
|
'kansai' => l10n.mapGroupKansai,
|
||||||
|
'chugoku' => l10n.mapGroupChugoku,
|
||||||
|
'shikoku' => l10n.mapGroupShikoku,
|
||||||
|
'kyushu' => l10n.mapGroupKyushu,
|
||||||
|
'okinawa' => l10n.mapGroupOkinawa,
|
||||||
|
'europe' => l10n.mapGroupEurope,
|
||||||
|
'africa' => l10n.mapGroupAfrica,
|
||||||
|
'westAsia' => l10n.mapGroupWestAsia,
|
||||||
|
'southAsia' => l10n.mapGroupSouthAsia,
|
||||||
|
'eastAsia' => l10n.mapGroupEastAsia,
|
||||||
|
'southeastAsia' => l10n.mapGroupSoutheastAsia,
|
||||||
|
'northAmerica' => l10n.mapGroupNorthAmerica,
|
||||||
|
'centralAmerica' => l10n.mapGroupCentralAmerica,
|
||||||
|
'southAmerica' => l10n.mapGroupSouthAmerica,
|
||||||
|
'oceania' => l10n.mapGroupOceania,
|
||||||
|
_ => id,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The name of the map [id] as a whole, in the app's language: what one
|
||||||
|
/// goes back to from a group.
|
||||||
|
String mapName(AppLocalizations l10n, String id) => switch (id) {
|
||||||
|
'japan' => l10n.mapNameJapan,
|
||||||
|
'world' => l10n.mapNameWorld,
|
||||||
|
_ => id,
|
||||||
|
};
|
||||||
|
|
@ -9,30 +9,67 @@ typedef Ring = List<GeoPoint>;
|
||||||
/// One area of a polygon: its outline first, then its holes.
|
/// One area of a polygon: its outline first, then its holes.
|
||||||
typedef Polygon = List<Ring>;
|
typedef Polygon = List<Ring>;
|
||||||
|
|
||||||
/// A region of a map, e.g. a prefecture: its code, its names and its
|
/// A box of longitude and latitude: west, south, east, north. East may lie
|
||||||
/// outline in one or more polygons (islands).
|
/// beyond 180° for a box across the antimeridian, as the Pacific's.
|
||||||
|
typedef GeoBox = (double west, double south, double east, double north);
|
||||||
|
|
||||||
|
/// A region of a map, e.g. a prefecture or a country: its code, its names
|
||||||
|
/// and its outline in one or more polygons (islands).
|
||||||
final class const MapRegion({
|
final class const MapRegion({
|
||||||
/// Its ISO 3166-2 code, e.g. `JP-13`.
|
/// Its ISO code: ISO 3166-2 for a prefecture (`JP-13`), ISO 3166-1 for a
|
||||||
|
/// country (`CY`).
|
||||||
required final String code,
|
required final String code,
|
||||||
|
|
||||||
/// Its English name.
|
/// Its names by language tag (`en`, `ja`, `zh-Hans`); English always.
|
||||||
required final String name,
|
required final Map<String, String> names,
|
||||||
|
|
||||||
/// Its name in the map's own language, if the map has one.
|
|
||||||
required final String? localName,
|
|
||||||
|
|
||||||
/// Its areas.
|
/// Its areas.
|
||||||
required final List<Polygon> polygons,
|
required final List<Polygon> polygons,
|
||||||
|
|
||||||
|
/// Where to mark it when it is too small to tap; the middle of its
|
||||||
|
/// largest area when the map gives none.
|
||||||
|
final GeoPoint? label,
|
||||||
}) {
|
}) {
|
||||||
|
/// Its English name.
|
||||||
|
String get name => names['en']!;
|
||||||
|
|
||||||
|
/// Its name in [language] (a tag, as `de` or `zh-Hans`), else null.
|
||||||
|
String? nameIn(String? language) => names[language];
|
||||||
|
|
||||||
|
/// Where to mark it when it is too small to tap.
|
||||||
|
GeoPoint get marker => label ?? boxOf(_largest).center;
|
||||||
|
|
||||||
|
Polygon get _largest =>
|
||||||
|
polygons.reduce((a, b) => _area(a.first) >= _area(b.first) ? a : b);
|
||||||
|
|
||||||
/// Whether [point] lies inside the region: in an outline and in none of
|
/// Whether [point] lies inside the region: in an outline and in none of
|
||||||
/// its holes.
|
/// its holes. Its longitude may be given beyond ±180°.
|
||||||
bool contains(GeoPoint point) => polygons.any(
|
bool contains(GeoPoint point) {
|
||||||
(polygon) =>
|
final (lon, lat) = point;
|
||||||
_inRing(polygon.first, point) &&
|
final normal = (_wrap(lon), lat);
|
||||||
!polygon.skip(1).any((hole) => _inRing(hole, point)),
|
return polygons.any(
|
||||||
);
|
(polygon) =>
|
||||||
|
_inRing(polygon.first, normal) &&
|
||||||
|
!polygon.skip(1).any((hole) => _inRing(hole, normal)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A part of a map one looks at first, e.g. a continent: the regions in it
|
||||||
|
/// ([members]) and the box that frames them, or that of the members'
|
||||||
|
/// outlines when null.
|
||||||
|
final class const MapGroup({
|
||||||
|
/// Its id, which names it (`mapGroupName`).
|
||||||
|
required final String id,
|
||||||
|
|
||||||
|
/// The codes of its regions. A region may be in several groups (Cyprus in
|
||||||
|
/// Europe and in Western Asia).
|
||||||
|
required final Set<String> members,
|
||||||
|
|
||||||
|
/// The box it shows.
|
||||||
|
final GeoBox? frame,
|
||||||
|
});
|
||||||
|
|
||||||
/// A map of regions, e.g. Japan's prefectures, as the app bundles it.
|
/// A map of regions, e.g. Japan's prefectures, as the app bundles it.
|
||||||
final class const GeoMap({
|
final class const GeoMap({
|
||||||
/// Its id, as cards name it, e.g. `japan`.
|
/// Its id, as cards name it, e.g. `japan`.
|
||||||
|
|
@ -40,6 +77,18 @@ final class const GeoMap({
|
||||||
|
|
||||||
/// Its regions, by code.
|
/// Its regions, by code.
|
||||||
required final List<MapRegion> regions,
|
required final List<MapRegion> regions,
|
||||||
|
|
||||||
|
/// Land that belongs to no region and can't be picked, e.g. a glacier two
|
||||||
|
/// countries claim.
|
||||||
|
final List<Polygon> land = const [],
|
||||||
|
|
||||||
|
/// The parts one picks first, then a region in them; none for a map
|
||||||
|
/// picked from at once.
|
||||||
|
final List<MapGroup> groups = const [],
|
||||||
|
|
||||||
|
/// The map's own language, its regions named in it beside the app's
|
||||||
|
/// (Japanese for Japan's prefectures); null for none.
|
||||||
|
final String? language,
|
||||||
}) {
|
}) {
|
||||||
/// The region [code], or null.
|
/// The region [code], or null.
|
||||||
MapRegion? region(String? code) =>
|
MapRegion? region(String? code) =>
|
||||||
|
|
@ -49,21 +98,72 @@ final class const GeoMap({
|
||||||
MapRegion? regionAt(GeoPoint point) =>
|
MapRegion? regionAt(GeoPoint point) =>
|
||||||
regions.where((region) => region.contains(point)).firstOrNull;
|
regions.where((region) => region.contains(point)).firstOrNull;
|
||||||
|
|
||||||
|
/// The groups [code] is in, in the map's order.
|
||||||
|
List<MapGroup> groupsOf(String code) => [
|
||||||
|
for (final group in groups)
|
||||||
|
if (group.members.contains(code)) group,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The box [group] shows: its frame, else its members' outlines.
|
||||||
|
GeoBox frameOf(MapGroup group) =>
|
||||||
|
group.frame ??
|
||||||
|
unionOf([
|
||||||
|
for (final region in regions)
|
||||||
|
if (group.members.contains(region.code))
|
||||||
|
for (final polygon in region.polygons) boxOf(polygon),
|
||||||
|
]);
|
||||||
|
|
||||||
/// The smallest box holding every region: west, south, east, north.
|
/// The smallest box holding every region: west, south, east, north.
|
||||||
(double, double, double, double) get bounds {
|
(double, double, double, double) get bounds => unionOf([
|
||||||
var (west, south, east, north) = (180.0, 90.0, -180.0, -90.0);
|
for (final region in regions)
|
||||||
for (final region in regions) {
|
for (final polygon in region.polygons) boxOf(polygon),
|
||||||
for (final polygon in region.polygons) {
|
]);
|
||||||
for (final (lon, lat) in polygon.first) {
|
}
|
||||||
west = math.min(west, lon);
|
|
||||||
east = math.max(east, lon);
|
/// The box of [polygon]'s outline.
|
||||||
south = math.min(south, lat);
|
GeoBox boxOf(Polygon polygon) {
|
||||||
north = math.max(north, lat);
|
var (west, south, east, north) = (180.0, 90.0, -180.0, -90.0);
|
||||||
}
|
for (final (lon, lat) in polygon.first) {
|
||||||
}
|
west = math.min(west, lon);
|
||||||
}
|
east = math.max(east, lon);
|
||||||
return (west, south, east, north);
|
south = math.min(south, lat);
|
||||||
|
north = math.max(north, lat);
|
||||||
}
|
}
|
||||||
|
return (west, south, east, north);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smallest box holding all of [boxes].
|
||||||
|
GeoBox unionOf(Iterable<GeoBox> boxes) => boxes.reduce(
|
||||||
|
(a, b) => (
|
||||||
|
math.min(a.$1, b.$1),
|
||||||
|
math.min(a.$2, b.$2),
|
||||||
|
math.max(a.$3, b.$3),
|
||||||
|
math.max(a.$4, b.$4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The middle and the extent of a [GeoBox].
|
||||||
|
extension GeoBoxParts on GeoBox {
|
||||||
|
/// Its middle.
|
||||||
|
GeoPoint get center => (($1 + $3) / 2, ($2 + $4) / 2);
|
||||||
|
|
||||||
|
/// How wide it is, in degrees of longitude.
|
||||||
|
double get width => $3 - $1;
|
||||||
|
|
||||||
|
/// How tall it is, in degrees of latitude.
|
||||||
|
double get height => $4 - $2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [lon] brought within -180° to 180°.
|
||||||
|
double _wrap(double lon) => (lon + 180) % 360 - 180;
|
||||||
|
|
||||||
|
/// The area of [ring], in square degrees (shoelace).
|
||||||
|
double _area(Ring ring) {
|
||||||
|
var sum = 0.0;
|
||||||
|
for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||||
|
sum += ring[j].$1 * ring[i].$2 - ring[i].$1 * ring[j].$2;
|
||||||
|
}
|
||||||
|
return sum.abs() / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether [point] lies inside [ring], by the even-odd rule.
|
/// Whether [point] lies inside [ring], by the even-odd rule.
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,37 @@
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui' show lerpDouble;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/map_groups.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// [map] drawn to fit its box, longitude and latitude to scale at the map's
|
/// [map] for picking a region, in two steps where the map has groups: on
|
||||||
/// middle; tapping a region reports its code. Pinching or scrolling zooms,
|
/// the whole map one taps a group (a continent, a part of Japan) or its
|
||||||
/// for regions too small to tap otherwise.
|
/// chip, and the map flies there; there one taps the region, and Back
|
||||||
|
/// returns to the whole map. A region one can hardly hit on a phone is
|
||||||
|
/// picked where it is large enough, Cyprus in Europe rather than on the
|
||||||
|
/// world.
|
||||||
|
///
|
||||||
|
/// Even there some are too small to hit (Liechtenstein, Malta, the
|
||||||
|
/// Caribbean's islands): they get a ring, which picks them, and within
|
||||||
|
/// 24 dp of it (half the 48 dp Material makes the least a finger is sure
|
||||||
|
/// to hit) a tap could mean them or their neighbours, so it zooms in rather
|
||||||
|
/// than guess, until the tap is plain. Pinching zooms and drags pan.
|
||||||
///
|
///
|
||||||
/// The [selected] region is filled in the primary colour; once checked,
|
/// The [selected] region is filled in the primary colour; once checked,
|
||||||
/// the [right] region green and a [wrong] one red.
|
/// the [right] region green and a [wrong] one red. The map opens on the
|
||||||
|
/// group they are in, or on the whole map when they are in different ones.
|
||||||
class const RegionMap({
|
class const RegionMap({
|
||||||
/// The map.
|
/// The map.
|
||||||
required final GeoMap map,
|
required final GeoMap map,
|
||||||
|
|
||||||
/// Called with the code of the region tapped; null makes it read-only.
|
/// Called with the code of the region picked; null makes it read-only
|
||||||
|
/// (one can still look at the groups).
|
||||||
final ValueChanged<String>? onRegionTapped,
|
final ValueChanged<String>? onRegionTapped,
|
||||||
|
|
||||||
/// The region picked.
|
/// The region picked.
|
||||||
|
|
@ -26,139 +43,793 @@ class const RegionMap({
|
||||||
/// The region picked wrongly, once checked.
|
/// The region picked wrongly, once checked.
|
||||||
final String? wrong,
|
final String? wrong,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<RegionMap> createState() => RegionMapState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state of a [RegionMap]: the group shown and how far it is zoomed.
|
||||||
|
class RegionMapState()
|
||||||
|
extends State<RegionMap>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
/// How far a region's ring reaches: half the 48 dp touch target.
|
||||||
|
static const _markerReach = 24.0;
|
||||||
|
|
||||||
|
/// How near its outline a tap in the sea still means a region.
|
||||||
|
static const _slop = 8.0;
|
||||||
|
|
||||||
|
/// How much a tap that could mean several regions zooms in.
|
||||||
|
static const _zoomStep = 3.0;
|
||||||
|
|
||||||
|
/// How far a group zooms in at most, from showing it whole.
|
||||||
|
static const _maxZoom = 24.0;
|
||||||
|
|
||||||
|
late var _geometry = _MapGeometry(widget.map);
|
||||||
|
|
||||||
|
/// The group shown; null for the whole map.
|
||||||
|
late MapGroup? _group = _groupToShow();
|
||||||
|
|
||||||
|
/// What is shown, once the box is known, and the box.
|
||||||
|
_View? _view;
|
||||||
|
Size? _size;
|
||||||
|
|
||||||
|
/// A flight from one view to another.
|
||||||
|
late final _flight = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
)..addListener(_fly);
|
||||||
|
_View? _from;
|
||||||
|
_View? _to;
|
||||||
|
|
||||||
|
/// Where a pinch started: the view, and the point between the fingers.
|
||||||
|
_View? _pinchView;
|
||||||
|
Offset? _pinchFocus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(RegionMap oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.map != widget.map) {
|
||||||
|
_flight.stop();
|
||||||
|
_geometry = _MapGeometry(widget.map);
|
||||||
|
_group = _groupToShow();
|
||||||
|
_size = null;
|
||||||
|
} else if (oldWidget.right != widget.right ||
|
||||||
|
oldWidget.wrong != widget.wrong) {
|
||||||
|
final group = _groupToShow();
|
||||||
|
if (group != _group) {
|
||||||
|
_open(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_flight.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The group the regions to see are in: the right one and the one picked
|
||||||
|
/// wrongly once checked, else the one picked; null when they share none,
|
||||||
|
/// or a group holds only them.
|
||||||
|
MapGroup? _groupToShow() {
|
||||||
|
final focus = widget.right ?? widget.selected;
|
||||||
|
if (focus == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final groups = widget.map
|
||||||
|
.groupsOf(focus)
|
||||||
|
.where(
|
||||||
|
(group) =>
|
||||||
|
group.members.length > 1 &&
|
||||||
|
(widget.wrong == null || group.members.contains(widget.wrong)),
|
||||||
|
);
|
||||||
|
return groups.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a tap picks a region here, rather than a group.
|
||||||
|
bool get _picksRegions => _group != null || widget.map.groups.isEmpty;
|
||||||
|
|
||||||
|
/// The regions marked with a ring when too small to tap: any in a group,
|
||||||
|
/// on the whole map only those picked there, and those coloured.
|
||||||
|
Set<String>? get _markable => _picksRegions
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
for (final region in widget.map.regions)
|
||||||
|
if (_pickedAtOnce(region.code)) region.code,
|
||||||
|
?widget.selected,
|
||||||
|
?widget.right,
|
||||||
|
?widget.wrong,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Whether the whole map picks [code] at once: in no group, or alone in
|
||||||
|
/// its one.
|
||||||
|
bool _pickedAtOnce(String code) => switch (widget.map.groupsOf(code)) {
|
||||||
|
[] => true,
|
||||||
|
[final group] => group.members.length == 1,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
GeoBox _frameOf(MapGroup? group) =>
|
||||||
|
group == null ? widget.map.bounds : widget.map.frameOf(group);
|
||||||
|
|
||||||
|
void _fly() {
|
||||||
|
setState(
|
||||||
|
() => _view = _View.lerp(
|
||||||
|
_from!,
|
||||||
|
_to!,
|
||||||
|
Curves.easeInOutCubic.transform(_flight.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows [target], flying there unless animations are off.
|
||||||
|
void _goTo(_View target) {
|
||||||
|
if (_view == null || MediaQuery.disableAnimationsOf(context)) {
|
||||||
|
setState(() => _view = target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_from = _view;
|
||||||
|
_to = target;
|
||||||
|
_flight.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows [group] whole; the whole map for null.
|
||||||
|
void _open(MapGroup? group) {
|
||||||
|
setState(() => _group = group);
|
||||||
|
if (_size case final size?) {
|
||||||
|
_goTo(_View.fit(_frameOf(group), size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group chosen: its region at once when it holds only that, else the
|
||||||
|
/// group.
|
||||||
|
void _choose(MapGroup group) {
|
||||||
|
if (group.members.length == 1 && widget.onRegionTapped != null) {
|
||||||
|
widget.onRegionTapped!(group.members.single);
|
||||||
|
} else {
|
||||||
|
_open(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tapped(Offset point) {
|
||||||
|
final view = _view;
|
||||||
|
final size = _size;
|
||||||
|
if (view == null || size == null || _flight.isAnimating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final hits = _geometry.hitsAt(
|
||||||
|
point,
|
||||||
|
view,
|
||||||
|
size,
|
||||||
|
markable: _markable,
|
||||||
|
markerReach: _markerReach,
|
||||||
|
slop: _slop,
|
||||||
|
);
|
||||||
|
if (hits.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_picksRegions) {
|
||||||
|
_pick(point, hits);
|
||||||
|
} else {
|
||||||
|
// The region under the finger, else the nearest, and of its groups
|
||||||
|
// the one whose middle is nearest the tap: Russia's Siberia opens
|
||||||
|
// East Asia, Moscow Europe.
|
||||||
|
final hit =
|
||||||
|
hits.where((hit) => hit.certain && !hit.marked).firstOrNull ??
|
||||||
|
_nearest(hits);
|
||||||
|
final code = hit.region.code;
|
||||||
|
if (_pickedAtOnce(code)) {
|
||||||
|
widget.onRegionTapped?.call(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final at = view.toGeo(point, size);
|
||||||
|
final groups = widget.map.groupsOf(code)
|
||||||
|
..sort(
|
||||||
|
(a, b) => _degreesBetween(
|
||||||
|
_frameOf(a).center,
|
||||||
|
at,
|
||||||
|
).compareTo(_degreesBetween(_frameOf(b).center, at)),
|
||||||
|
);
|
||||||
|
_open(groups.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks the region of [hits] the tap at [point] plainly means: the only
|
||||||
|
/// one; else the one whose ring it is on, with no other ring near; else,
|
||||||
|
/// with no ring near, the one it is in. Anything else zooms in, and once
|
||||||
|
/// that is as far as it goes, picks the nearest.
|
||||||
|
void _pick(Offset point, List<_Hit> hits) {
|
||||||
|
final onRegion = widget.onRegionTapped;
|
||||||
|
if (onRegion == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final marked = hits.where((hit) => hit.marked).toList();
|
||||||
|
final certain = hits.where((hit) => hit.certain).toList();
|
||||||
|
final plain = switch ((hits, marked, certain)) {
|
||||||
|
([final only], _, _) => only,
|
||||||
|
(_, [final ring], _) when ring.certain => ring,
|
||||||
|
(_, [], [final inside]) => inside,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (plain != null) {
|
||||||
|
onRegion(plain.region.code);
|
||||||
|
} else if (!_zoomIn(point)) {
|
||||||
|
onRegion(_nearest(hits).region.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static _Hit _nearest(List<_Hit> hits) =>
|
||||||
|
hits.reduce((a, b) => a.distance <= b.distance ? a : b);
|
||||||
|
|
||||||
|
/// Zooms in around [point], which stays under the finger; false when it
|
||||||
|
/// is zoomed in as far as it goes.
|
||||||
|
bool _zoomIn(Offset point) {
|
||||||
|
final view = _view!;
|
||||||
|
final size = _size!;
|
||||||
|
final (least, most) = _scales(size);
|
||||||
|
if (view.scale >= most * 0.999) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final at = view.toGeo(point, size);
|
||||||
|
_goTo(
|
||||||
|
_clamp(
|
||||||
|
view
|
||||||
|
.withScale(math.min(view.scale * _zoomStep, most))
|
||||||
|
.placing(at, point, size),
|
||||||
|
least,
|
||||||
|
most,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the group shown zooms: whole, and as far as it goes.
|
||||||
|
(double, double) _scales(Size size) {
|
||||||
|
final least = _View.fit(_frameOf(_group), size).scale;
|
||||||
|
return (least, least * _maxZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [view] within [least] and [most] zoom, its middle within the group.
|
||||||
|
_View _clamp(_View view, double least, double most) {
|
||||||
|
final (west, south, east, north) = _frameOf(_group);
|
||||||
|
return _View(
|
||||||
|
lon: view.lon.clamp(west, east),
|
||||||
|
lat: view.lat.clamp(south, north),
|
||||||
|
scale: view.scale.clamp(least, most),
|
||||||
|
xScale: view.xScale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pinchStarted(ScaleStartDetails details) {
|
||||||
|
_flight.stop();
|
||||||
|
_pinchView = _view;
|
||||||
|
_pinchFocus = details.localFocalPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pinched(ScaleUpdateDetails details) {
|
||||||
|
final start = _pinchView;
|
||||||
|
final size = _size;
|
||||||
|
if (start == null || size == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final (least, most) = _scales(size);
|
||||||
|
final at = start.toGeo(_pinchFocus!, size);
|
||||||
|
setState(
|
||||||
|
() => _view = _clamp(
|
||||||
|
start
|
||||||
|
.withScale((start.scale * details.scale).clamp(least, most))
|
||||||
|
.placing(at, details.localFocalPoint, size),
|
||||||
|
least,
|
||||||
|
most,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mouse's wheel zooms around the pointer, as pinching does.
|
||||||
|
void _scrolled(PointerSignalEvent event) {
|
||||||
|
final view = _view;
|
||||||
|
final size = _size;
|
||||||
|
if (event is! PointerScrollEvent || view == null || size == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_flight.stop();
|
||||||
|
final (least, most) = _scales(size);
|
||||||
|
final at = view.toGeo(event.localPosition, size);
|
||||||
|
setState(
|
||||||
|
() => _view = _clamp(
|
||||||
|
view
|
||||||
|
.withScale(
|
||||||
|
(view.scale * math.exp(-event.scrollDelta.dy / 200)).clamp(
|
||||||
|
least,
|
||||||
|
most,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.placing(at, event.localPosition, size),
|
||||||
|
least,
|
||||||
|
most,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where [point] shows on the screen now, for tests to tap it.
|
||||||
|
@visibleForTesting
|
||||||
|
Offset globalOffsetOf(GeoPoint point) {
|
||||||
|
final canvas = _canvasKey.currentContext!.findRenderObject()! as RenderBox;
|
||||||
|
return canvas.localToGlobal(_view!.toBox(_nearLon(point), _size!));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [point] on the turn of the earth nearest the view's middle.
|
||||||
|
GeoPoint _nearLon(GeoPoint point) {
|
||||||
|
final (lon, lat) = point;
|
||||||
|
return (lon + 360.0 * ((_view!.lon - lon) / 360).round(), lat);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _canvasKey = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
return AspectRatio(
|
final canvas = ClipRRect(
|
||||||
aspectRatio: _Projection(map, const Size(1, 1)).aspectRatio,
|
key: _canvasKey,
|
||||||
child: ClipRRect(
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: ColoredBox(
|
||||||
child: ColoredBox(
|
color: colors.surfaceContainerHighest,
|
||||||
color: colors.surfaceContainerHighest,
|
child: LayoutBuilder(
|
||||||
child: InteractiveViewer(
|
builder: (context, constraints) {
|
||||||
maxScale: 8,
|
final size = constraints.biggest;
|
||||||
child: LayoutBuilder(
|
if (_size != size || _view == null) {
|
||||||
builder: (context, constraints) {
|
// A new box: the group shown whole in it.
|
||||||
final projection = _Projection(map, constraints.biggest);
|
_flight.stop();
|
||||||
return GestureDetector(
|
_size = size;
|
||||||
onTapUp: onRegionTapped == null
|
_view = _View.fit(_frameOf(_group), size);
|
||||||
? null
|
}
|
||||||
: (details) {
|
return Listener(
|
||||||
final region = map.regionAt(
|
onPointerSignal: _picksRegions ? _scrolled : null,
|
||||||
projection.toGeo(details.localPosition),
|
child: GestureDetector(
|
||||||
);
|
behavior: HitTestBehavior.opaque,
|
||||||
if (region != null) {
|
onTapUp: (details) => _tapped(details.localPosition),
|
||||||
onRegionTapped!(region.code);
|
onScaleStart: _picksRegions ? _pinchStarted : null,
|
||||||
}
|
onScaleUpdate: _picksRegions ? _pinched : null,
|
||||||
},
|
child: CustomPaint(
|
||||||
child: CustomPaint(
|
size: size,
|
||||||
size: constraints.biggest,
|
painter: _MapPainter(
|
||||||
painter: _MapPainter(
|
geometry: _geometry,
|
||||||
map: map,
|
view: _view!,
|
||||||
projection: projection,
|
land: colors.surface,
|
||||||
land: colors.surface,
|
border: colors.outline,
|
||||||
border: colors.outline,
|
markable: _markable,
|
||||||
fills: {
|
fills: {
|
||||||
?selected: colors.primary,
|
?widget.selected: colors.primary,
|
||||||
?wrong: colors.error,
|
?widget.wrong: colors.error,
|
||||||
?right: theme.extras.success,
|
?widget.right: theme.extras.success,
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final header = widget.map.groups.isEmpty
|
||||||
|
? null
|
||||||
|
: _Header(
|
||||||
|
map: widget.map,
|
||||||
|
group: _group,
|
||||||
|
onBack: () => _open(null),
|
||||||
|
onChoose: _choose,
|
||||||
|
);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: constraints.hasBoundedHeight
|
||||||
|
? MainAxisSize.max
|
||||||
|
: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (header != null) ...[header, const SizedBox(height: 8)],
|
||||||
|
if (constraints.hasBoundedHeight)
|
||||||
|
Expanded(child: canvas)
|
||||||
|
else
|
||||||
|
AspectRatio(aspectRatio: 4 / 3, child: canvas),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Longitude and latitude to a box and back: equirectangular, longitude
|
/// Above the map: on the whole map a chip per group, which opens it; in a
|
||||||
/// scaled by the cosine of the map's middle latitude so shapes keep their
|
/// group, Back to the whole map and the group's name.
|
||||||
/// proportions there, with a margin.
|
class const _Header({
|
||||||
class _Projection(final GeoMap map, final Size size) {
|
required final GeoMap map,
|
||||||
this {
|
required final MapGroup? group,
|
||||||
final (west, south, east, north) = map.bounds;
|
required final VoidCallback onBack,
|
||||||
_west = west;
|
required final ValueChanged<MapGroup> onChoose,
|
||||||
_north = north;
|
}) extends StatelessWidget {
|
||||||
_xScale = math.cos((south + north) / 2 * math.pi / 180);
|
@override
|
||||||
final width = (east - west) * _xScale;
|
Widget build(BuildContext context) {
|
||||||
final height = north - south;
|
final l10n = AppLocalizations.of(context);
|
||||||
aspectRatio = width / height;
|
final theme = Theme.of(context);
|
||||||
_scale = math.min(
|
return SizedBox(
|
||||||
(size.width - 2 * _margin) / width,
|
height: 40,
|
||||||
(size.height - 2 * _margin) / height,
|
child: switch (group) {
|
||||||
);
|
null => ListView.separated(
|
||||||
_offset = Offset(
|
scrollDirection: Axis.horizontal,
|
||||||
(size.width - width * _scale) / 2,
|
itemCount: map.groups.length,
|
||||||
(size.height - height * _scale) / 2,
|
separatorBuilder: (context, _) => const SizedBox(width: 8),
|
||||||
);
|
itemBuilder: (context, index) => ActionChip(
|
||||||
}
|
label: Text(mapGroupName(l10n, map.groups[index].id)),
|
||||||
|
onPressed: () => onChoose(map.groups[index]),
|
||||||
static const _margin = 8.0;
|
),
|
||||||
|
),
|
||||||
late final double aspectRatio;
|
final group => Row(
|
||||||
late final double _west;
|
children: [
|
||||||
late final double _north;
|
TextButton.icon(
|
||||||
late final double _xScale;
|
onPressed: onBack,
|
||||||
late final double _scale;
|
icon: const Icon(LucideIcons.chevronLeft, size: 18),
|
||||||
late final Offset _offset;
|
label: Text(mapName(l10n, map.id)),
|
||||||
|
),
|
||||||
Offset toBox(GeoPoint point) {
|
const SizedBox(width: 8),
|
||||||
final (lon, lat) = point;
|
Expanded(
|
||||||
return _offset +
|
child: Text(
|
||||||
Offset((lon - _west) * _xScale * _scale, (_north - lat) * _scale);
|
mapGroupName(l10n, group.id),
|
||||||
}
|
style: theme.textTheme.titleSmall,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
GeoPoint toGeo(Offset offset) {
|
overflow: TextOverflow.ellipsis,
|
||||||
final local = offset - _offset;
|
),
|
||||||
return (
|
),
|
||||||
local.dx / _scale / _xScale + _west,
|
],
|
||||||
_north - local.dy / _scale,
|
),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the map shows of the earth: the point in the middle, how many
|
||||||
|
/// pixels a degree of latitude takes, and how much narrower a degree of
|
||||||
|
/// longitude is: the cosine of the latitude its group is true at, so shapes
|
||||||
|
/// keep their proportions there (equirectangular).
|
||||||
|
final class const _View({
|
||||||
|
required final double lon,
|
||||||
|
required final double lat,
|
||||||
|
required final double scale,
|
||||||
|
required final double xScale,
|
||||||
|
}) {
|
||||||
|
/// [box] whole in [size], with a margin.
|
||||||
|
factory fit(GeoBox box, Size size) {
|
||||||
|
const margin = 8.0;
|
||||||
|
final (west, south, east, north) = box;
|
||||||
|
final xScale = math.cos((south + north) / 2 * math.pi / 180);
|
||||||
|
return _View(
|
||||||
|
lon: (west + east) / 2,
|
||||||
|
lat: (south + north) / 2,
|
||||||
|
scale: math.max(
|
||||||
|
math.min(
|
||||||
|
(size.width - 2 * margin) / ((east - west) * xScale),
|
||||||
|
(size.height - 2 * margin) / (north - south),
|
||||||
|
),
|
||||||
|
// A box with no room left: anything rather than nothing.
|
||||||
|
1e-3,
|
||||||
|
),
|
||||||
|
xScale: xScale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Between [a] and [b], zooming evenly.
|
||||||
|
factory lerp(_View a, _View b, double t) => _View(
|
||||||
|
lon: lerpDouble(a.lon, b.lon, t)!,
|
||||||
|
lat: lerpDouble(a.lat, b.lat, t)!,
|
||||||
|
scale: math.exp(lerpDouble(math.log(a.scale), math.log(b.scale), t)!),
|
||||||
|
xScale: lerpDouble(a.xScale, b.xScale, t)!,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// This view zoomed to [scale].
|
||||||
|
_View withScale(double scale) =>
|
||||||
|
_View(lon: lon, lat: lat, scale: scale, xScale: xScale);
|
||||||
|
|
||||||
|
/// This view moved so [point] shows at [at] in [size].
|
||||||
|
_View placing(GeoPoint point, Offset at, Size size) {
|
||||||
|
final (pointLon, pointLat) = point;
|
||||||
|
return _View(
|
||||||
|
lon: pointLon - (at.dx - size.width / 2) / (xScale * scale),
|
||||||
|
lat: pointLat + (at.dy - size.height / 2) / scale,
|
||||||
|
scale: scale,
|
||||||
|
xScale: xScale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset toBox(GeoPoint point, Size size) {
|
||||||
|
final (pointLon, pointLat) = point;
|
||||||
|
return Offset(
|
||||||
|
size.width / 2 + (pointLon - lon) * xScale * scale,
|
||||||
|
size.height / 2 + (lat - pointLat) * scale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeoPoint toGeo(Offset offset, Size size) => (
|
||||||
|
lon + (offset.dx - size.width / 2) / (xScale * scale),
|
||||||
|
lat - (offset.dy - size.height / 2) / scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// What [size] shows, in degrees; its longitudes may run past ±180°.
|
||||||
|
GeoBox visible(Size size) {
|
||||||
|
final halfWidth = size.width / 2 / (xScale * scale);
|
||||||
|
final halfHeight = size.height / 2 / scale;
|
||||||
|
return (
|
||||||
|
lon - halfWidth,
|
||||||
|
lat - halfHeight,
|
||||||
|
lon + halfWidth,
|
||||||
|
lat + halfHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transform from the outlines' paths (x the longitude, y the
|
||||||
|
/// latitude's negative) to [size].
|
||||||
|
Float64List matrix(Size size) {
|
||||||
|
final sx = xScale * scale;
|
||||||
|
return Float64List.fromList([
|
||||||
|
sx, 0, 0, 0, //
|
||||||
|
0, scale, 0, 0,
|
||||||
|
0, 0, 1, 0,
|
||||||
|
size.width / 2 - lon * sx, size.height / 2 + lat * scale, 0, 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is _View &&
|
||||||
|
other.lon == lon &&
|
||||||
|
other.lat == lat &&
|
||||||
|
other.scale == scale &&
|
||||||
|
other.xScale == xScale;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(lon, lat, scale, xScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A region near a tap: whether it is `marked` with a ring, whether the
|
||||||
|
/// tap is `certain`ly on it (in it, or on its ring), and how far the tap
|
||||||
|
/// is from it, in pixels.
|
||||||
|
typedef _Hit = ({MapRegion region, bool marked, bool certain, double distance});
|
||||||
|
|
||||||
|
/// A map's outlines as paths, made once: each polygon in degrees (x the
|
||||||
|
/// longitude, y the latitude's negative) with its box, so a view draws and
|
||||||
|
/// hit-tests only what it shows, each piece on the turn of the earth
|
||||||
|
/// nearest the view's middle.
|
||||||
|
final class _MapGeometry(final GeoMap map) {
|
||||||
|
late final regions = [for (final region in map.regions) _RegionShape(region)];
|
||||||
|
late final land = [for (final polygon in map.land) _PolygonShape(polygon)];
|
||||||
|
|
||||||
|
/// The regions a tap at [point] may mean: those it is in or on the ring
|
||||||
|
/// of, those whose ring is within [markerReach] and those whose outline
|
||||||
|
/// is within [slop]. Only regions in [markable] (any for null) get rings.
|
||||||
|
List<_Hit> hitsAt(
|
||||||
|
Offset point,
|
||||||
|
_View view,
|
||||||
|
Size size, {
|
||||||
|
required Set<String>? markable,
|
||||||
|
required double markerReach,
|
||||||
|
required double slop,
|
||||||
|
}) {
|
||||||
|
final at = view.toGeo(point, size);
|
||||||
|
final hits = <_Hit>[];
|
||||||
|
for (final shape in regions) {
|
||||||
|
final region = shape.region;
|
||||||
|
final inside = region.contains(at);
|
||||||
|
if ((markable == null || markable.contains(region.code)) &&
|
||||||
|
shape.isTiny(view, size)) {
|
||||||
|
final distance =
|
||||||
|
(view.toBox(shape.markerNear(view.lon), size) - point).distance;
|
||||||
|
if (distance <= markerReach || inside) {
|
||||||
|
hits.add((
|
||||||
|
region: region,
|
||||||
|
marked: true,
|
||||||
|
certain: inside || distance <= _MapPainter.markerRadius,
|
||||||
|
distance: distance,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if (inside) {
|
||||||
|
hits.add((region: region, marked: false, certain: true, distance: 0));
|
||||||
|
} else if (shape.distanceTo(point, view, size, slop) case final d?) {
|
||||||
|
hits.add((region: region, marked: false, certain: false, distance: d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A region's polygons as paths, and where it is marked when small.
|
||||||
|
final class _RegionShape(final MapRegion region) {
|
||||||
|
late final polygons = [
|
||||||
|
for (final polygon in region.polygons) _PolygonShape(polygon),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Its largest area, whose size says whether it is too small to tap.
|
||||||
|
late final _largest = polygons.reduce(
|
||||||
|
(a, b) => a.box.width * a.box.height >= b.box.width * b.box.height ? a : b,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Its paths together, by which turn of the earth each piece is on.
|
||||||
|
final _paths = <int, Path>{};
|
||||||
|
|
||||||
|
/// Whether its largest area is narrower and shorter than a fingertip's
|
||||||
|
/// mark in [view]: too small to tap.
|
||||||
|
bool isTiny(_View view, Size size) =>
|
||||||
|
math.max(
|
||||||
|
_largest.box.width * view.xScale * view.scale,
|
||||||
|
_largest.box.height * view.scale,
|
||||||
|
) <
|
||||||
|
_MapPainter.tinySide;
|
||||||
|
|
||||||
|
/// Where it is marked, on the turn of the earth nearest [lon].
|
||||||
|
GeoPoint markerNear(double lon) {
|
||||||
|
final (markerLon, markerLat) = region.marker;
|
||||||
|
return (markerLon + 360.0 * ((lon - markerLon) / 360).round(), markerLat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Its outline in [visible], each piece on the turn of the earth nearest
|
||||||
|
/// [lon]; null when none of it shows.
|
||||||
|
Path? pathIn(GeoBox visible, double lon) {
|
||||||
|
final turns = [for (final polygon in polygons) polygon.turnsTo(lon)];
|
||||||
|
if (!polygons.indexed.any((e) => e.$2.shows(visible, turns[e.$1]))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _paths[Object.hashAll(turns)] ??= () {
|
||||||
|
final path = Path()..fillType = PathFillType.evenOdd;
|
||||||
|
for (final (index, polygon) in polygons.indexed) {
|
||||||
|
path.addPath(polygon.path, Offset(360.0 * turns[index], 0));
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far [point] is from its outline in [view], when within [limit].
|
||||||
|
double? distanceTo(Offset point, _View view, Size size, double limit) {
|
||||||
|
double? nearest;
|
||||||
|
for (final polygon in polygons) {
|
||||||
|
final shift = 360.0 * polygon.turnsTo(view.lon);
|
||||||
|
final (west, south, east, north) = polygon.box;
|
||||||
|
final corner = view.toBox((west + shift, north), size);
|
||||||
|
final far = view.toBox((east + shift, south), size);
|
||||||
|
if (!Rect.fromPoints(corner, far).inflate(limit).contains(point)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (final ring in polygon.polygon) {
|
||||||
|
var previous = view.toBox((ring.last.$1 + shift, ring.last.$2), size);
|
||||||
|
for (final (lon, lat) in ring) {
|
||||||
|
final next = view.toBox((lon + shift, lat), size);
|
||||||
|
final distance = _toSegment(point, previous, next);
|
||||||
|
if (distance <= limit && (nearest == null || distance < nearest)) {
|
||||||
|
nearest = distance;
|
||||||
|
}
|
||||||
|
previous = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toSegment(Offset point, Offset a, Offset b) {
|
||||||
|
final ab = b - a;
|
||||||
|
final length = ab.distanceSquared;
|
||||||
|
if (length == 0) {
|
||||||
|
return (point - a).distance;
|
||||||
|
}
|
||||||
|
final ap = point - a;
|
||||||
|
final t = ((ap.dx * ab.dx + ap.dy * ab.dy) / length).clamp(0.0, 1.0);
|
||||||
|
return (point - (a + ab * t)).distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A polygon as a path in degrees, with its box.
|
||||||
|
final class _PolygonShape(final Polygon polygon) {
|
||||||
|
late final box = boxOf(polygon);
|
||||||
|
|
||||||
|
late final path = () {
|
||||||
|
final path = Path()..fillType = PathFillType.evenOdd;
|
||||||
|
for (final ring in polygon) {
|
||||||
|
path.addPolygon([for (final (lon, lat) in ring) Offset(lon, -lat)], true);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}();
|
||||||
|
|
||||||
|
/// How many turns of the earth to shift it by to draw it nearest [lon]:
|
||||||
|
/// Samoa east of Fiji when looking at the Pacific.
|
||||||
|
int turnsTo(double lon) => ((lon - box.center.$1) / 360).round();
|
||||||
|
|
||||||
|
/// Whether it shows in [visible], shifted by [turns].
|
||||||
|
bool shows(GeoBox visible, int turns) {
|
||||||
|
final shift = 360.0 * turns;
|
||||||
|
final (west, south, east, north) = box;
|
||||||
|
final (left, bottom, right, top) = visible;
|
||||||
|
return west + shift <= right &&
|
||||||
|
east + shift >= left &&
|
||||||
|
south <= top &&
|
||||||
|
north >= bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class const _MapPainter({
|
class const _MapPainter({
|
||||||
required final GeoMap map,
|
required final _MapGeometry geometry,
|
||||||
required final _Projection projection,
|
required final _View view,
|
||||||
required final Color land,
|
required final Color land,
|
||||||
required final Color border,
|
required final Color border,
|
||||||
|
required final Set<String>? markable,
|
||||||
required final Map<String, Color> fills,
|
required final Map<String, Color> fills,
|
||||||
}) extends CustomPainter {
|
}) extends CustomPainter {
|
||||||
|
/// A region's largest area narrower and shorter than this is marked.
|
||||||
|
static const tinySide = 12.0;
|
||||||
|
|
||||||
|
/// The ring that marks it.
|
||||||
|
static const markerRadius = 9.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final matrix = view.matrix(size);
|
||||||
|
final visible = view.visible(size);
|
||||||
final stroke = Paint()
|
final stroke = Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 0.6
|
..strokeWidth = 0.6
|
||||||
..color = border;
|
..color = border;
|
||||||
for (final region in map.regions) {
|
final landPaint = Paint()..color = land;
|
||||||
final path = Path()..fillType = PathFillType.evenOdd;
|
for (final polygon in geometry.land) {
|
||||||
for (final polygon in region.polygons) {
|
final turns = polygon.turnsTo(view.lon);
|
||||||
for (final ring in polygon) {
|
if (polygon.shows(visible, turns)) {
|
||||||
path.addPolygon([
|
canvas.drawPath(
|
||||||
for (final point in ring) projection.toBox(point),
|
polygon.path.shift(Offset(360.0 * turns, 0)).transform(matrix),
|
||||||
], true);
|
landPaint,
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
canvas
|
}
|
||||||
..drawPath(path, Paint()..color = fills[region.code] ?? land)
|
for (final shape in geometry.regions) {
|
||||||
..drawPath(path, stroke);
|
final path = shape.pathIn(visible, view.lon)?.transform(matrix);
|
||||||
|
if (path != null) {
|
||||||
|
canvas
|
||||||
|
..drawPath(
|
||||||
|
path,
|
||||||
|
fills[shape.region.code] == null
|
||||||
|
? landPaint
|
||||||
|
: (Paint()..color = fills[shape.region.code]!),
|
||||||
|
)
|
||||||
|
..drawPath(path, stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final ring = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.5
|
||||||
|
..color = border;
|
||||||
|
final area = Offset.zero & size;
|
||||||
|
for (final shape in geometry.regions) {
|
||||||
|
final code = shape.region.code;
|
||||||
|
if (markable != null && !markable!.contains(code) ||
|
||||||
|
!shape.isTiny(view, size)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final center = view.toBox(shape.markerNear(view.lon), size);
|
||||||
|
if (!area.inflate(markerRadius).contains(center)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fills[code] case final fill?) {
|
||||||
|
canvas.drawCircle(center, markerRadius, Paint()..color = fill);
|
||||||
|
}
|
||||||
|
canvas.drawCircle(center, markerRadius, ring);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_MapPainter oldDelegate) =>
|
bool shouldRepaint(_MapPainter oldDelegate) =>
|
||||||
oldDelegate.map != map ||
|
oldDelegate.geometry != geometry ||
|
||||||
oldDelegate.projection.size != projection.size ||
|
oldDelegate.view != view ||
|
||||||
oldDelegate.land != land ||
|
oldDelegate.land != land ||
|
||||||
oldDelegate.border != border ||
|
oldDelegate.border != border ||
|
||||||
!_sameFills(oldDelegate.fills, fills);
|
!setEquals(oldDelegate.markable, markable) ||
|
||||||
|
!mapEquals(oldDelegate.fills, fills);
|
||||||
static bool _sameFills(Map<String, Color> a, Map<String, Color> b) =>
|
}
|
||||||
a.length == b.length && a.entries.every((e) => b[e.key] == e.value);
|
|
||||||
|
/// How far apart [a] and [b] are, in degrees, across the antimeridian
|
||||||
|
/// where that is shorter.
|
||||||
|
double _degreesBetween(GeoPoint a, GeoPoint b) {
|
||||||
|
final dLon = ((a.$1 - b.$1 + 180) % 360) - 180;
|
||||||
|
final dLat = a.$2 - b.$2;
|
||||||
|
return math.sqrt(dLon * dLon + dLat * dLat);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// [region]'s name in the app's language, else in English.
|
||||||
|
String regionName(BuildContext context, MapRegion region) =>
|
||||||
|
region.nameIn(Localizations.localeOf(context).languageCode) ?? region.name;
|
||||||
|
|
||||||
|
/// [region]'s name beside [regionName]: in [map]'s own language (Japanese
|
||||||
|
/// for Japan's prefectures), else in the studyset's, the language of its
|
||||||
|
/// cards' fronts, so one learning Japanese sees キプロス beside Cyprus; null
|
||||||
|
/// where that is the app's language or names it alike.
|
||||||
|
String? regionOtherName(BuildContext context, GeoMap map, MapRegion region) {
|
||||||
|
final language = map.language ?? StudysetLanguageScope.of(context).front;
|
||||||
|
if (language == null ||
|
||||||
|
language == Localizations.localeOf(context).languageCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final name = region.nameIn(language);
|
||||||
|
return name == regionName(context, region) ? null : name;
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/card_extras.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_names.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/character_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/character_sheet.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_page_chip.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_page_chip.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/example_word.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/example_word.dart';
|
||||||
|
|
@ -1407,18 +1408,16 @@ class _MapBodyState() extends State<MapBody> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: RegionMap(
|
||||||
child: RegionMap(
|
map: map,
|
||||||
map: map,
|
selected: widget.checked ? null : _picked,
|
||||||
selected: widget.checked ? null : _picked,
|
right: widget.checked ? right : null,
|
||||||
right: widget.checked ? right : null,
|
wrong: widget.checked && _picked != right
|
||||||
wrong: widget.checked && _picked != right
|
? _picked
|
||||||
? _picked
|
: null,
|
||||||
: null,
|
onRegionTapped: widget.checked
|
||||||
onRegionTapped: widget.checked
|
? null
|
||||||
? null
|
: (code) => setState(() => _picked = code),
|
||||||
: (code) => setState(() => _picked = code),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
@ -1474,10 +1473,10 @@ class const _MapCaption({
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
);
|
);
|
||||||
String? name(String? code) => switch (map.region(code)) {
|
String? name(String? code) => switch (map.region(code)) {
|
||||||
final region? =>
|
final region? => switch (regionOtherName(context, map, region)) {
|
||||||
region.localName == null
|
final other? => '${regionName(context, region)} ($other)',
|
||||||
? region.name
|
null => regionName(context, region),
|
||||||
: '${region.name} (${region.localName})',
|
},
|
||||||
null => null,
|
null => null,
|
||||||
};
|
};
|
||||||
final (text, style) = switch ((checked, name(picked), name(right))) {
|
final (text, style) = switch ((checked, name(picked), name(right))) {
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ flutter:
|
||||||
- assets/icon/halkyon_icon.png
|
- assets/icon/halkyon_icon.png
|
||||||
# Region maps for map questions (see assets/maps/ATTRIBUTION.md).
|
# Region maps for map questions (see assets/maps/ATTRIBUTION.md).
|
||||||
- assets/maps/japan_prefectures.geojson
|
- assets/maps/japan_prefectures.geojson
|
||||||
|
- assets/maps/world_countries.geojson
|
||||||
|
- assets/maps/LICENSE-CLDR.txt
|
||||||
# The bundled fonts' licences, for the licence list (see assets/fonts/ATTRIBUTION.md).
|
# The bundled fonts' licences, for the licence list (see assets/fonts/ATTRIBUTION.md).
|
||||||
- assets/fonts/OFL-NotoSans.txt
|
- assets/fonts/OFL-NotoSans.txt
|
||||||
- assets/fonts/OFL-NotoSansCJK.txt
|
- assets/fonts/OFL-NotoSansCJK.txt
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ void main() {
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
GeoMapRepository? maps,
|
GeoMapRepository? maps,
|
||||||
Size size = const Size(1400, 1600),
|
Size size = const Size(1400, 1600),
|
||||||
|
String? frontLanguage,
|
||||||
}) async {
|
}) async {
|
||||||
tester.view.physicalSize = size;
|
tester.view.physicalSize = size;
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|
@ -89,7 +90,11 @@ void main() {
|
||||||
..receiveRemoteChange(
|
..receiveRemoteChange(
|
||||||
'japanese',
|
'japanese',
|
||||||
encodeStudysetPayload(
|
encodeStudysetPayload(
|
||||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
StudysetContent(
|
||||||
|
title: 'Japanese',
|
||||||
|
description: RichContent.empty(),
|
||||||
|
frontLanguage: frontLanguage,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await pumpApp(
|
await pumpApp(
|
||||||
|
|
@ -576,4 +581,35 @@ void main() {
|
||||||
expect(saved.answers.single.content.plainText, 'Tokyo');
|
expect(saved.answers.single.content.plainText, 'Tokyo');
|
||||||
expect(saved.back, RichContent.empty());
|
expect(saved.back, RichContent.empty());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets("a map question can be on the world's countries; changing the "
|
||||||
|
'map drops the region picked on the other', (tester) async {
|
||||||
|
await cards.create('japanese', card('Where is Cyprus?', 'east'));
|
||||||
|
await showStudyset(
|
||||||
|
tester,
|
||||||
|
maps: await loadedMaps(tester),
|
||||||
|
frontLanguage: 'ja',
|
||||||
|
);
|
||||||
|
await open(tester, 'Where is Cyprus?');
|
||||||
|
|
||||||
|
await tester.tap(find.text('Map'));
|
||||||
|
await settle(tester);
|
||||||
|
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
||||||
|
await settle(tester);
|
||||||
|
await tester.ensureVisible(find.text('Countries of the world'));
|
||||||
|
await tester.tap(find.text('Countries of the world'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('No region picked yet'), findsOneWidget);
|
||||||
|
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('CY');
|
||||||
|
await settle(tester);
|
||||||
|
// Beside the app's language, the studyset's: Japanese.
|
||||||
|
expect(find.text('Cyprus · キプロス'), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(BackButton));
|
||||||
|
await settle(tester);
|
||||||
|
|
||||||
|
final saved = (await cards.cardsOf('japanese')).single.content;
|
||||||
|
expect(saved.map, 'world');
|
||||||
|
expect(saved.answers.single.region, 'CY');
|
||||||
|
expect(saved.answers.single.content.plainText, 'Cyprus');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue