diff --git a/Nuvolari/.gitignore b/Nuvolari/.gitignore index 57d2d60..edbdf50 100644 --- a/Nuvolari/.gitignore +++ b/Nuvolari/.gitignore @@ -62,3 +62,6 @@ backend/.cache/ # Editors .idea/ + +# Downloaded source archives for tool/ generators. +tool/.cache/ diff --git a/Nuvolari/CLAUDE.md b/Nuvolari/CLAUDE.md index 8aefd0b..4a6ec8b 100644 --- a/Nuvolari/CLAUDE.md +++ b/Nuvolari/CLAUDE.md @@ -10,8 +10,8 @@ regions via configuration. In scope: - Animated precipitation radar over an OpenStreetMap base map -- Saved places and optional device location, used to frame the map and later to anchor - notifications +- Saved places, municipality and coordinate search, and optional device location with a + live position marker; used to frame the map and later to anchor notifications - Ground-station rain accumulations and 72-hour temperature from the ARPA realtime API, published through the worker - ARPA Piemonte official alert bulletin (XML-CAP), republished verbatim @@ -73,6 +73,10 @@ grounds that there are no ads today. - Base map: OpenFreeMap (https://openfreemap.org), free OSM vector tiles with no API key and no request limits. Mandatory credits: "© OpenMapTiles" and "© OpenStreetMap contributors" (ODbL). OpenFreeMap's own credit is optional. +- Municipalities: 1180 Piedmont comuni bundled from Istat boundary shapefiles (CC BY 4.0), + generated by tool/generate_places.py. Offline search, no geocoding service. The source + DBF is UTF-8 despite appearances and the geometry is UTM 32N — see docs/data-sources.md + before regenerating. ## Verification loop (run after each milestone) - `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle` diff --git a/Nuvolari/app/assets/regions/piemonte.json b/Nuvolari/app/assets/regions/piemonte.json index 80291fd..a34b8d7 100644 --- a/Nuvolari/app/assets/regions/piemonte.json +++ b/Nuvolari/app/assets/regions/piemonte.json @@ -2,42 +2,136 @@ "id": "piemonte", "displayName": "Piemonte", "timeZone": "Europe/Rome", - - "bbox": [6.55, 43.95, 9.30, 46.55], - + "bbox": [ + 6.55, + 43.95, + 9.3, + 46.55 + ], "map": { - "center": [7.95, 45.25], - "zoom": { "min": 6.0, "max": 13.0, "initial": 7.2 } + "center": [ + 7.95, + 45.25 + ], + "zoom": { + "min": 6.0, + "max": 13.0, + "initial": 7.2 + } }, - "sources": { "radar": { "defaultAdapter": "mock", - "availableAdapters": ["mock", "dpc"], + "availableAdapters": [ + "mock", + "dpc" + ], "product": "VMI", "frameIntervalMinutes": 5 }, "alerts": { "defaultAdapter": "arpaCap", - "availableAdapters": ["arpaCap"], + "availableAdapters": [ + "arpaCap" + ], "officialBulletinUrl": "https://www.arpa.piemonte.it/rischi_naturali/boll/bollettino_allerta.pdf" } }, - "alertZones": [ - { "code": "Piem-A", "name": "Toce", "provinces": ["NO", "VB"] }, - { "code": "Piem-B", "name": "Val Sesia, Cervo e Chiusella", "provinces": ["BI", "NO", "TO", "VC"] }, - { "code": "Piem-C", "name": "Valli Orco, Lanzo, bassa Val Susa e Sangone", "provinces": ["TO"] }, - { "code": "Piem-D", "name": "Alta Val Susa, Valli Chisone, Pellice e Po", "provinces": ["CN", "TO"] }, - { "code": "Piem-E", "name": "Valli Varaita, Maira e Stura", "provinces": ["CN"] }, - { "code": "Piem-F", "name": "Valle Tanaro", "provinces": ["CN"] }, - { "code": "Piem-G", "name": "Belbo e Bormida", "provinces": ["AL", "AT", "CN"] }, - { "code": "Piem-H", "name": "Scrivia", "provinces": ["AL"] }, - { "code": "Piem-I", "name": "Pianura Settentrionale", "provinces": ["AL", "AT", "BI", "NO", "TO", "VC"] }, - { "code": "Piem-L", "name": "Pianura Torinese e Colline", "provinces": ["AL", "AT", "CN", "TO"] }, - { "code": "Piem-M", "name": "Pianura Cuneese", "provinces": ["CN", "TO"] } + { + "code": "Piem-A", + "name": "Toce", + "provinces": [ + "NO", + "VB" + ] + }, + { + "code": "Piem-B", + "name": "Val Sesia, Cervo e Chiusella", + "provinces": [ + "BI", + "NO", + "TO", + "VC" + ] + }, + { + "code": "Piem-C", + "name": "Valli Orco, Lanzo, bassa Val Susa e Sangone", + "provinces": [ + "TO" + ] + }, + { + "code": "Piem-D", + "name": "Alta Val Susa, Valli Chisone, Pellice e Po", + "provinces": [ + "CN", + "TO" + ] + }, + { + "code": "Piem-E", + "name": "Valli Varaita, Maira e Stura", + "provinces": [ + "CN" + ] + }, + { + "code": "Piem-F", + "name": "Valle Tanaro", + "provinces": [ + "CN" + ] + }, + { + "code": "Piem-G", + "name": "Belbo e Bormida", + "provinces": [ + "AL", + "AT", + "CN" + ] + }, + { + "code": "Piem-H", + "name": "Scrivia", + "provinces": [ + "AL" + ] + }, + { + "code": "Piem-I", + "name": "Pianura Settentrionale", + "provinces": [ + "AL", + "AT", + "BI", + "NO", + "TO", + "VC" + ] + }, + { + "code": "Piem-L", + "name": "Pianura Torinese e Colline", + "provinces": [ + "AL", + "AT", + "CN", + "TO" + ] + }, + { + "code": "Piem-M", + "name": "Pianura Cuneese", + "provinces": [ + "CN", + "TO" + ] + } ], - "attributions": [ { "id": "dpc", @@ -68,6 +162,12 @@ "text": "OpenFreeMap", "license": "MIT", "url": "https://openfreemap.org/" + }, + { + "id": "istat", + "text": "Istat — Confini delle unità amministrative", + "license": "CC BY 4.0", + "url": "https://www.istat.it/notizia/confini-delle-unita-amministrative-a-fini-statistici/" } ] } diff --git a/Nuvolari/app/assets/regions/piemonte_comuni.json b/Nuvolari/app/assets/regions/piemonte_comuni.json new file mode 100644 index 0000000..0e00469 --- /dev/null +++ b/Nuvolari/app/assets/regions/piemonte_comuni.json @@ -0,0 +1 @@ +{"region":"piemonte","source":"Istat — Confini delle unità amministrative a fini statistici, 1 gennaio 2025","license":"CC BY 4.0","sourceUrl":"https://www.istat.it/storage/cartografia/confini_amministrativi/generalizzati/2025/Limiti01012025_g.zip","places":[{"name":"Acceglio","province":"CN","istat":"004001","lat":44.48728,"lng":6.95036},{"name":"Acqui Terme","province":"AL","istat":"006001","lat":44.68126,"lng":8.46393},{"name":"Agliano Terme","province":"AT","istat":"005001","lat":44.7876,"lng":8.24683},{"name":"Agliè","province":"TO","istat":"001001","lat":45.36612,"lng":7.77612},{"name":"Agrate Conturbia","province":"NO","istat":"003001","lat":45.66648,"lng":8.56946},{"name":"Ailoche","province":"BI","istat":"096001","lat":45.70551,"lng":8.21718},{"name":"Airasca","province":"TO","istat":"001002","lat":44.92174,"lng":7.48324},{"name":"Aisone","province":"CN","istat":"004002","lat":44.28751,"lng":7.22145},{"name":"Ala di Stura","province":"TO","istat":"001003","lat":45.30691,"lng":7.28208},{"name":"Alagna Valsesia","province":"VC","istat":"002002","lat":45.84326,"lng":7.9172},{"name":"Alba","province":"CN","istat":"004003","lat":44.68793,"lng":8.0309},{"name":"Albano Vercellese","province":"VC","istat":"002003","lat":45.42548,"lng":8.38114},{"name":"Albaretto della Torre","province":"CN","istat":"004004","lat":44.59791,"lng":8.06126},{"name":"Albera Ligure","province":"AL","istat":"006002","lat":44.70799,"lng":9.10394},{"name":"Albiano d'Ivrea","province":"TO","istat":"001004","lat":45.43177,"lng":7.94383},{"name":"Albugnano","province":"AT","istat":"005002","lat":45.0802,"lng":7.97516},{"name":"Alessandria","province":"AL","istat":"006003","lat":44.90902,"lng":8.64443},{"name":"Alfiano Natta","province":"AL","istat":"006004","lat":45.04098,"lng":8.20845},{"name":"Alice Bel Colle","province":"AL","istat":"006005","lat":44.72006,"lng":8.44991},{"name":"Alice Castello","province":"VC","istat":"002004","lat":45.36941,"lng":8.08591},{"name":"Alluvioni Piovera","province":"AL","istat":"006192","lat":44.96812,"lng":8.75398},{"name":"Almese","province":"TO","istat":"001006","lat":45.11825,"lng":7.41999},{"name":"Alpette","province":"TO","istat":"001007","lat":45.40646,"lng":7.57684},{"name":"Alpignano","province":"TO","istat":"001008","lat":45.10123,"lng":7.51688},{"name":"Altavilla Monferrato","province":"AL","istat":"006007","lat":44.98511,"lng":8.37922},{"name":"Alto","province":"CN","istat":"004005","lat":44.11888,"lng":7.99052},{"name":"Alto Sermenza","province":"VC","istat":"002170","lat":45.87651,"lng":8.02916},{"name":"Alzano Scrivia","province":"AL","istat":"006008","lat":45.01567,"lng":8.88441},{"name":"Ameno","province":"NO","istat":"003002","lat":45.7882,"lng":8.45147},{"name":"Andezeno","province":"TO","istat":"001009","lat":45.03583,"lng":7.86621},{"name":"Andorno Micca","province":"BI","istat":"096002","lat":45.61322,"lng":8.06544},{"name":"Andrate","province":"TO","istat":"001010","lat":45.54626,"lng":7.88596},{"name":"Angrogna","province":"TO","istat":"001011","lat":44.86086,"lng":7.17705},{"name":"Antignano","province":"AT","istat":"005003","lat":44.84068,"lng":8.1374},{"name":"Antrona Schieranco","province":"VB","istat":"103001","lat":46.05718,"lng":8.08545},{"name":"Anzola d'Ossola","province":"VB","istat":"103002","lat":45.9705,"lng":8.34027},{"name":"Aramengo","province":"AT","istat":"005004","lat":45.09865,"lng":7.9933},{"name":"Arborio","province":"VC","istat":"002006","lat":45.49131,"lng":8.37525},{"name":"Argentera","province":"CN","istat":"004006","lat":44.37824,"lng":6.94935},{"name":"Arguello","province":"CN","istat":"004007","lat":44.58855,"lng":8.09894},{"name":"Arignano","province":"TO","istat":"001012","lat":45.03808,"lng":7.89871},{"name":"Arizzano","province":"VB","istat":"103003","lat":45.9553,"lng":8.58177},{"name":"Armeno","province":"NO","istat":"003006","lat":45.83188,"lng":8.46399},{"name":"Arola","province":"VB","istat":"103004","lat":45.81563,"lng":8.34484},{"name":"Arona","province":"NO","istat":"003008","lat":45.75987,"lng":8.54308},{"name":"Arquata Scrivia","province":"AL","istat":"006009","lat":44.67023,"lng":8.88914},{"name":"Asigliano Vercellese","province":"VC","istat":"002007","lat":45.26486,"lng":8.41074},{"name":"Asti","province":"AT","istat":"005005","lat":44.91342,"lng":8.19439},{"name":"Aurano","province":"VB","istat":"103005","lat":46.02484,"lng":8.58041},{"name":"Avigliana","province":"TO","istat":"001013","lat":45.07508,"lng":7.39222},{"name":"Avolasca","province":"AL","istat":"006010","lat":44.81181,"lng":8.97036},{"name":"Azeglio","province":"TO","istat":"001014","lat":45.42235,"lng":7.99315},{"name":"Azzano d'Asti","province":"AT","istat":"005006","lat":44.8822,"lng":8.26652},{"name":"Baceno","province":"VB","istat":"103006","lat":46.3184,"lng":8.27985},{"name":"Bagnasco","province":"CN","istat":"004008","lat":44.2929,"lng":8.04737},{"name":"Bagnolo Piemonte","province":"CN","istat":"004009","lat":44.75297,"lng":7.25434},{"name":"Bairo","province":"TO","istat":"001015","lat":45.37667,"lng":7.74779},{"name":"Balangero","province":"TO","istat":"001016","lat":45.27695,"lng":7.51946},{"name":"Baldichieri d'Asti","province":"AT","istat":"005007","lat":44.90697,"lng":8.09431},{"name":"Baldissero Canavese","province":"TO","istat":"001017","lat":45.40631,"lng":7.74493},{"name":"Baldissero Torinese","province":"TO","istat":"001018","lat":45.06979,"lng":7.80465},{"name":"Baldissero d'Alba","province":"CN","istat":"004010","lat":44.7656,"lng":7.88944},{"name":"Balme","province":"TO","istat":"001019","lat":45.29995,"lng":7.18708},{"name":"Balmuccia","province":"VC","istat":"002008","lat":45.8211,"lng":8.14198},{"name":"Balocco","province":"VC","istat":"002009","lat":45.46156,"lng":8.28522},{"name":"Balzola","province":"AL","istat":"006011","lat":45.18498,"lng":8.41177},{"name":"Banchette","province":"TO","istat":"001020","lat":45.45777,"lng":7.8563},{"name":"Bannio Anzino","province":"VB","istat":"103007","lat":45.95653,"lng":8.13694},{"name":"Barbania","province":"TO","istat":"001021","lat":45.29436,"lng":7.62062},{"name":"Barbaresco","province":"CN","istat":"004011","lat":44.71969,"lng":8.07688},{"name":"Bardonecchia","province":"TO","istat":"001022","lat":45.10081,"lng":6.73115},{"name":"Barengo","province":"NO","istat":"003012","lat":45.57031,"lng":8.51126},{"name":"Barge","province":"CN","istat":"004012","lat":44.72857,"lng":7.348},{"name":"Barolo","province":"CN","istat":"004013","lat":44.61498,"lng":7.9415},{"name":"Barone Canavese","province":"TO","istat":"001023","lat":45.31847,"lng":7.86584},{"name":"Basaluzzo","province":"AL","istat":"006012","lat":44.76737,"lng":8.71358},{"name":"Bassignana","province":"AL","istat":"006013","lat":45.00434,"lng":8.73185},{"name":"Bastia Mondovì","province":"CN","istat":"004014","lat":44.4483,"lng":7.90078},{"name":"Battifollo","province":"CN","istat":"004015","lat":44.3227,"lng":8.01488},{"name":"Baveno","province":"VB","istat":"103008","lat":45.91572,"lng":8.49352},{"name":"Bee","province":"VB","istat":"103009","lat":45.96931,"lng":8.5786},{"name":"Beinasco","province":"TO","istat":"001024","lat":45.01651,"lng":7.59037},{"name":"Beinette","province":"CN","istat":"004016","lat":44.36975,"lng":7.65114},{"name":"Belforte Monferrato","province":"AL","istat":"006014","lat":44.60563,"lng":8.67004},{"name":"Belgirate","province":"VB","istat":"103010","lat":45.84553,"lng":8.57896},{"name":"Bellino","province":"CN","istat":"004017","lat":44.57634,"lng":6.98322},{"name":"Bellinzago Novarese","province":"NO","istat":"003016","lat":45.55385,"lng":8.64235},{"name":"Belvedere Langhe","province":"CN","istat":"004018","lat":44.49972,"lng":7.97896},{"name":"Belveglio","province":"AT","istat":"005008","lat":44.83517,"lng":8.33268},{"name":"Bene Vagienna","province":"CN","istat":"004019","lat":44.53531,"lng":7.82595},{"name":"Benevello","province":"CN","istat":"004020","lat":44.62945,"lng":8.09778},{"name":"Benna","province":"BI","istat":"096003","lat":45.51306,"lng":8.13783},{"name":"Bergamasco","province":"AL","istat":"006015","lat":44.82798,"lng":8.44957},{"name":"Bergolo","province":"CN","istat":"004021","lat":44.54971,"lng":8.17703},{"name":"Bernezzo","province":"CN","istat":"004022","lat":44.38284,"lng":7.42696},{"name":"Berzano di San Pietro","province":"AT","istat":"005009","lat":45.10151,"lng":7.95119},{"name":"Berzano di Tortona","province":"AL","istat":"006016","lat":44.87517,"lng":8.95439},{"name":"Beura-Cardezza","province":"VB","istat":"103011","lat":46.05657,"lng":8.3201},{"name":"Biandrate","province":"NO","istat":"003018","lat":45.45067,"lng":8.47494},{"name":"Bianzè","province":"VC","istat":"002011","lat":45.29973,"lng":8.14192},{"name":"Bibiana","province":"TO","istat":"001025","lat":44.78803,"lng":7.28352},{"name":"Biella","province":"BI","istat":"096004","lat":45.59263,"lng":8.0199},{"name":"Bioglio","province":"BI","istat":"096005","lat":45.69333,"lng":8.04667},{"name":"Bistagno","province":"AL","istat":"006017","lat":44.67047,"lng":8.37468},{"name":"Bobbio Pellice","province":"TO","istat":"001026","lat":44.78451,"lng":7.06649},{"name":"Boca","province":"NO","istat":"003019","lat":45.68092,"lng":8.40807},{"name":"Boccioleto","province":"VC","istat":"002014","lat":45.83162,"lng":8.0811},{"name":"Bognanco","province":"VB","istat":"103012","lat":46.12897,"lng":8.17639},{"name":"Bogogno","province":"NO","istat":"003021","lat":45.66245,"lng":8.53417},{"name":"Bollengo","province":"TO","istat":"001027","lat":45.46647,"lng":7.9488},{"name":"Bolzano Novarese","province":"NO","istat":"003022","lat":45.7615,"lng":8.44757},{"name":"Bonvicino","province":"CN","istat":"004023","lat":44.50494,"lng":8.01604},{"name":"Borgaro Torinese","province":"TO","istat":"001028","lat":45.14698,"lng":7.66311},{"name":"Borghetto di Borbera","province":"AL","istat":"006018","lat":44.73496,"lng":8.96798},{"name":"Borgiallo","province":"TO","istat":"001029","lat":45.42801,"lng":7.65951},{"name":"Borgo San Dalmazzo","province":"CN","istat":"004025","lat":44.32959,"lng":7.47137},{"name":"Borgo San Martino","province":"AL","istat":"006020","lat":45.0924,"lng":8.52965},{"name":"Borgo Ticino","province":"NO","istat":"003025","lat":45.692,"lng":8.5991},{"name":"Borgo Vercelli","province":"VC","istat":"002017","lat":45.36043,"lng":8.46649},{"name":"Borgo d'Ale","province":"VC","istat":"002015","lat":45.35055,"lng":8.05262},{"name":"Borgofranco d'Ivrea","province":"TO","istat":"001030","lat":45.51416,"lng":7.85026},{"name":"Borgolavezzaro","province":"NO","istat":"003023","lat":45.31649,"lng":8.69196},{"name":"Borgomale","province":"CN","istat":"004024","lat":44.62986,"lng":8.12171},{"name":"Borgomanero","province":"NO","istat":"003024","lat":45.69798,"lng":8.46967},{"name":"Borgomasino","province":"TO","istat":"001031","lat":45.36263,"lng":7.98033},{"name":"Borgomezzavalle","province":"VB","istat":"103078","lat":46.04707,"lng":8.18817},{"name":"Borgone Susa","province":"TO","istat":"001032","lat":45.12174,"lng":7.24514},{"name":"Borgoratto Alessandrino","province":"AL","istat":"006019","lat":44.83074,"lng":8.51936},{"name":"Borgosesia","province":"VC","istat":"002016","lat":45.73089,"lng":8.25967},{"name":"Borriana","province":"BI","istat":"096006","lat":45.50677,"lng":8.03819},{"name":"Bosco Marengo","province":"AL","istat":"006021","lat":44.81943,"lng":8.70549},{"name":"Bosconero","province":"TO","istat":"001033","lat":45.26976,"lng":7.76754},{"name":"Bosia","province":"CN","istat":"004026","lat":44.59858,"lng":8.14172},{"name":"Bosio","province":"AL","istat":"006022","lat":44.5751,"lng":8.78409},{"name":"Bossolasco","province":"CN","istat":"004027","lat":44.53194,"lng":8.05087},{"name":"Boves","province":"CN","istat":"004028","lat":44.30747,"lng":7.55957},{"name":"Bozzole","province":"AL","istat":"006023","lat":45.07339,"lng":8.62182},{"name":"Bra","province":"CN","istat":"004029","lat":44.70706,"lng":7.82501},{"name":"Brandizzo","province":"TO","istat":"001034","lat":45.17357,"lng":7.83437},{"name":"Briaglia","province":"CN","istat":"004030","lat":44.40154,"lng":7.87444},{"name":"Bricherasio","province":"TO","istat":"001035","lat":44.83244,"lng":7.29793},{"name":"Briga Alta","province":"CN","istat":"004031","lat":44.13268,"lng":7.71129},{"name":"Briga Novarese","province":"NO","istat":"003026","lat":45.73165,"lng":8.45747},{"name":"Brignano-Frascata","province":"AL","istat":"006024","lat":44.81028,"lng":9.04448},{"name":"Briona","province":"NO","istat":"003027","lat":45.53309,"lng":8.50449},{"name":"Brondello","province":"CN","istat":"004032","lat":44.59862,"lng":7.39967},{"name":"Brossasco","province":"CN","istat":"004033","lat":44.58198,"lng":7.32778},{"name":"Brosso","province":"TO","istat":"001036","lat":45.51001,"lng":7.80188},{"name":"Brovello-Carpugnino","province":"VB","istat":"103013","lat":45.84899,"lng":8.52717},{"name":"Brozolo","province":"TO","istat":"001037","lat":45.11439,"lng":8.06957},{"name":"Bruino","province":"TO","istat":"001038","lat":45.01412,"lng":7.47443},{"name":"Bruno","province":"AT","istat":"005010","lat":44.79667,"lng":8.44729},{"name":"Brusasco","province":"TO","istat":"001039","lat":45.15167,"lng":8.06209},{"name":"Brusnengo","province":"BI","istat":"096007","lat":45.57245,"lng":8.26717},{"name":"Bruzolo","province":"TO","istat":"001040","lat":45.15851,"lng":7.19878},{"name":"Bubbio","province":"AT","istat":"005011","lat":44.65817,"lng":8.29311},{"name":"Buriasco","province":"TO","istat":"001041","lat":44.87455,"lng":7.42424},{"name":"Burolo","province":"TO","istat":"001042","lat":45.48429,"lng":7.92688},{"name":"Buronzo","province":"VC","istat":"002021","lat":45.48424,"lng":8.26375},{"name":"Busano","province":"TO","istat":"001043","lat":45.32239,"lng":7.661},{"name":"Busca","province":"CN","istat":"004034","lat":44.50925,"lng":7.44985},{"name":"Bussoleno","province":"TO","istat":"001044","lat":45.1313,"lng":7.13654},{"name":"Buttigliera Alta","province":"TO","istat":"001045","lat":45.07361,"lng":7.43527},{"name":"Buttigliera d'Asti","province":"AT","istat":"005012","lat":45.00641,"lng":7.95405},{"name":"Cabella Ligure","province":"AL","istat":"006025","lat":44.66963,"lng":9.13739},{"name":"Cafasse","province":"TO","istat":"001046","lat":45.24423,"lng":7.508},{"name":"Calamandrana","province":"AT","istat":"005013","lat":44.73503,"lng":8.3362},{"name":"Calasca-Castiglione","province":"VB","istat":"103014","lat":45.99956,"lng":8.18401},{"name":"Callabiana","province":"BI","istat":"096008","lat":45.64109,"lng":8.08157},{"name":"Calliano Monferrato","province":"AT","istat":"005014","lat":45.00282,"lng":8.25161},{"name":"Calosso","province":"AT","istat":"005015","lat":44.74541,"lng":8.23512},{"name":"Caltignaga","province":"NO","istat":"003030","lat":45.52665,"lng":8.57625},{"name":"Caluso","province":"TO","istat":"001047","lat":45.28126,"lng":7.88272},{"name":"Camagna Monferrato","province":"AL","istat":"006026","lat":45.02168,"lng":8.43398},{"name":"Camandona","province":"BI","istat":"096009","lat":45.64506,"lng":8.09077},{"name":"Cambiano","province":"TO","istat":"001048","lat":44.97822,"lng":7.77597},{"name":"Cambiasca","province":"VB","istat":"103015","lat":45.96907,"lng":8.54237},{"name":"Camburzano","province":"BI","istat":"096010","lat":45.54337,"lng":8.00051},{"name":"Camerana","province":"CN","istat":"004035","lat":44.42669,"lng":8.13558},{"name":"Camerano Casasco","province":"AT","istat":"005016","lat":44.98406,"lng":8.08774},{"name":"Cameri","province":"NO","istat":"003032","lat":45.51442,"lng":8.66522},{"name":"Camino","province":"AL","istat":"006027","lat":45.1614,"lng":8.27501},{"name":"Campertogno","province":"VC","istat":"002025","lat":45.79126,"lng":7.99144},{"name":"Campiglia Cervo","province":"BI","istat":"096086","lat":45.66099,"lng":8.00172},{"name":"Campiglione Fenile","province":"TO","istat":"001049","lat":44.80478,"lng":7.32725},{"name":"Canale","province":"CN","istat":"004037","lat":44.80013,"lng":8.00502},{"name":"Candelo","province":"BI","istat":"096012","lat":45.53828,"lng":8.12359},{"name":"Candia Canavese","province":"TO","istat":"001050","lat":45.33025,"lng":7.89719},{"name":"Candiolo","province":"TO","istat":"001051","lat":44.96374,"lng":7.57837},{"name":"Canelli","province":"AT","istat":"005017","lat":44.72206,"lng":8.28293},{"name":"Canischio","province":"TO","istat":"001052","lat":45.38051,"lng":7.57761},{"name":"Cannero Riviera","province":"VB","istat":"103016","lat":46.01429,"lng":8.67491},{"name":"Cannobio","province":"VB","istat":"103017","lat":46.06814,"lng":8.67162},{"name":"Canosio","province":"CN","istat":"004038","lat":44.42085,"lng":7.0542},{"name":"Cantalupa","province":"TO","istat":"001053","lat":44.95165,"lng":7.33281},{"name":"Cantalupo Ligure","province":"AL","istat":"006028","lat":44.73292,"lng":9.06153},{"name":"Cantarana","province":"AT","istat":"005018","lat":44.8983,"lng":8.02837},{"name":"Cantoira","province":"TO","istat":"001054","lat":45.35074,"lng":7.38796},{"name":"Caprauna","province":"CN","istat":"004039","lat":44.11927,"lng":7.94883},{"name":"Caprezzo","province":"VB","istat":"103018","lat":45.99235,"lng":8.54934},{"name":"Capriata d'Orba","province":"AL","istat":"006029","lat":44.72902,"lng":8.69414},{"name":"Caprie","province":"TO","istat":"001055","lat":45.13373,"lng":7.34067},{"name":"Capriglio","province":"AT","istat":"005019","lat":45.0069,"lng":8.00997},{"name":"Caprile","province":"BI","istat":"096013","lat":45.74494,"lng":8.13938},{"name":"Caraglio","province":"CN","istat":"004040","lat":44.42706,"lng":7.44047},{"name":"Caramagna Piemonte","province":"CN","istat":"004041","lat":44.78571,"lng":7.73049},{"name":"Caravino","province":"TO","istat":"001056","lat":45.39945,"lng":7.9609},{"name":"Carbonara Scrivia","province":"AL","istat":"006030","lat":44.85364,"lng":8.85813},{"name":"Carcoforo","province":"VC","istat":"002029","lat":45.91436,"lng":8.04153},{"name":"Cardè","province":"CN","istat":"004042","lat":44.74003,"lng":7.47313},{"name":"Carema","province":"TO","istat":"001057","lat":45.58566,"lng":7.83059},{"name":"Carentino","province":"AL","istat":"006031","lat":44.82249,"lng":8.48503},{"name":"Caresana","province":"VC","istat":"002030","lat":45.22117,"lng":8.50851},{"name":"Caresanablot","province":"VC","istat":"002031","lat":45.36034,"lng":8.3802},{"name":"Carezzano","province":"AL","istat":"006032","lat":44.8071,"lng":8.89475},{"name":"Carignano","province":"TO","istat":"001058","lat":44.89741,"lng":7.66155},{"name":"Carisio","province":"VC","istat":"002032","lat":45.42468,"lng":8.20623},{"name":"Carmagnola","province":"TO","istat":"001059","lat":44.85837,"lng":7.73536},{"name":"Carpeneto","province":"AL","istat":"006033","lat":44.69188,"lng":8.61557},{"name":"Carpignano Sesia","province":"NO","istat":"003036","lat":45.54147,"lng":8.41973},{"name":"Carrega Ligure","province":"AL","istat":"006034","lat":44.61639,"lng":9.15543},{"name":"Carrosio","province":"AL","istat":"006035","lat":44.65614,"lng":8.837},{"name":"Carrù","province":"CN","istat":"004043","lat":44.48081,"lng":7.85394},{"name":"Cartignano","province":"CN","istat":"004044","lat":44.4882,"lng":7.28563},{"name":"Cartosio","province":"AL","istat":"006036","lat":44.59581,"lng":8.42276},{"name":"Casal Cermelli","province":"AL","istat":"006037","lat":44.82409,"lng":8.63185},{"name":"Casalbeltrame","province":"NO","istat":"003037","lat":45.42847,"lng":8.47601},{"name":"Casalborgone","province":"TO","istat":"001060","lat":45.12959,"lng":7.93563},{"name":"Casale Corte Cerro","province":"VB","istat":"103019","lat":45.92189,"lng":8.40589},{"name":"Casale Monferrato","province":"AL","istat":"006039","lat":45.12637,"lng":8.46175},{"name":"Casaleggio Boiro","province":"AL","istat":"006038","lat":44.61224,"lng":8.74127},{"name":"Casaleggio Novara","province":"NO","istat":"003039","lat":45.48752,"lng":8.50248},{"name":"Casalgrasso","province":"CN","istat":"004045","lat":44.81664,"lng":7.62528},{"name":"Casalino","province":"NO","istat":"003040","lat":45.38387,"lng":8.52769},{"name":"Casalnoceto","province":"AL","istat":"006040","lat":44.91803,"lng":8.97993},{"name":"Casalvolone","province":"NO","istat":"003041","lat":45.40066,"lng":8.46847},{"name":"Casanova Elvo","province":"VC","istat":"002033","lat":45.39785,"lng":8.29117},{"name":"Casapinta","province":"BI","istat":"096014","lat":45.61434,"lng":8.19985},{"name":"Casasco","province":"AL","istat":"006041","lat":44.82105,"lng":9.00444},{"name":"Cascinette d'Ivrea","province":"TO","istat":"001061","lat":45.48131,"lng":7.90738},{"name":"Caselette","province":"TO","istat":"001062","lat":45.10634,"lng":7.47017},{"name":"Caselle Torinese","province":"TO","istat":"001063","lat":45.1774,"lng":7.65097},{"name":"Casorzo Monferrato","province":"AT","istat":"005020","lat":45.01481,"lng":8.33556},{"name":"Cassano Spinola","province":"AL","istat":"006191","lat":44.77054,"lng":8.86934},{"name":"Cassinasco","province":"AT","istat":"005021","lat":44.69224,"lng":8.31129},{"name":"Cassine","province":"AL","istat":"006043","lat":44.74996,"lng":8.51799},{"name":"Cassinelle","province":"AL","istat":"006044","lat":44.57943,"lng":8.55751},{"name":"Castagneto Po","province":"TO","istat":"001064","lat":45.1625,"lng":7.89249},{"name":"Castagnito","province":"CN","istat":"004046","lat":44.75106,"lng":8.05255},{"name":"Castagnole Monferrato","province":"AT","istat":"005023","lat":44.95398,"lng":8.30391},{"name":"Castagnole Piemonte","province":"TO","istat":"001065","lat":44.89489,"lng":7.56647},{"name":"Castagnole delle Lanze","province":"AT","istat":"005022","lat":44.75744,"lng":8.14352},{"name":"Castel Boglione","province":"AT","istat":"005024","lat":44.72767,"lng":8.3767},{"name":"Castel Rocchero","province":"AT","istat":"005032","lat":44.72038,"lng":8.41292},{"name":"Casteldelfino","province":"CN","istat":"004047","lat":44.59204,"lng":7.08879},{"name":"Castell'Alfero","province":"AT","istat":"005025","lat":44.97864,"lng":8.20354},{"name":"Castellamonte","province":"TO","istat":"001066","lat":45.40032,"lng":7.71087},{"name":"Castellania Coppi","province":"AL","istat":"006045","lat":44.79666,"lng":8.93897},{"name":"Castellar Guidobono","province":"AL","istat":"006046","lat":44.90704,"lng":8.95023},{"name":"Castellazzo Bormida","province":"AL","istat":"006047","lat":44.83673,"lng":8.58292},{"name":"Castellazzo Novarese","province":"NO","istat":"003042","lat":45.51137,"lng":8.49468},{"name":"Castellero","province":"AT","istat":"005026","lat":44.92043,"lng":8.07208},{"name":"Castelletto Cervo","province":"BI","istat":"096015","lat":45.51945,"lng":8.24313},{"name":"Castelletto Merli","province":"AL","istat":"006050","lat":45.07538,"lng":8.2355},{"name":"Castelletto Molina","province":"AT","istat":"005027","lat":44.74408,"lng":8.43094},{"name":"Castelletto Monferrato","province":"AL","istat":"006051","lat":44.97212,"lng":8.55399},{"name":"Castelletto Stura","province":"CN","istat":"004049","lat":44.4314,"lng":7.63566},{"name":"Castelletto Uzzone","province":"CN","istat":"004050","lat":44.4925,"lng":8.18972},{"name":"Castelletto d'Erro","province":"AL","istat":"006048","lat":44.63091,"lng":8.3901},{"name":"Castelletto d'Orba","province":"AL","istat":"006049","lat":44.68845,"lng":8.70867},{"name":"Castelletto sopra Ticino","province":"NO","istat":"003043","lat":45.71132,"lng":8.62092},{"name":"Castellinaldo d'Alba","province":"CN","istat":"004051","lat":44.77513,"lng":8.03363},{"name":"Castellino Tanaro","province":"CN","istat":"004052","lat":44.4268,"lng":7.98826},{"name":"Castello di Annone","province":"AT","istat":"005028","lat":44.89399,"lng":8.33049},{"name":"Castelmagno","province":"CN","istat":"004053","lat":44.40429,"lng":7.19232},{"name":"Castelnuovo Belbo","province":"AT","istat":"005029","lat":44.80518,"lng":8.41477},{"name":"Castelnuovo Bormida","province":"AL","istat":"006052","lat":44.74202,"lng":8.55869},{"name":"Castelnuovo Calcea","province":"AT","istat":"005030","lat":44.78689,"lng":8.2867},{"name":"Castelnuovo Don Bosco","province":"AT","istat":"005031","lat":45.03582,"lng":7.97777},{"name":"Castelnuovo Nigra","province":"TO","istat":"001067","lat":45.4668,"lng":7.67915},{"name":"Castelnuovo Scrivia","province":"AL","istat":"006053","lat":44.97513,"lng":8.87461},{"name":"Castelnuovo di Ceva","province":"CN","istat":"004054","lat":44.35085,"lng":8.13263},{"name":"Castelspina","province":"AL","istat":"006054","lat":44.8006,"lng":8.59652},{"name":"Castiglione Falletto","province":"CN","istat":"004055","lat":44.62937,"lng":7.97288},{"name":"Castiglione Tinella","province":"CN","istat":"004056","lat":44.73051,"lng":8.19178},{"name":"Castiglione Torinese","province":"TO","istat":"001068","lat":45.11277,"lng":7.8035},{"name":"Castino","province":"CN","istat":"004057","lat":44.62489,"lng":8.16028},{"name":"Cavaglietto","province":"NO","istat":"003044","lat":45.59899,"lng":8.49834},{"name":"Cavaglio d'Agogna","province":"NO","istat":"003045","lat":45.61057,"lng":8.47341},{"name":"Cavaglià","province":"BI","istat":"096016","lat":45.40257,"lng":8.10995},{"name":"Cavagnolo","province":"TO","istat":"001069","lat":45.1455,"lng":8.03418},{"name":"Cavallerleone","province":"CN","istat":"004058","lat":44.73513,"lng":7.64899},{"name":"Cavallermaggiore","province":"CN","istat":"004059","lat":44.70826,"lng":7.69872},{"name":"Cavallirio","province":"NO","istat":"003047","lat":45.6624,"lng":8.4033},{"name":"Cavatore","province":"AL","istat":"006055","lat":44.6321,"lng":8.45676},{"name":"Cavour","province":"TO","istat":"001070","lat":44.79469,"lng":7.3887},{"name":"Cella Monte","province":"AL","istat":"006056","lat":45.0653,"lng":8.38457},{"name":"Cellarengo","province":"AT","istat":"005033","lat":44.87267,"lng":7.92865},{"name":"Celle Enomondo","province":"AT","istat":"005034","lat":44.85843,"lng":8.11601},{"name":"Celle di Macra","province":"CN","istat":"004060","lat":44.45867,"lng":7.17544},{"name":"Cellio con Breia","province":"VC","istat":"002171","lat":45.76087,"lng":8.31202},{"name":"Centallo","province":"CN","istat":"004061","lat":44.48825,"lng":7.59288},{"name":"Ceppo Morelli","province":"VB","istat":"103021","lat":45.97636,"lng":8.05183},{"name":"Cerano","province":"NO","istat":"003049","lat":45.40437,"lng":8.79034},{"name":"Cercenasco","province":"TO","istat":"001071","lat":44.8663,"lng":7.5025},{"name":"Ceres","province":"TO","istat":"001072","lat":45.32061,"lng":7.36249},{"name":"Cereseto","province":"AL","istat":"006057","lat":45.08938,"lng":8.32277},{"name":"Ceresole Alba","province":"CN","istat":"004062","lat":44.8182,"lng":7.81653},{"name":"Ceresole Reale","province":"TO","istat":"001073","lat":45.44451,"lng":7.19067},{"name":"Cerreto Grue","province":"AL","istat":"006058","lat":44.83821,"lng":8.93029},{"name":"Cerreto d'Asti","province":"AT","istat":"005035","lat":45.04812,"lng":8.03045},{"name":"Cerretto Langhe","province":"CN","istat":"004063","lat":44.57733,"lng":8.07767},{"name":"Cerrina Monferrato","province":"AL","istat":"006059","lat":45.11258,"lng":8.20962},{"name":"Cerrione","province":"BI","istat":"096018","lat":45.47511,"lng":8.07122},{"name":"Cerro Tanaro","province":"AT","istat":"005036","lat":44.87712,"lng":8.36762},{"name":"Cervasca","province":"CN","istat":"004064","lat":44.38554,"lng":7.48098},{"name":"Cervatto","province":"VC","istat":"002041","lat":45.87461,"lng":8.14286},{"name":"Cervere","province":"CN","istat":"004065","lat":44.62615,"lng":7.77223},{"name":"Cesana Torinese","province":"TO","istat":"001074","lat":44.9276,"lng":6.82041},{"name":"Cesara","province":"VB","istat":"103022","lat":45.83281,"lng":8.35551},{"name":"Cessole","province":"AT","istat":"005037","lat":44.64547,"lng":8.2511},{"name":"Ceva","province":"CN","istat":"004066","lat":44.37577,"lng":8.02631},{"name":"Cherasco","province":"CN","istat":"004067","lat":44.64536,"lng":7.83269},{"name":"Chialamberto","province":"TO","istat":"001075","lat":45.37759,"lng":7.33952},{"name":"Chianocco","province":"TO","istat":"001076","lat":45.16648,"lng":7.17093},{"name":"Chiaverano","province":"TO","istat":"001077","lat":45.50423,"lng":7.90231},{"name":"Chieri","province":"TO","istat":"001078","lat":45.00364,"lng":7.828},{"name":"Chiesanuova","province":"TO","istat":"001079","lat":45.4227,"lng":7.64764},{"name":"Chiomonte","province":"TO","istat":"001080","lat":45.10224,"lng":6.98591},{"name":"Chiusa di Pesio","province":"CN","istat":"004068","lat":44.26034,"lng":7.67083},{"name":"Chiusa di San Michele","province":"TO","istat":"001081","lat":45.0987,"lng":7.32318},{"name":"Chiusano d'Asti","province":"AT","istat":"005038","lat":44.98326,"lng":8.12043},{"name":"Chivasso","province":"TO","istat":"001082","lat":45.20589,"lng":7.89587},{"name":"Ciconio","province":"TO","istat":"001083","lat":45.32973,"lng":7.76118},{"name":"Cigliano","province":"VC","istat":"002042","lat":45.28721,"lng":8.01902},{"name":"Cigliè","province":"CN","istat":"004069","lat":44.43697,"lng":7.92657},{"name":"Cinaglio","province":"AT","istat":"005039","lat":44.96795,"lng":8.09582},{"name":"Cintano","province":"TO","istat":"001084","lat":45.44293,"lng":7.68128},{"name":"Cinzano","province":"TO","istat":"001085","lat":45.09324,"lng":7.92232},{"name":"Ciriè","province":"TO","istat":"001086","lat":45.21968,"lng":7.59173},{"name":"Cissone","province":"CN","istat":"004070","lat":44.5628,"lng":8.02926},{"name":"Cisterna d'Asti","province":"AT","istat":"005040","lat":44.83453,"lng":8.00177},{"name":"Civiasco","province":"VC","istat":"002043","lat":45.81341,"lng":8.30694},{"name":"Clavesana","province":"CN","istat":"004071","lat":44.47811,"lng":7.92425},{"name":"Claviere","province":"TO","istat":"001087","lat":44.92183,"lng":6.75737},{"name":"Coassolo Torinese","province":"TO","istat":"001088","lat":45.32529,"lng":7.4656},{"name":"Coazze","province":"TO","istat":"001089","lat":45.05085,"lng":7.23246},{"name":"Coazzolo","province":"AT","istat":"005041","lat":44.7287,"lng":8.15158},{"name":"Cocconato","province":"AT","istat":"005042","lat":45.09011,"lng":8.04547},{"name":"Coggiola","province":"BI","istat":"096019","lat":45.71359,"lng":8.15678},{"name":"Colazza","province":"NO","istat":"003051","lat":45.79165,"lng":8.49075},{"name":"Collegno","province":"TO","istat":"001090","lat":45.09137,"lng":7.59026},{"name":"Colleretto Castelnuovo","province":"TO","istat":"001091","lat":45.43935,"lng":7.669},{"name":"Colleretto Giacosa","province":"TO","istat":"001092","lat":45.43045,"lng":7.81247},{"name":"Collobiano","province":"VC","istat":"002045","lat":45.39711,"lng":8.3476},{"name":"Comignago","province":"NO","istat":"003052","lat":45.7156,"lng":8.56085},{"name":"Condove","province":"TO","istat":"001093","lat":45.15717,"lng":7.272},{"name":"Coniolo","province":"AL","istat":"006060","lat":45.152,"lng":8.3832},{"name":"Conzano","province":"AL","istat":"006061","lat":45.03328,"lng":8.46239},{"name":"Corio","province":"TO","istat":"001094","lat":45.32933,"lng":7.51935},{"name":"Corneliano d'Alba","province":"CN","istat":"004072","lat":44.73678,"lng":7.96141},{"name":"Corsione","province":"AT","istat":"005044","lat":45.00291,"lng":8.15439},{"name":"Cortandone","province":"AT","istat":"005045","lat":44.95612,"lng":8.058},{"name":"Cortanze","province":"AT","istat":"005046","lat":45.01769,"lng":8.08749},{"name":"Cortazzone","province":"AT","istat":"005047","lat":44.97773,"lng":8.05105},{"name":"Cortemilia","province":"CN","istat":"004073","lat":44.5797,"lng":8.18976},{"name":"Cortiglione","province":"AT","istat":"005048","lat":44.8305,"lng":8.36562},{"name":"Cossano Belbo","province":"CN","istat":"004074","lat":44.66334,"lng":8.19377},{"name":"Cossano Canavese","province":"TO","istat":"001095","lat":45.38641,"lng":8.00036},{"name":"Cossato","province":"BI","istat":"096020","lat":45.55285,"lng":8.18438},{"name":"Cossogno","province":"VB","istat":"103023","lat":46.01869,"lng":8.47624},{"name":"Cossombrato","province":"AT","istat":"005049","lat":44.98531,"lng":8.14137},{"name":"Costa Vescovato","province":"AL","istat":"006062","lat":44.81935,"lng":8.93027},{"name":"Costanzana","province":"VC","istat":"002047","lat":45.22402,"lng":8.36628},{"name":"Costigliole Saluzzo","province":"CN","istat":"004075","lat":44.56191,"lng":7.4902},{"name":"Costigliole d'Asti","province":"AT","istat":"005050","lat":44.78513,"lng":8.17773},{"name":"Cravagliana","province":"VC","istat":"002048","lat":45.86596,"lng":8.19824},{"name":"Cravanzana","province":"CN","istat":"004076","lat":44.57042,"lng":8.12133},{"name":"Craveggia","province":"VB","istat":"103024","lat":46.17532,"lng":8.50922},{"name":"Cremolino","province":"AL","istat":"006063","lat":44.63193,"lng":8.57564},{"name":"Crescentino","province":"VC","istat":"002049","lat":45.20327,"lng":8.11439},{"name":"Cressa","province":"NO","istat":"003055","lat":45.64579,"lng":8.5091},{"name":"Crevacuore","province":"BI","istat":"096021","lat":45.68003,"lng":8.24834},{"name":"Crevoladossola","province":"VB","istat":"103025","lat":46.1651,"lng":8.27887},{"name":"Crissolo","province":"CN","istat":"004077","lat":44.70024,"lng":7.12541},{"name":"Crodo","province":"VB","istat":"103026","lat":46.24328,"lng":8.29536},{"name":"Crova","province":"VC","istat":"002052","lat":45.31952,"lng":8.23797},{"name":"Cuceglio","province":"TO","istat":"001096","lat":45.35802,"lng":7.81246},{"name":"Cumiana","province":"TO","istat":"001097","lat":44.9678,"lng":7.38443},{"name":"Cuneo","province":"CN","istat":"004078","lat":44.41637,"lng":7.55293},{"name":"Cunico","province":"AT","istat":"005051","lat":45.04147,"lng":8.09083},{"name":"Cuorgnè","province":"TO","istat":"001098","lat":45.39971,"lng":7.63247},{"name":"Cureggio","province":"NO","istat":"003058","lat":45.66798,"lng":8.45487},{"name":"Curino","province":"BI","istat":"096023","lat":45.63636,"lng":8.2369},{"name":"Demonte","province":"CN","istat":"004079","lat":44.32896,"lng":7.25349},{"name":"Denice","province":"AL","istat":"006065","lat":44.60352,"lng":8.33346},{"name":"Dernice","province":"AL","istat":"006066","lat":44.76148,"lng":9.04552},{"name":"Desana","province":"VC","istat":"002054","lat":45.26156,"lng":8.34039},{"name":"Diano d'Alba","province":"CN","istat":"004080","lat":44.64843,"lng":8.02533},{"name":"Divignano","province":"NO","istat":"003060","lat":45.65555,"lng":8.59796},{"name":"Dogliani","province":"CN","istat":"004081","lat":44.5362,"lng":7.95652},{"name":"Domodossola","province":"VB","istat":"103028","lat":46.12289,"lng":8.25397},{"name":"Donato","province":"BI","istat":"096024","lat":45.54263,"lng":7.90759},{"name":"Dormelletto","province":"NO","istat":"003062","lat":45.73447,"lng":8.57388},{"name":"Dorzano","province":"BI","istat":"096025","lat":45.42616,"lng":8.10967},{"name":"Dronero","province":"CN","istat":"004082","lat":44.45517,"lng":7.32924},{"name":"Druento","province":"TO","istat":"001099","lat":45.15249,"lng":7.5689},{"name":"Druogno","province":"VB","istat":"103029","lat":46.13187,"lng":8.41231},{"name":"Dusino San Michele","province":"AT","istat":"005052","lat":44.92018,"lng":7.98181},{"name":"Elva","province":"CN","istat":"004083","lat":44.54517,"lng":7.07818},{"name":"Entracque","province":"CN","istat":"004084","lat":44.18162,"lng":7.40298},{"name":"Envie","province":"CN","istat":"004085","lat":44.69164,"lng":7.37553},{"name":"Exilles","province":"TO","istat":"001100","lat":45.10628,"lng":6.91885},{"name":"Fabbrica Curone","province":"AL","istat":"006067","lat":44.74155,"lng":9.16918},{"name":"Fara Novarese","province":"NO","istat":"003065","lat":45.5591,"lng":8.45794},{"name":"Farigliano","province":"CN","istat":"004086","lat":44.50988,"lng":7.92303},{"name":"Faule","province":"CN","istat":"004087","lat":44.79581,"lng":7.57028},{"name":"Favria","province":"TO","istat":"001101","lat":45.31072,"lng":7.68539},{"name":"Feisoglio","province":"CN","istat":"004088","lat":44.54473,"lng":8.10337},{"name":"Feletto","province":"TO","istat":"001102","lat":45.29534,"lng":7.75846},{"name":"Felizzano","province":"AL","istat":"006068","lat":44.90836,"lng":8.42863},{"name":"Fenestrelle","province":"TO","istat":"001103","lat":45.02217,"lng":7.05845},{"name":"Ferrere","province":"AT","istat":"005053","lat":44.87276,"lng":7.99254},{"name":"Fiano","province":"TO","istat":"001104","lat":45.21198,"lng":7.52884},{"name":"Fiorano Canavese","province":"TO","istat":"001105","lat":45.4663,"lng":7.82988},{"name":"Fobello","province":"VC","istat":"002057","lat":45.90813,"lng":8.1298},{"name":"Foglizzo","province":"TO","istat":"001106","lat":45.26713,"lng":7.82314},{"name":"Fontaneto d'Agogna","province":"NO","istat":"003066","lat":45.64066,"lng":8.46868},{"name":"Fontanetto Po","province":"VC","istat":"002058","lat":45.19974,"lng":8.18794},{"name":"Fontanile","province":"AT","istat":"005054","lat":44.74736,"lng":8.4117},{"name":"Formazza","province":"VB","istat":"103031","lat":46.39845,"lng":8.40031},{"name":"Formigliana","province":"VC","istat":"002059","lat":45.432,"lng":8.27996},{"name":"Forno Canavese","province":"TO","istat":"001107","lat":45.34737,"lng":7.56719},{"name":"Fossano","province":"CN","istat":"004089","lat":44.54417,"lng":7.69277},{"name":"Frabosa Soprana","province":"CN","istat":"004090","lat":44.25194,"lng":7.81309},{"name":"Frabosa Sottana","province":"CN","istat":"004091","lat":44.28074,"lng":7.77558},{"name":"Fraconalto","province":"AL","istat":"006069","lat":44.58407,"lng":8.89391},{"name":"Francavilla Bisio","province":"AL","istat":"006070","lat":44.72648,"lng":8.73859},{"name":"Frascaro","province":"AL","istat":"006071","lat":44.81938,"lng":8.52017},{"name":"Frassinello Monferrato","province":"AL","istat":"006072","lat":45.0371,"lng":8.39465},{"name":"Frassineto Po","province":"AL","istat":"006073","lat":45.13441,"lng":8.54864},{"name":"Frassinetto","province":"TO","istat":"001108","lat":45.46364,"lng":7.62027},{"name":"Frassino","province":"CN","istat":"004092","lat":44.56628,"lng":7.26772},{"name":"Fresonara","province":"AL","istat":"006074","lat":44.78317,"lng":8.69175},{"name":"Frinco","province":"AT","istat":"005055","lat":44.99572,"lng":8.17557},{"name":"Front","province":"TO","istat":"001109","lat":45.27352,"lng":7.66806},{"name":"Frossasco","province":"TO","istat":"001110","lat":44.92592,"lng":7.37262},{"name":"Frugarolo","province":"AL","istat":"006075","lat":44.85175,"lng":8.67304},{"name":"Fubine Monferrato","province":"AL","istat":"006076","lat":44.95899,"lng":8.42372},{"name":"Gabiano","province":"AL","istat":"006077","lat":45.15259,"lng":8.20638},{"name":"Gaglianico","province":"BI","istat":"096026","lat":45.53473,"lng":8.08061},{"name":"Gaiola","province":"CN","istat":"004093","lat":44.33397,"lng":7.40695},{"name":"Galliate","province":"NO","istat":"003068","lat":45.48528,"lng":8.7121},{"name":"Gamalero","province":"AL","istat":"006078","lat":44.80181,"lng":8.52713},{"name":"Gambasca","province":"CN","istat":"004094","lat":44.62313,"lng":7.33729},{"name":"Garbagna","province":"AL","istat":"006079","lat":44.78054,"lng":8.99765},{"name":"Garbagna Novarese","province":"NO","istat":"003069","lat":45.39182,"lng":8.66165},{"name":"Garessio","province":"CN","istat":"004095","lat":44.19883,"lng":7.99292},{"name":"Gargallo","province":"NO","istat":"003070","lat":45.725,"lng":8.41075},{"name":"Garzigliana","province":"TO","istat":"001111","lat":44.83361,"lng":7.36811},{"name":"Gassino Torinese","province":"TO","istat":"001112","lat":45.11776,"lng":7.83814},{"name":"Gattico-Veruno","province":"NO","istat":"003166","lat":45.70516,"lng":8.527},{"name":"Gattinara","province":"VC","istat":"002061","lat":45.60438,"lng":8.35742},{"name":"Gavi","province":"AL","istat":"006081","lat":44.68765,"lng":8.81582},{"name":"Genola","province":"CN","istat":"004096","lat":44.5886,"lng":7.66858},{"name":"Germagnano","province":"TO","istat":"001113","lat":45.25196,"lng":7.45659},{"name":"Germagno","province":"VB","istat":"103032","lat":45.89905,"lng":8.38554},{"name":"Ghemme","province":"NO","istat":"003073","lat":45.59704,"lng":8.42494},{"name":"Ghiffa","province":"VB","istat":"103033","lat":45.95823,"lng":8.61731},{"name":"Ghislarengo","province":"VC","istat":"002062","lat":45.52675,"lng":8.37465},{"name":"Giaglione","province":"TO","istat":"001114","lat":45.15004,"lng":6.95346},{"name":"Giarole","province":"AL","istat":"006082","lat":45.05871,"lng":8.56351},{"name":"Giaveno","province":"TO","istat":"001115","lat":45.0248,"lng":7.30481},{"name":"Gifflenga","province":"BI","istat":"096027","lat":45.49452,"lng":8.23462},{"name":"Gignese","province":"VB","istat":"103034","lat":45.86466,"lng":8.49877},{"name":"Givoletto","province":"TO","istat":"001116","lat":45.16228,"lng":7.4944},{"name":"Gorzegno","province":"CN","istat":"004097","lat":44.51412,"lng":8.11982},{"name":"Gottasecca","province":"CN","istat":"004098","lat":44.46047,"lng":8.17646},{"name":"Govone","province":"CN","istat":"004099","lat":44.79459,"lng":8.10378},{"name":"Gozzano","province":"NO","istat":"003076","lat":45.74885,"lng":8.42815},{"name":"Graglia","province":"BI","istat":"096028","lat":45.57134,"lng":7.9392},{"name":"Grana Monferrato","province":"AT","istat":"005056","lat":45.00193,"lng":8.29591},{"name":"Granozzo con Monticello","province":"NO","istat":"003077","lat":45.36895,"lng":8.58807},{"name":"Gravellona Toce","province":"VB","istat":"103035","lat":45.91895,"lng":8.4379},{"name":"Gravere","province":"TO","istat":"001117","lat":45.10729,"lng":7.02905},{"name":"Grazzano Badoglio","province":"AT","istat":"005057","lat":45.03639,"lng":8.30798},{"name":"Greggio","province":"VC","istat":"002065","lat":45.45597,"lng":8.38103},{"name":"Gremiasco","province":"AL","istat":"006083","lat":44.80014,"lng":9.10885},{"name":"Grignasco","province":"NO","istat":"003079","lat":45.68904,"lng":8.34471},{"name":"Grinzane Cavour","province":"CN","istat":"004100","lat":44.65939,"lng":7.99413},{"name":"Grognardo","province":"AL","istat":"006084","lat":44.62706,"lng":8.49406},{"name":"Grondona","province":"AL","istat":"006085","lat":44.69407,"lng":8.96961},{"name":"Groscavallo","province":"TO","istat":"001118","lat":45.36748,"lng":7.23022},{"name":"Grosso","province":"TO","istat":"001119","lat":45.26704,"lng":7.56334},{"name":"Grugliasco","province":"TO","istat":"001120","lat":45.05533,"lng":7.59322},{"name":"Guardabosone","province":"VC","istat":"002066","lat":45.69772,"lng":8.25374},{"name":"Guarene","province":"CN","istat":"004101","lat":44.7357,"lng":8.03286},{"name":"Guazzora","province":"AL","istat":"006086","lat":45.01487,"lng":8.84826},{"name":"Gurro","province":"VB","istat":"103036","lat":46.08058,"lng":8.54649},{"name":"Igliano","province":"CN","istat":"004102","lat":44.44115,"lng":8.01513},{"name":"Incisa Scapaccino","province":"AT","istat":"005058","lat":44.81448,"lng":8.38766},{"name":"Ingria","province":"TO","istat":"001121","lat":45.47744,"lng":7.58679},{"name":"Intragna","province":"VB","istat":"103037","lat":46.00815,"lng":8.56316},{"name":"Inverso Pinasca","province":"TO","istat":"001122","lat":44.9336,"lng":7.21402},{"name":"Invorio","province":"NO","istat":"003082","lat":45.75749,"lng":8.48022},{"name":"Isasca","province":"CN","istat":"004103","lat":44.59029,"lng":7.36979},{"name":"Isola Sant'Antonio","province":"AL","istat":"006087","lat":45.03166,"lng":8.84727},{"name":"Isola d'Asti","province":"AT","istat":"005059","lat":44.82605,"lng":8.17945},{"name":"Isolabella","province":"TO","istat":"001123","lat":44.9044,"lng":7.91081},{"name":"Issiglio","province":"TO","istat":"001124","lat":45.44886,"lng":7.73683},{"name":"Ivrea","province":"TO","istat":"001125","lat":45.45382,"lng":7.8912},{"name":"La Cassa","province":"TO","istat":"001126","lat":45.18016,"lng":7.51841},{"name":"La Loggia","province":"TO","istat":"001127","lat":44.95329,"lng":7.67578},{"name":"La Morra","province":"CN","istat":"004105","lat":44.64735,"lng":7.93185},{"name":"Lagnasco","province":"CN","istat":"004104","lat":44.63244,"lng":7.55797},{"name":"Lamporo","province":"VC","istat":"002067","lat":45.23255,"lng":8.10035},{"name":"Landiona","province":"NO","istat":"003083","lat":45.4952,"lng":8.42085},{"name":"Lanzo Torinese","province":"TO","istat":"001128","lat":45.27558,"lng":7.48014},{"name":"Lauriano","province":"TO","istat":"001129","lat":45.15267,"lng":7.98951},{"name":"Leini","province":"TO","istat":"001130","lat":45.18751,"lng":7.71976},{"name":"Lemie","province":"TO","istat":"001131","lat":45.22768,"lng":7.28384},{"name":"Lenta","province":"VC","istat":"002068","lat":45.55781,"lng":8.36718},{"name":"Lequio Berria","province":"CN","istat":"004106","lat":44.60661,"lng":8.10134},{"name":"Lequio Tanaro","province":"CN","istat":"004107","lat":44.5538,"lng":7.883},{"name":"Lerma","province":"AL","istat":"006088","lat":44.63373,"lng":8.71362},{"name":"Lesa","province":"NO","istat":"003084","lat":45.8238,"lng":8.56093},{"name":"Lesegno","province":"CN","istat":"004108","lat":44.39641,"lng":7.96391},{"name":"Lessolo","province":"TO","istat":"001132","lat":45.48237,"lng":7.82155},{"name":"Lessona","province":"BI","istat":"096085","lat":45.57224,"lng":8.21016},{"name":"Levice","province":"CN","istat":"004109","lat":44.53463,"lng":8.157},{"name":"Levone","province":"TO","istat":"001133","lat":45.32021,"lng":7.60079},{"name":"Lignana","province":"VC","istat":"002070","lat":45.28768,"lng":8.31916},{"name":"Limone Piemonte","province":"CN","istat":"004110","lat":44.1862,"lng":7.5763},{"name":"Lisio","province":"CN","istat":"004111","lat":44.30572,"lng":7.98251},{"name":"Livorno Ferraris","province":"VC","istat":"002071","lat":45.2649,"lng":8.1095},{"name":"Loazzolo","province":"AT","istat":"005060","lat":44.67152,"lng":8.25615},{"name":"Locana","province":"TO","istat":"001134","lat":45.43622,"lng":7.41072},{"name":"Lombardore","province":"TO","istat":"001135","lat":45.23511,"lng":7.72656},{"name":"Lombriasco","province":"TO","istat":"001136","lat":44.84475,"lng":7.63584},{"name":"Loranzè","province":"TO","istat":"001137","lat":45.44559,"lng":7.80888},{"name":"Loreglia","province":"VB","istat":"103038","lat":45.92241,"lng":8.36813},{"name":"Lozzolo","province":"VC","istat":"002072","lat":45.62451,"lng":8.32364},{"name":"Lu e Cuccaro Monferrato","province":"AL","istat":"006193","lat":45.00212,"lng":8.48331},{"name":"Luserna San Giovanni","province":"TO","istat":"001139","lat":44.80581,"lng":7.23816},{"name":"Lusernetta","province":"TO","istat":"001140","lat":44.79521,"lng":7.24408},{"name":"Lusigliè","province":"TO","istat":"001141","lat":45.31138,"lng":7.77029},{"name":"Macello","province":"TO","istat":"001142","lat":44.84966,"lng":7.41572},{"name":"Macra","province":"CN","istat":"004112","lat":44.50495,"lng":7.17342},{"name":"Macugnaga","province":"VB","istat":"103039","lat":45.95344,"lng":7.95578},{"name":"Madonna del Sasso","province":"VB","istat":"103040","lat":45.79044,"lng":8.34453},{"name":"Maggiora","province":"NO","istat":"003088","lat":45.69906,"lng":8.414},{"name":"Magliano Alfieri","province":"CN","istat":"004113","lat":44.76521,"lng":8.07673},{"name":"Magliano Alpi","province":"CN","istat":"004114","lat":44.45974,"lng":7.7998},{"name":"Maglione","province":"TO","istat":"001143","lat":45.35062,"lng":8.00999},{"name":"Magnano","province":"BI","istat":"096030","lat":45.46964,"lng":8.00321},{"name":"Malesco","province":"VB","istat":"103041","lat":46.09208,"lng":8.48374},{"name":"Malvicino","province":"AL","istat":"006090","lat":44.55757,"lng":8.4189},{"name":"Mandello Vitta","province":"NO","istat":"003090","lat":45.50035,"lng":8.46212},{"name":"Mango","province":"CN","istat":"004115","lat":44.68086,"lng":8.15306},{"name":"Manta","province":"CN","istat":"004116","lat":44.61453,"lng":7.48995},{"name":"Mappano","province":"TO","istat":"001316","lat":45.1509,"lng":7.7037},{"name":"Marano Ticino","province":"NO","istat":"003091","lat":45.63027,"lng":8.63023},{"name":"Maranzana","province":"AT","istat":"005061","lat":44.76295,"lng":8.48234},{"name":"Marene","province":"CN","istat":"004117","lat":44.65802,"lng":7.7325},{"name":"Marentino","province":"TO","istat":"001144","lat":45.06548,"lng":7.87942},{"name":"Maretto","province":"AT","istat":"005062","lat":44.9418,"lng":8.03873},{"name":"Margarita","province":"CN","istat":"004118","lat":44.40055,"lng":7.67468},{"name":"Marmora","province":"CN","istat":"004119","lat":44.43545,"lng":7.11518},{"name":"Marsaglia","province":"CN","istat":"004120","lat":44.45523,"lng":7.98556},{"name":"Martiniana Po","province":"CN","istat":"004121","lat":44.61629,"lng":7.36226},{"name":"Masera","province":"VB","istat":"103042","lat":46.14361,"lng":8.35326},{"name":"Masio","province":"AL","istat":"006091","lat":44.86533,"lng":8.41953},{"name":"Massazza","province":"BI","istat":"096031","lat":45.486,"lng":8.16316},{"name":"Massello","province":"TO","istat":"001145","lat":44.96872,"lng":7.0239},{"name":"Masserano","province":"BI","istat":"096032","lat":45.57013,"lng":8.24219},{"name":"Massino Visconti","province":"NO","istat":"003093","lat":45.82677,"lng":8.52996},{"name":"Massiola","province":"VB","istat":"103043","lat":45.92951,"lng":8.3137},{"name":"Mathi","province":"TO","istat":"001146","lat":45.26251,"lng":7.54327},{"name":"Mattie","province":"TO","istat":"001147","lat":45.09613,"lng":7.10693},{"name":"Mazzè","province":"TO","istat":"001148","lat":45.28431,"lng":7.93968},{"name":"Meana di Susa","province":"TO","istat":"001149","lat":45.10057,"lng":7.06588},{"name":"Meina","province":"NO","istat":"003095","lat":45.78531,"lng":8.52856},{"name":"Melazzo","province":"AL","istat":"006092","lat":44.64018,"lng":8.4212},{"name":"Melle","province":"CN","istat":"004122","lat":44.54771,"lng":7.30875},{"name":"Merana","province":"AL","istat":"006093","lat":44.51541,"lng":8.28944},{"name":"Mercenasco","province":"TO","istat":"001150","lat":45.35662,"lng":7.86936},{"name":"Mergozzo","province":"VB","istat":"103044","lat":45.97451,"lng":8.43421},{"name":"Mezzana Mortigliengo","province":"BI","istat":"096033","lat":45.62944,"lng":8.18978},{"name":"Mezzenile","province":"TO","istat":"001152","lat":45.28206,"lng":7.36684},{"name":"Mezzomerico","province":"NO","istat":"003097","lat":45.62046,"lng":8.60052},{"name":"Miagliano","province":"BI","istat":"096034","lat":45.61624,"lng":8.0424},{"name":"Miasino","province":"NO","istat":"003098","lat":45.8066,"lng":8.43487},{"name":"Miazzina","province":"VB","istat":"103045","lat":46.0161,"lng":8.5194},{"name":"Mirabello Monferrato","province":"AL","istat":"006094","lat":45.0333,"lng":8.52599},{"name":"Moasca","province":"AT","istat":"005063","lat":44.76464,"lng":8.27186},{"name":"Moiola","province":"CN","istat":"004123","lat":44.31336,"lng":7.38703},{"name":"Molare","province":"AL","istat":"006095","lat":44.57303,"lng":8.59019},{"name":"Molino dei Torti","province":"AL","istat":"006096","lat":45.02787,"lng":8.8964},{"name":"Mollia","province":"VC","istat":"002078","lat":45.8252,"lng":8.0215},{"name":"Mombaldone","province":"AT","istat":"005064","lat":44.57609,"lng":8.33065},{"name":"Mombarcaro","province":"CN","istat":"004124","lat":44.46555,"lng":8.07943},{"name":"Mombaruzzo","province":"AT","istat":"005065","lat":44.7769,"lng":8.44923},{"name":"Mombasiglio","province":"CN","istat":"004125","lat":44.35694,"lng":7.96354},{"name":"Mombello Monferrato","province":"AL","istat":"006097","lat":45.12534,"lng":8.25615},{"name":"Mombello di Torino","province":"TO","istat":"001153","lat":45.04321,"lng":7.91754},{"name":"Mombercelli","province":"AT","istat":"005066","lat":44.8272,"lng":8.29614},{"name":"Momo","province":"NO","istat":"003100","lat":45.56956,"lng":8.5645},{"name":"Mompantero","province":"TO","istat":"001154","lat":45.16873,"lng":7.07604},{"name":"Momperone","province":"AL","istat":"006098","lat":44.84047,"lng":9.03655},{"name":"Monale","province":"AT","istat":"005067","lat":44.93602,"lng":8.07782},{"name":"Monastero Bormida","province":"AT","istat":"005068","lat":44.63923,"lng":8.32737},{"name":"Monastero di Lanzo","province":"TO","istat":"001155","lat":45.32445,"lng":7.43139},{"name":"Monastero di Vasco","province":"CN","istat":"004126","lat":44.33624,"lng":7.81978},{"name":"Monasterolo Casotto","province":"CN","istat":"004127","lat":44.31424,"lng":7.93965},{"name":"Monasterolo di Savigliano","province":"CN","istat":"004128","lat":44.68207,"lng":7.60994},{"name":"Moncalieri","province":"TO","istat":"001156","lat":44.9884,"lng":7.70266},{"name":"Moncalvo","province":"AT","istat":"005069","lat":45.05067,"lng":8.27153},{"name":"Moncenisio","province":"TO","istat":"001157","lat":45.20459,"lng":6.98422},{"name":"Moncestino","province":"AL","istat":"006099","lat":45.157,"lng":8.15836},{"name":"Monchiero","province":"CN","istat":"004129","lat":44.567,"lng":7.92118},{"name":"Moncrivello","province":"VC","istat":"002079","lat":45.3235,"lng":7.99571},{"name":"Moncucco Torinese","province":"AT","istat":"005070","lat":45.07072,"lng":7.92143},{"name":"Mondovì","province":"CN","istat":"004130","lat":44.40881,"lng":7.81329},{"name":"Monesiglio","province":"CN","istat":"004131","lat":44.46399,"lng":8.12246},{"name":"Monforte d'Alba","province":"CN","istat":"004132","lat":44.58614,"lng":7.97063},{"name":"Mongardino","province":"AT","istat":"005071","lat":44.84926,"lng":8.22536},{"name":"Mongiardino Ligure","province":"AL","istat":"006100","lat":44.642,"lng":9.07264},{"name":"Mongrando","province":"BI","istat":"096035","lat":45.52178,"lng":7.99527},{"name":"Monleale","province":"AL","istat":"006101","lat":44.87117,"lng":8.97529},{"name":"Montabone","province":"AT","istat":"005072","lat":44.70016,"lng":8.39085},{"name":"Montacuto","province":"AL","istat":"006102","lat":44.76017,"lng":9.107},{"name":"Montafia","province":"AT","istat":"005073","lat":44.98505,"lng":8.01572},{"name":"Montaldeo","province":"AL","istat":"006103","lat":44.66551,"lng":8.72767},{"name":"Montaldo Bormida","province":"AL","istat":"006104","lat":44.6965,"lng":8.59249},{"name":"Montaldo Roero","province":"CN","istat":"004135","lat":44.77049,"lng":7.91626},{"name":"Montaldo Scarampi","province":"AT","istat":"005074","lat":44.83133,"lng":8.26693},{"name":"Montaldo Torinese","province":"TO","istat":"001158","lat":45.06656,"lng":7.85328},{"name":"Montaldo di Mondovì","province":"CN","istat":"004134","lat":44.30145,"lng":7.85646},{"name":"Montalenghe","province":"TO","istat":"001159","lat":45.33158,"lng":7.83443},{"name":"Montalto Dora","province":"TO","istat":"001160","lat":45.49374,"lng":7.86273},{"name":"Montanaro","province":"TO","istat":"001161","lat":45.23455,"lng":7.84996},{"name":"Montanera","province":"CN","istat":"004136","lat":44.45839,"lng":7.67457},{"name":"Montecastello","province":"AL","istat":"006105","lat":44.95806,"lng":8.69205},{"name":"Montechiaro d'Acqui","province":"AL","istat":"006106","lat":44.58997,"lng":8.38053},{"name":"Montechiaro d'Asti","province":"AT","istat":"005075","lat":45.01242,"lng":8.11398},{"name":"Montecrestese","province":"VB","istat":"103046","lat":46.21537,"lng":8.38988},{"name":"Montegioco","province":"AL","istat":"006107","lat":44.84054,"lng":8.95939},{"name":"Montegrosso d'Asti","province":"AT","istat":"005076","lat":44.81406,"lng":8.23253},{"name":"Montelupo Albese","province":"CN","istat":"004137","lat":44.62372,"lng":8.03558},{"name":"Montemagno Monferrato","province":"AT","istat":"005077","lat":44.97377,"lng":8.32984},{"name":"Montemale di Cuneo","province":"CN","istat":"004138","lat":44.434,"lng":7.36317},{"name":"Montemarzino","province":"AL","istat":"006108","lat":44.84886,"lng":8.99269},{"name":"Monterosso Grana","province":"CN","istat":"004139","lat":44.38722,"lng":7.30252},{"name":"Montescheno","province":"VB","istat":"103047","lat":46.07969,"lng":8.19527},{"name":"Monteu Roero","province":"CN","istat":"004140","lat":44.79187,"lng":7.91243},{"name":"Monteu da Po","province":"TO","istat":"001162","lat":45.16271,"lng":8.01557},{"name":"Montezemolo","province":"CN","istat":"004141","lat":44.3808,"lng":8.1336},{"name":"Monticello d'Alba","province":"CN","istat":"004142","lat":44.71242,"lng":7.94659},{"name":"Montiglio Monferrato","province":"AT","istat":"005121","lat":45.05842,"lng":8.1078},{"name":"Montà","province":"CN","istat":"004133","lat":44.82366,"lng":7.95169},{"name":"Morano sul Po","province":"AL","istat":"006109","lat":45.17233,"lng":8.35387},{"name":"Moransengo-Tonengo","province":"AT","istat":"005122","lat":45.11826,"lng":8.01636},{"name":"Morbello","province":"AL","istat":"006110","lat":44.59509,"lng":8.52139},{"name":"Moretta","province":"CN","istat":"004143","lat":44.76524,"lng":7.53828},{"name":"Moriondo Torinese","province":"TO","istat":"001163","lat":45.03538,"lng":7.93683},{"name":"Mornese","province":"AL","istat":"006111","lat":44.62717,"lng":8.75944},{"name":"Morozzo","province":"CN","istat":"004144","lat":44.42974,"lng":7.68836},{"name":"Morsasco","province":"AL","istat":"006112","lat":44.6671,"lng":8.54921},{"name":"Motta de' Conti","province":"VC","istat":"002082","lat":45.1888,"lng":8.53231},{"name":"Mottalciata","province":"BI","istat":"096037","lat":45.50279,"lng":8.20379},{"name":"Murazzano","province":"CN","istat":"004145","lat":44.47505,"lng":8.01339},{"name":"Murello","province":"CN","istat":"004146","lat":44.75551,"lng":7.60487},{"name":"Murisengo","province":"AL","istat":"006113","lat":45.08272,"lng":8.1378},{"name":"Muzzano","province":"BI","istat":"096038","lat":45.58228,"lng":7.94366},{"name":"Narzole","province":"CN","istat":"004147","lat":44.59616,"lng":7.87296},{"name":"Nebbiuno","province":"NO","istat":"003103","lat":45.809,"lng":8.51475},{"name":"Neive","province":"CN","istat":"004148","lat":44.72788,"lng":8.11567},{"name":"Netro","province":"BI","istat":"096039","lat":45.54524,"lng":7.93991},{"name":"Neviglie","province":"CN","istat":"004149","lat":44.69313,"lng":8.11889},{"name":"Nibbiola","province":"NO","istat":"003104","lat":45.3696,"lng":8.64736},{"name":"Nichelino","province":"TO","istat":"001164","lat":44.98999,"lng":7.62391},{"name":"Niella Belbo","province":"CN","istat":"004150","lat":44.51583,"lng":8.08153},{"name":"Niella Tanaro","province":"CN","istat":"004151","lat":44.40973,"lng":7.92256},{"name":"Nizza Monferrato","province":"AT","istat":"005080","lat":44.7696,"lng":8.35501},{"name":"Noasca","province":"TO","istat":"001165","lat":45.4652,"lng":7.30187},{"name":"Nole","province":"TO","istat":"001166","lat":45.24431,"lng":7.56847},{"name":"Nomaglio","province":"TO","istat":"001167","lat":45.54224,"lng":7.86141},{"name":"None","province":"TO","istat":"001168","lat":44.94015,"lng":7.54885},{"name":"Nonio","province":"VB","istat":"103048","lat":45.85299,"lng":8.36583},{"name":"Novalesa","province":"TO","istat":"001169","lat":45.19576,"lng":7.03443},{"name":"Novara","province":"NO","istat":"003106","lat":45.4393,"lng":8.61537},{"name":"Novello","province":"CN","istat":"004152","lat":44.59095,"lng":7.92016},{"name":"Novi Ligure","province":"AL","istat":"006114","lat":44.76515,"lng":8.79525},{"name":"Nucetto","province":"CN","istat":"004153","lat":44.33909,"lng":8.05577},{"name":"Occhieppo Inferiore","province":"BI","istat":"096040","lat":45.54428,"lng":8.02346},{"name":"Occhieppo Superiore","province":"BI","istat":"096041","lat":45.5709,"lng":7.99676},{"name":"Occimiano","province":"AL","istat":"006115","lat":45.06157,"lng":8.51157},{"name":"Odalengo Grande","province":"AL","istat":"006116","lat":45.10483,"lng":8.16142},{"name":"Odalengo Piccolo","province":"AL","istat":"006117","lat":45.07357,"lng":8.20561},{"name":"Oggebbio","province":"VB","istat":"103049","lat":45.9949,"lng":8.64447},{"name":"Oglianico","province":"TO","istat":"001170","lat":45.34303,"lng":7.68614},{"name":"Olcenengo","province":"VC","istat":"002088","lat":45.35582,"lng":8.31661},{"name":"Oldenico","province":"VC","istat":"002089","lat":45.39641,"lng":8.38646},{"name":"Oleggio","province":"NO","istat":"003108","lat":45.59683,"lng":8.64625},{"name":"Oleggio Castello","province":"NO","istat":"003109","lat":45.74452,"lng":8.53009},{"name":"Olivola","province":"AL","istat":"006118","lat":45.03511,"lng":8.36817},{"name":"Olmo Gentile","province":"AT","istat":"005081","lat":44.58191,"lng":8.25169},{"name":"Omegna","province":"VB","istat":"103050","lat":45.87053,"lng":8.41748},{"name":"Oncino","province":"CN","istat":"004154","lat":44.65082,"lng":7.16283},{"name":"Orbassano","province":"TO","istat":"001171","lat":45.00605,"lng":7.55583},{"name":"Orio Canavese","province":"TO","istat":"001172","lat":45.32568,"lng":7.85386},{"name":"Ormea","province":"CN","istat":"004155","lat":44.15613,"lng":7.8673},{"name":"Ornavasso","province":"VB","istat":"103051","lat":45.96285,"lng":8.3878},{"name":"Orsara Bormida","province":"AL","istat":"006119","lat":44.68832,"lng":8.56495},{"name":"Orta San Giulio","province":"NO","istat":"003112","lat":45.78988,"lng":8.41531},{"name":"Osasco","province":"TO","istat":"001173","lat":44.84863,"lng":7.34829},{"name":"Osasio","province":"TO","istat":"001174","lat":44.87398,"lng":7.61004},{"name":"Ostana","province":"CN","istat":"004156","lat":44.70714,"lng":7.19302},{"name":"Ottiglio","province":"AL","istat":"006120","lat":45.05195,"lng":8.34131},{"name":"Oulx","province":"TO","istat":"001175","lat":45.04446,"lng":6.79853},{"name":"Ovada","province":"AL","istat":"006121","lat":44.62436,"lng":8.63778},{"name":"Oviglio","province":"AL","istat":"006122","lat":44.86499,"lng":8.48202},{"name":"Ozegna","province":"TO","istat":"001176","lat":45.3444,"lng":7.74909},{"name":"Ozzano Monferrato","province":"AL","istat":"006123","lat":45.10683,"lng":8.36927},{"name":"Paderna","province":"AL","istat":"006124","lat":44.82467,"lng":8.88639},{"name":"Paesana","province":"CN","istat":"004157","lat":44.67126,"lng":7.24724},{"name":"Pagno","province":"CN","istat":"004158","lat":44.6079,"lng":7.42878},{"name":"Palazzo Canavese","province":"TO","istat":"001177","lat":45.45668,"lng":7.98019},{"name":"Palazzolo Vercellese","province":"VC","istat":"002090","lat":45.18611,"lng":8.23274},{"name":"Pallanzeno","province":"VB","istat":"103052","lat":46.042,"lng":8.2496},{"name":"Pamparato","province":"CN","istat":"004159","lat":44.2561,"lng":7.9097},{"name":"Pancalieri","province":"TO","istat":"001178","lat":44.83234,"lng":7.58383},{"name":"Parella","province":"TO","istat":"001179","lat":45.42265,"lng":7.79582},{"name":"Pareto","province":"AL","istat":"006125","lat":44.51669,"lng":8.40352},{"name":"Parodi Ligure","province":"AL","istat":"006126","lat":44.67256,"lng":8.75935},{"name":"Paroldo","province":"CN","istat":"004160","lat":44.43009,"lng":8.06972},{"name":"Paruzzaro","province":"NO","istat":"003114","lat":45.74795,"lng":8.51085},{"name":"Passerano Marmorito","province":"AT","istat":"005082","lat":45.05589,"lng":8.01295},{"name":"Pasturana","province":"AL","istat":"006127","lat":44.75062,"lng":8.74643},{"name":"Pavarolo","province":"TO","istat":"001180","lat":45.07688,"lng":7.83282},{"name":"Pavone Canavese","province":"TO","istat":"001181","lat":45.42902,"lng":7.85097},{"name":"Pecetto Torinese","province":"TO","istat":"001183","lat":45.01794,"lng":7.75301},{"name":"Pecetto di Valenza","province":"AL","istat":"006128","lat":44.98731,"lng":8.67334},{"name":"Pella","province":"NO","istat":"003115","lat":45.81041,"lng":8.38593},{"name":"Penango","province":"AT","istat":"005083","lat":45.02676,"lng":8.26174},{"name":"Perletto","province":"CN","istat":"004161","lat":44.59702,"lng":8.21647},{"name":"Perlo","province":"CN","istat":"004162","lat":44.32469,"lng":8.09237},{"name":"Perosa Argentina","province":"TO","istat":"001184","lat":44.98208,"lng":7.1881},{"name":"Perosa Canavese","province":"TO","istat":"001185","lat":45.40726,"lng":7.83481},{"name":"Perrero","province":"TO","istat":"001186","lat":44.92346,"lng":7.12222},{"name":"Pertengo","province":"VC","istat":"002091","lat":45.23346,"lng":8.41512},{"name":"Pertusio","province":"TO","istat":"001187","lat":45.35008,"lng":7.63988},{"name":"Pessinetto","province":"TO","istat":"001188","lat":45.28748,"lng":7.42639},{"name":"Pettenasco","province":"NO","istat":"003116","lat":45.82763,"lng":8.40428},{"name":"Pettinengo","province":"BI","istat":"096042","lat":45.61229,"lng":8.09697},{"name":"Peveragno","province":"CN","istat":"004163","lat":44.31069,"lng":7.6181},{"name":"Pezzana","province":"VC","istat":"002093","lat":45.26056,"lng":8.50004},{"name":"Pezzolo Valle Uzzone","province":"CN","istat":"004164","lat":44.53417,"lng":8.21512},{"name":"Pianezza","province":"TO","istat":"001189","lat":45.11385,"lng":7.55393},{"name":"Pianfei","province":"CN","istat":"004165","lat":44.37313,"lng":7.71109},{"name":"Piasco","province":"CN","istat":"004166","lat":44.56889,"lng":7.44717},{"name":"Piatto","province":"BI","istat":"096043","lat":45.58773,"lng":8.14038},{"name":"Piea","province":"AT","istat":"005084","lat":45.0212,"lng":8.06175},{"name":"Piedicavallo","province":"BI","istat":"096044","lat":45.69696,"lng":7.959},{"name":"Piedimulera","province":"VB","istat":"103053","lat":46.02347,"lng":8.24763},{"name":"Pietra Marazzi","province":"AL","istat":"006129","lat":44.94425,"lng":8.67386},{"name":"Pietraporzio","province":"CN","istat":"004167","lat":44.32715,"lng":7.00859},{"name":"Pieve Vergonte","province":"VB","istat":"103054","lat":45.98353,"lng":8.27407},{"name":"Pila","province":"VC","istat":"002096","lat":45.75719,"lng":8.07016},{"name":"Pinasca","province":"TO","istat":"001190","lat":44.9676,"lng":7.24715},{"name":"Pinerolo","province":"TO","istat":"001191","lat":44.89136,"lng":7.34753},{"name":"Pino Torinese","province":"TO","istat":"001192","lat":45.04431,"lng":7.77754},{"name":"Pino d'Asti","province":"AT","istat":"005085","lat":45.05475,"lng":7.98671},{"name":"Piobesi Torinese","province":"TO","istat":"001193","lat":44.92048,"lng":7.60321},{"name":"Piobesi d'Alba","province":"CN","istat":"004168","lat":44.73101,"lng":7.98313},{"name":"Piode","province":"VC","istat":"002097","lat":45.76035,"lng":8.03976},{"name":"Piossasco","province":"TO","istat":"001194","lat":44.9867,"lng":7.44953},{"name":"Piovà Massaia","province":"AT","istat":"005086","lat":45.04551,"lng":8.04951},{"name":"Piozzo","province":"CN","istat":"004169","lat":44.51639,"lng":7.8809},{"name":"Pisano","province":"NO","istat":"003119","lat":45.79391,"lng":8.50757},{"name":"Piscina","province":"TO","istat":"001195","lat":44.92049,"lng":7.42599},{"name":"Piverone","province":"TO","istat":"001196","lat":45.44098,"lng":8.00879},{"name":"Pocapaglia","province":"CN","istat":"004170","lat":44.72418,"lng":7.87785},{"name":"Pogno","province":"NO","istat":"003120","lat":45.75802,"lng":8.37667},{"name":"Poirino","province":"TO","istat":"001197","lat":44.90252,"lng":7.84281},{"name":"Pollone","province":"BI","istat":"096046","lat":45.5884,"lng":7.99368},{"name":"Polonghera","province":"CN","istat":"004171","lat":44.7908,"lng":7.59563},{"name":"Pomaretto","province":"TO","istat":"001198","lat":44.95037,"lng":7.17822},{"name":"Pomaro Monferrato","province":"AL","istat":"006131","lat":45.06958,"lng":8.5837},{"name":"Pombia","province":"NO","istat":"003121","lat":45.64635,"lng":8.6465},{"name":"Ponderano","province":"BI","istat":"096047","lat":45.5308,"lng":8.0505},{"name":"Pont Canavese","province":"TO","istat":"001199","lat":45.4327,"lng":7.58482},{"name":"Pontechianale","province":"CN","istat":"004172","lat":44.64552,"lng":7.02543},{"name":"Pontecurone","province":"AL","istat":"006132","lat":44.95639,"lng":8.93997},{"name":"Pontestura","province":"AL","istat":"006133","lat":45.13476,"lng":8.32862},{"name":"Ponti","province":"AL","istat":"006134","lat":44.62606,"lng":8.36249},{"name":"Ponzano Monferrato","province":"AL","istat":"006135","lat":45.0814,"lng":8.2719},{"name":"Ponzone","province":"AL","istat":"006136","lat":44.5534,"lng":8.49367},{"name":"Portacomaro","province":"AT","istat":"005087","lat":44.94863,"lng":8.26328},{"name":"Porte","province":"TO","istat":"001200","lat":44.89456,"lng":7.26766},{"name":"Portula","province":"BI","istat":"096048","lat":45.695,"lng":8.14559},{"name":"Postua","province":"VC","istat":"002102","lat":45.739,"lng":8.19894},{"name":"Pozzol Groppo","province":"AL","istat":"006137","lat":44.86864,"lng":9.03958},{"name":"Pozzolo Formigaro","province":"AL","istat":"006138","lat":44.81126,"lng":8.78921},{"name":"Pradleves","province":"CN","istat":"004173","lat":44.4136,"lng":7.26665},{"name":"Pragelato","province":"TO","istat":"001201","lat":44.98348,"lng":6.94617},{"name":"Prali","province":"TO","istat":"001202","lat":44.88153,"lng":7.04449},{"name":"Pralormo","province":"TO","istat":"001203","lat":44.85286,"lng":7.89752},{"name":"Pralungo","province":"BI","istat":"096049","lat":45.60689,"lng":8.02297},{"name":"Pramollo","province":"TO","istat":"001204","lat":44.90105,"lng":7.19076},{"name":"Prarolo","province":"VC","istat":"002104","lat":45.28134,"lng":8.48002},{"name":"Prarostino","province":"TO","istat":"001205","lat":44.86602,"lng":7.26513},{"name":"Prasco","province":"AL","istat":"006139","lat":44.64383,"lng":8.54014},{"name":"Prascorsano","province":"TO","istat":"001206","lat":45.36383,"lng":7.61953},{"name":"Pratiglione","province":"TO","istat":"001207","lat":45.3618,"lng":7.57789},{"name":"Prato Sesia","province":"NO","istat":"003122","lat":45.6636,"lng":8.36992},{"name":"Pray","province":"BI","istat":"096050","lat":45.67327,"lng":8.20869},{"name":"Prazzo","province":"CN","istat":"004174","lat":44.50207,"lng":7.04786},{"name":"Predosa","province":"AL","istat":"006140","lat":44.75431,"lng":8.63904},{"name":"Premeno","province":"VB","istat":"103055","lat":45.98282,"lng":8.59977},{"name":"Premia","province":"VB","istat":"103056","lat":46.30628,"lng":8.36211},{"name":"Premosello-Chiovenda","province":"VB","istat":"103057","lat":46.01674,"lng":8.36248},{"name":"Priero","province":"CN","istat":"004175","lat":44.36441,"lng":8.09881},{"name":"Priocca","province":"CN","istat":"004176","lat":44.7919,"lng":8.06107},{"name":"Priola","province":"CN","istat":"004177","lat":44.25332,"lng":8.03084},{"name":"Prunetto","province":"CN","istat":"004178","lat":44.49234,"lng":8.13871},{"name":"Quagliuzzo","province":"TO","istat":"001208","lat":45.42409,"lng":7.78093},{"name":"Quaranti","province":"AT","istat":"005088","lat":44.75019,"lng":8.44898},{"name":"Quaregna Cerreto","province":"BI","istat":"096087","lat":45.5802,"lng":8.16172},{"name":"Quargnento","province":"AL","istat":"006141","lat":44.95399,"lng":8.48638},{"name":"Quarna Sopra","province":"VB","istat":"103058","lat":45.88909,"lng":8.3625},{"name":"Quarna Sotto","province":"VB","istat":"103059","lat":45.87739,"lng":8.32856},{"name":"Quarona","province":"VC","istat":"002107","lat":45.76532,"lng":8.25467},{"name":"Quassolo","province":"TO","istat":"001209","lat":45.5251,"lng":7.82705},{"name":"Quattordio","province":"AL","istat":"006142","lat":44.90526,"lng":8.38792},{"name":"Quincinetto","province":"TO","istat":"001210","lat":45.55686,"lng":7.77811},{"name":"Quinto Vercellese","province":"VC","istat":"002108","lat":45.37779,"lng":8.35462},{"name":"Racconigi","province":"CN","istat":"004179","lat":44.77627,"lng":7.67304},{"name":"Rassa","province":"VC","istat":"002110","lat":45.75078,"lng":7.97847},{"name":"Re","province":"VB","istat":"103060","lat":46.14912,"lng":8.56744},{"name":"Reano","province":"TO","istat":"001211","lat":45.05323,"lng":7.42873},{"name":"Recetto","province":"NO","istat":"003129","lat":45.46021,"lng":8.42565},{"name":"Refrancore","province":"AT","istat":"005089","lat":44.92983,"lng":8.34597},{"name":"Revello","province":"CN","istat":"004180","lat":44.66912,"lng":7.41589},{"name":"Revigliasco d'Asti","province":"AT","istat":"005090","lat":44.85726,"lng":8.16307},{"name":"Ribordone","province":"TO","istat":"001212","lat":45.45295,"lng":7.47998},{"name":"Ricaldone","province":"AL","istat":"006143","lat":44.73358,"lng":8.47298},{"name":"Rifreddo","province":"CN","istat":"004181","lat":44.6548,"lng":7.35346},{"name":"Rimella","province":"VC","istat":"002113","lat":45.91902,"lng":8.19175},{"name":"Rittana","province":"CN","istat":"004182","lat":44.35846,"lng":7.3749},{"name":"Riva presso Chieri","province":"TO","istat":"001215","lat":44.97935,"lng":7.88887},{"name":"Rivalba","province":"TO","istat":"001213","lat":45.12278,"lng":7.88909},{"name":"Rivalta Bormida","province":"AL","istat":"006144","lat":44.70984,"lng":8.56099},{"name":"Rivalta di Torino","province":"TO","istat":"001214","lat":45.02245,"lng":7.52037},{"name":"Rivara","province":"TO","istat":"001216","lat":45.32569,"lng":7.63265},{"name":"Rivarolo Canavese","province":"TO","istat":"001217","lat":45.30404,"lng":7.72494},{"name":"Rivarone","province":"AL","istat":"006145","lat":44.97364,"lng":8.71506},{"name":"Rivarossa","province":"TO","istat":"001218","lat":45.2535,"lng":7.70222},{"name":"Rive","province":"VC","istat":"002115","lat":45.20986,"lng":8.41635},{"name":"Rivoli","province":"TO","istat":"001219","lat":45.06705,"lng":7.52328},{"name":"Roaschia","province":"CN","istat":"004183","lat":44.2561,"lng":7.45715},{"name":"Roascio","province":"CN","istat":"004184","lat":44.41708,"lng":8.02478},{"name":"Roasio","province":"VC","istat":"002116","lat":45.59075,"lng":8.29165},{"name":"Roatto","province":"AT","istat":"005091","lat":44.9518,"lng":8.01686},{"name":"Robassomero","province":"TO","istat":"001220","lat":45.19058,"lng":7.57833},{"name":"Robella","province":"AT","istat":"005092","lat":45.10642,"lng":8.10349},{"name":"Robilante","province":"CN","istat":"004185","lat":44.28252,"lng":7.51701},{"name":"Roburent","province":"CN","istat":"004186","lat":44.26182,"lng":7.87149},{"name":"Rocca Canavese","province":"TO","istat":"001221","lat":45.30852,"lng":7.57517},{"name":"Rocca Cigliè","province":"CN","istat":"004188","lat":44.44293,"lng":7.94966},{"name":"Rocca Grimalda","province":"AL","istat":"006147","lat":44.6853,"lng":8.64383},{"name":"Rocca d'Arazzo","province":"AT","istat":"005093","lat":44.86372,"lng":8.2886},{"name":"Rocca de' Baldi","province":"CN","istat":"004189","lat":44.43821,"lng":7.74632},{"name":"Roccabruna","province":"CN","istat":"004187","lat":44.49234,"lng":7.32327},{"name":"Roccaforte Ligure","province":"AL","istat":"006146","lat":44.6805,"lng":9.01812},{"name":"Roccaforte Mondovì","province":"CN","istat":"004190","lat":44.2508,"lng":7.73168},{"name":"Roccasparvera","province":"CN","istat":"004191","lat":44.34497,"lng":7.43291},{"name":"Roccaverano","province":"AT","istat":"005094","lat":44.59498,"lng":8.28588},{"name":"Roccavione","province":"CN","istat":"004192","lat":44.29959,"lng":7.47811},{"name":"Rocchetta Belbo","province":"CN","istat":"004193","lat":44.63877,"lng":8.17431},{"name":"Rocchetta Ligure","province":"AL","istat":"006148","lat":44.69147,"lng":9.05808},{"name":"Rocchetta Palafea","province":"AT","istat":"005095","lat":44.70478,"lng":8.34367},{"name":"Rocchetta Tanaro","province":"AT","istat":"005096","lat":44.85747,"lng":8.34701},{"name":"Roddi","province":"CN","istat":"004194","lat":44.67882,"lng":7.96639},{"name":"Roddino","province":"CN","istat":"004195","lat":44.57516,"lng":8.01833},{"name":"Rodello","province":"CN","istat":"004196","lat":44.62866,"lng":8.06336},{"name":"Roletto","province":"TO","istat":"001222","lat":44.92026,"lng":7.33614},{"name":"Romagnano Sesia","province":"NO","istat":"003130","lat":45.6305,"lng":8.39958},{"name":"Romano Canavese","province":"TO","istat":"001223","lat":45.40008,"lng":7.87038},{"name":"Romentino","province":"NO","istat":"003131","lat":45.46381,"lng":8.74687},{"name":"Ronco Biellese","province":"BI","istat":"096053","lat":45.58336,"lng":8.09441},{"name":"Ronco Canavese","province":"TO","istat":"001224","lat":45.50757,"lng":7.49804},{"name":"Rondissone","province":"TO","istat":"001225","lat":45.24653,"lng":7.96056},{"name":"Ronsecco","province":"VC","istat":"002118","lat":45.26191,"lng":8.26686},{"name":"Roppolo","province":"BI","istat":"096054","lat":45.42475,"lng":8.07064},{"name":"Rorà","province":"TO","istat":"001226","lat":44.7798,"lng":7.18998},{"name":"Rosazza","province":"BI","istat":"096055","lat":45.67556,"lng":7.96226},{"name":"Rosignano Monferrato","province":"AL","istat":"006149","lat":45.0728,"lng":8.41617},{"name":"Rossa","province":"VC","istat":"002121","lat":45.8538,"lng":8.12321},{"name":"Rossana","province":"CN","istat":"004197","lat":44.53182,"lng":7.41231},{"name":"Rosta","province":"TO","istat":"001228","lat":45.0752,"lng":7.46655},{"name":"Roure","province":"TO","istat":"001227","lat":45.0111,"lng":7.12765},{"name":"Rovasenda","province":"VC","istat":"002122","lat":45.53538,"lng":8.31593},{"name":"Rubiana","province":"TO","istat":"001229","lat":45.15626,"lng":7.37463},{"name":"Rueglio","province":"TO","istat":"001230","lat":45.47351,"lng":7.73829},{"name":"Ruffia","province":"CN","istat":"004198","lat":44.71127,"lng":7.60244},{"name":"Sagliano Micca","province":"BI","istat":"096056","lat":45.63635,"lng":8.03609},{"name":"Sala Biellese","province":"BI","istat":"096057","lat":45.5107,"lng":7.95768},{"name":"Sala Monferrato","province":"AL","istat":"006150","lat":45.07491,"lng":8.35464},{"name":"Salasco","province":"VC","istat":"002126","lat":45.32044,"lng":8.28326},{"name":"Salassa","province":"TO","istat":"001231","lat":45.35918,"lng":7.69265},{"name":"Salbertrand","province":"TO","istat":"001232","lat":45.07506,"lng":6.88273},{"name":"Sale","province":"AL","istat":"006151","lat":44.96502,"lng":8.80051},{"name":"Sale San Giovanni","province":"CN","istat":"004200","lat":44.40644,"lng":8.07597},{"name":"Sale delle Langhe","province":"CN","istat":"004199","lat":44.3956,"lng":8.09267},{"name":"Salerano Canavese","province":"TO","istat":"001233","lat":45.45755,"lng":7.84228},{"name":"Sali Vercellese","province":"VC","istat":"002127","lat":45.3068,"lng":8.31538},{"name":"Saliceto","province":"CN","istat":"004201","lat":44.41858,"lng":8.18299},{"name":"Salmour","province":"CN","istat":"004202","lat":44.58193,"lng":7.79761},{"name":"Saluggia","province":"VC","istat":"002128","lat":45.23579,"lng":8.02787},{"name":"Salussola","province":"BI","istat":"096058","lat":45.45154,"lng":8.13965},{"name":"Saluzzo","province":"CN","istat":"004203","lat":44.67014,"lng":7.49021},{"name":"Salza di Pinerolo","province":"TO","istat":"001234","lat":44.93521,"lng":7.03076},{"name":"Sambuco","province":"CN","istat":"004204","lat":44.347,"lng":7.08359},{"name":"Samone","province":"TO","istat":"001235","lat":45.4462,"lng":7.83858},{"name":"Sampeyre","province":"CN","istat":"004205","lat":44.58321,"lng":7.18703},{"name":"San Benedetto Belbo","province":"CN","istat":"004206","lat":44.49008,"lng":8.0599},{"name":"San Benigno Canavese","province":"TO","istat":"001236","lat":45.23199,"lng":7.78632},{"name":"San Bernardino Verbano","province":"VB","istat":"103061","lat":45.99098,"lng":8.45871},{"name":"San Carlo Canavese","province":"TO","istat":"001237","lat":45.25478,"lng":7.61775},{"name":"San Colombano Belmonte","province":"TO","istat":"001238","lat":45.38624,"lng":7.60823},{"name":"San Cristoforo","province":"AL","istat":"006152","lat":44.6951,"lng":8.74604},{"name":"San Damiano Macra","province":"CN","istat":"004207","lat":44.4971,"lng":7.23755},{"name":"San Damiano d'Asti","province":"AT","istat":"005097","lat":44.84548,"lng":8.052},{"name":"San Didero","province":"TO","istat":"001239","lat":45.13442,"lng":7.21688},{"name":"San Francesco al Campo","province":"TO","istat":"001240","lat":45.2298,"lng":7.66656},{"name":"San Germano Chisone","province":"TO","istat":"001242","lat":44.88883,"lng":7.23609},{"name":"San Germano Vercellese","province":"VC","istat":"002131","lat":45.35574,"lng":8.2597},{"name":"San Giacomo Vercellese","province":"VC","istat":"002035","lat":45.48991,"lng":8.32978},{"name":"San Gillio","province":"TO","istat":"001243","lat":45.13863,"lng":7.52437},{"name":"San Giorgio Canavese","province":"TO","istat":"001244","lat":45.32118,"lng":7.79973},{"name":"San Giorgio Monferrato","province":"AL","istat":"006153","lat":45.11051,"lng":8.41273},{"name":"San Giorgio Scarampi","province":"AT","istat":"005098","lat":44.60919,"lng":8.23918},{"name":"San Giorio di Susa","province":"TO","istat":"001245","lat":45.09522,"lng":7.17334},{"name":"San Giusto Canavese","province":"TO","istat":"001246","lat":45.29844,"lng":7.80776},{"name":"San Martino Alfieri","province":"AT","istat":"005099","lat":44.82351,"lng":8.11583},{"name":"San Martino Canavese","province":"TO","istat":"001247","lat":45.39682,"lng":7.80906},{"name":"San Marzano Oliveto","province":"AT","istat":"005100","lat":44.75524,"lng":8.30052},{"name":"San Maurizio Canavese","province":"TO","istat":"001248","lat":45.20443,"lng":7.63583},{"name":"San Maurizio d'Opaglio","province":"NO","istat":"003133","lat":45.77745,"lng":8.39328},{"name":"San Mauro Torinese","province":"TO","istat":"001249","lat":45.10355,"lng":7.7654},{"name":"San Michele Mondovì","province":"CN","istat":"004210","lat":44.36887,"lng":7.92081},{"name":"San Nazzaro Sesia","province":"NO","istat":"003134","lat":45.42712,"lng":8.42041},{"name":"San Paolo Solbrito","province":"AT","istat":"005101","lat":44.95191,"lng":7.98066},{"name":"San Pietro Mosezzo","province":"NO","istat":"003135","lat":45.46908,"lng":8.54097},{"name":"San Pietro Val Lemina","province":"TO","istat":"001250","lat":44.92891,"lng":7.28919},{"name":"San Ponso","province":"TO","istat":"001251","lat":45.3461,"lng":7.6643},{"name":"San Raffaele Cimena","province":"TO","istat":"001252","lat":45.15207,"lng":7.85589},{"name":"San Salvatore Monferrato","province":"AL","istat":"006154","lat":45.00424,"lng":8.56148},{"name":"San Sebastiano Curone","province":"AL","istat":"006155","lat":44.78708,"lng":9.07063},{"name":"San Sebastiano da Po","province":"TO","istat":"001253","lat":45.16001,"lng":7.94356},{"name":"San Secondo di Pinerolo","province":"TO","istat":"001254","lat":44.86049,"lng":7.30355},{"name":"Sandigliano","province":"BI","istat":"096059","lat":45.5121,"lng":8.07824},{"name":"Sanfront","province":"CN","istat":"004209","lat":44.64062,"lng":7.30267},{"name":"Sanfrè","province":"CN","istat":"004208","lat":44.74968,"lng":7.80661},{"name":"Sangano","province":"TO","istat":"001241","lat":45.02335,"lng":7.44491},{"name":"Sant'Agata Fossili","province":"AL","istat":"006156","lat":44.7843,"lng":8.90422},{"name":"Sant'Albano Stura","province":"CN","istat":"004211","lat":44.48968,"lng":7.72244},{"name":"Sant'Ambrogio di Torino","province":"TO","istat":"001255","lat":45.09671,"lng":7.35933},{"name":"Sant'Antonino di Susa","province":"TO","istat":"001256","lat":45.09369,"lng":7.26549},{"name":"Santa Maria Maggiore","province":"VB","istat":"103062","lat":46.16297,"lng":8.44982},{"name":"Santa Vittoria d'Alba","province":"CN","istat":"004212","lat":44.6989,"lng":7.93808},{"name":"Santena","province":"TO","istat":"001257","lat":44.94421,"lng":7.78273},{"name":"Santhià","province":"VC","istat":"002133","lat":45.38315,"lng":8.19558},{"name":"Santo Stefano Belbo","province":"CN","istat":"004213","lat":44.70455,"lng":8.21942},{"name":"Santo Stefano Roero","province":"CN","istat":"004214","lat":44.80099,"lng":7.92506},{"name":"Sardigliano","province":"AL","istat":"006157","lat":44.76584,"lng":8.91965},{"name":"Sarezzano","province":"AL","istat":"006158","lat":44.86432,"lng":8.92673},{"name":"Sauze d'Oulx","province":"TO","istat":"001259","lat":45.01782,"lng":6.86979},{"name":"Sauze di Cesana","province":"TO","istat":"001258","lat":44.89754,"lng":6.92631},{"name":"Savigliano","province":"CN","istat":"004215","lat":44.62132,"lng":7.63681},{"name":"Scagnello","province":"CN","istat":"004216","lat":44.3321,"lng":7.97938},{"name":"Scalenghe","province":"TO","istat":"001260","lat":44.89305,"lng":7.48702},{"name":"Scarmagno","province":"TO","istat":"001261","lat":45.38146,"lng":7.84528},{"name":"Scarnafigi","province":"CN","istat":"004217","lat":44.68833,"lng":7.55859},{"name":"Sciolze","province":"TO","istat":"001262","lat":45.0988,"lng":7.87594},{"name":"Scopa","province":"VC","istat":"002134","lat":45.78704,"lng":8.12564},{"name":"Scopello","province":"VC","istat":"002135","lat":45.76194,"lng":8.09311},{"name":"Scurzolengo","province":"AT","istat":"005103","lat":44.96693,"lng":8.27602},{"name":"Serole","province":"AT","istat":"005104","lat":44.54777,"lng":8.26027},{"name":"Serralunga d'Alba","province":"CN","istat":"004218","lat":44.61584,"lng":7.99649},{"name":"Serralunga di Crea","province":"AL","istat":"006159","lat":45.0997,"lng":8.28929},{"name":"Serravalle Langhe","province":"CN","istat":"004219","lat":44.55873,"lng":8.0665},{"name":"Serravalle Scrivia","province":"AL","istat":"006160","lat":44.72337,"lng":8.84952},{"name":"Serravalle Sesia","province":"VC","istat":"002137","lat":45.67263,"lng":8.30926},{"name":"Sessame","province":"AT","istat":"005105","lat":44.67117,"lng":8.33877},{"name":"Sestriere","province":"TO","istat":"001263","lat":44.96002,"lng":6.88393},{"name":"Settime","province":"AT","istat":"005106","lat":44.95856,"lng":8.11235},{"name":"Settimo Rottaro","province":"TO","istat":"001264","lat":45.40656,"lng":7.99288},{"name":"Settimo Torinese","province":"TO","istat":"001265","lat":45.14577,"lng":7.77311},{"name":"Settimo Vittone","province":"TO","istat":"001266","lat":45.56747,"lng":7.8565},{"name":"Sezzadio","province":"AL","istat":"006161","lat":44.75959,"lng":8.58611},{"name":"Sillavengo","province":"NO","istat":"003138","lat":45.51667,"lng":8.43785},{"name":"Silvano d'Orba","province":"AL","istat":"006162","lat":44.6757,"lng":8.67836},{"name":"Sinio","province":"CN","istat":"004220","lat":44.60489,"lng":8.02519},{"name":"Sizzano","province":"NO","istat":"003139","lat":45.57729,"lng":8.44313},{"name":"Soglio","province":"AT","istat":"005107","lat":44.99644,"lng":8.07605},{"name":"Solero","province":"AL","istat":"006163","lat":44.91257,"lng":8.50176},{"name":"Solonghello","province":"AL","istat":"006164","lat":45.13011,"lng":8.29077},{"name":"Somano","province":"CN","istat":"004221","lat":44.53048,"lng":8.01389},{"name":"Sommariva Perno","province":"CN","istat":"004223","lat":44.74537,"lng":7.88994},{"name":"Sommariva del Bosco","province":"CN","istat":"004222","lat":44.77357,"lng":7.78111},{"name":"Sordevolo","province":"BI","istat":"096063","lat":45.59661,"lng":7.95019},{"name":"Soriso","province":"NO","istat":"003140","lat":45.73698,"lng":8.39818},{"name":"Sostegno","province":"BI","istat":"096064","lat":45.65332,"lng":8.27965},{"name":"Sozzago","province":"NO","istat":"003141","lat":45.39556,"lng":8.72489},{"name":"Sparone","province":"TO","istat":"001267","lat":45.40883,"lng":7.53154},{"name":"Spigno Monferrato","province":"AL","istat":"006165","lat":44.53295,"lng":8.34079},{"name":"Spineto Scrivia","province":"AL","istat":"006166","lat":44.83801,"lng":8.8735},{"name":"Stazzano","province":"AL","istat":"006167","lat":44.73709,"lng":8.89957},{"name":"Strambinello","province":"TO","istat":"001268","lat":45.41792,"lng":7.76721},{"name":"Strambino","province":"TO","istat":"001269","lat":45.3834,"lng":7.90326},{"name":"Stresa","province":"VB","istat":"103064","lat":45.887,"lng":8.52556},{"name":"Strevi","province":"AL","istat":"006168","lat":44.69837,"lng":8.52133},{"name":"Strona","province":"BI","istat":"096065","lat":45.62385,"lng":8.17158},{"name":"Stroppiana","province":"VC","istat":"002142","lat":45.22877,"lng":8.4571},{"name":"Stroppo","province":"CN","istat":"004224","lat":44.51219,"lng":7.13014},{"name":"Suno","province":"NO","istat":"003143","lat":45.62735,"lng":8.54618},{"name":"Susa","province":"TO","istat":"001270","lat":45.13503,"lng":7.07711},{"name":"Tagliolo Monferrato","province":"AL","istat":"006169","lat":44.61242,"lng":8.69636},{"name":"Tarantasca","province":"CN","istat":"004225","lat":44.49814,"lng":7.5319},{"name":"Tassarolo","province":"AL","istat":"006170","lat":44.72595,"lng":8.76835},{"name":"Tavagnasco","province":"TO","istat":"001271","lat":45.5416,"lng":7.80628},{"name":"Tavigliano","province":"BI","istat":"096066","lat":45.64198,"lng":8.05935},{"name":"Terdobbiate","province":"NO","istat":"003144","lat":45.37554,"lng":8.69452},{"name":"Ternengo","province":"BI","istat":"096067","lat":45.58905,"lng":8.11536},{"name":"Terruggia","province":"AL","istat":"006171","lat":45.07721,"lng":8.45028},{"name":"Terzo","province":"AL","istat":"006172","lat":44.67711,"lng":8.41558},{"name":"Ticineto","province":"AL","istat":"006173","lat":45.10197,"lng":8.55769},{"name":"Tigliole","province":"AT","istat":"005108","lat":44.8843,"lng":8.08459},{"name":"Toceno","province":"VB","istat":"103065","lat":46.17506,"lng":8.47983},{"name":"Tollegno","province":"BI","istat":"096068","lat":45.59988,"lng":8.04401},{"name":"Tonco","province":"AT","istat":"005109","lat":45.02063,"lng":8.18938},{"name":"Torino","province":"TO","istat":"001272","lat":45.07175,"lng":7.67789},{"name":"Tornaco","province":"NO","istat":"003146","lat":45.35224,"lng":8.72622},{"name":"Torrazza Piemonte","province":"TO","istat":"001273","lat":45.21854,"lng":7.97879},{"name":"Torrazzo","province":"BI","istat":"096069","lat":45.49859,"lng":7.94993},{"name":"Torre Bormida","province":"CN","istat":"004226","lat":44.56272,"lng":8.1448},{"name":"Torre Canavese","province":"TO","istat":"001274","lat":45.39834,"lng":7.76261},{"name":"Torre Mondovì","province":"CN","istat":"004227","lat":44.32699,"lng":7.90978},{"name":"Torre Pellice","province":"TO","istat":"001275","lat":44.82463,"lng":7.19558},{"name":"Torre San Giorgio","province":"CN","istat":"004228","lat":44.7276,"lng":7.52789},{"name":"Torresina","province":"CN","istat":"004229","lat":44.43624,"lng":8.03416},{"name":"Tortona","province":"AL","istat":"006174","lat":44.88704,"lng":8.84194},{"name":"Trana","province":"TO","istat":"001276","lat":45.02931,"lng":7.40523},{"name":"Trarego Viggiona","province":"VB","istat":"103066","lat":46.0385,"lng":8.64205},{"name":"Trasquera","province":"VB","istat":"103067","lat":46.21607,"lng":8.18163},{"name":"Traversella","province":"TO","istat":"001278","lat":45.52758,"lng":7.69563},{"name":"Traves","province":"TO","istat":"001279","lat":45.26237,"lng":7.42051},{"name":"Trecate","province":"NO","istat":"003149","lat":45.43216,"lng":8.7397},{"name":"Treiso","province":"CN","istat":"004230","lat":44.68948,"lng":8.08522},{"name":"Treville","province":"AL","istat":"006175","lat":45.09697,"lng":8.35496},{"name":"Trezzo Tinella","province":"CN","istat":"004231","lat":44.66395,"lng":8.11396},{"name":"Tricerro","province":"VC","istat":"002147","lat":45.23349,"lng":8.31354},{"name":"Trinità","province":"CN","istat":"004232","lat":44.51124,"lng":7.76303},{"name":"Trino","province":"VC","istat":"002148","lat":45.21781,"lng":8.26616},{"name":"Trisobbio","province":"AL","istat":"006176","lat":44.65967,"lng":8.58645},{"name":"Trofarello","province":"TO","istat":"001280","lat":44.97516,"lng":7.74465},{"name":"Trontano","province":"VB","istat":"103068","lat":46.08943,"lng":8.36436},{"name":"Tronzano Vercellese","province":"VC","istat":"002150","lat":45.32017,"lng":8.1865},{"name":"Usseaux","province":"TO","istat":"001281","lat":45.04937,"lng":7.00806},{"name":"Usseglio","province":"TO","istat":"001282","lat":45.22788,"lng":7.17052},{"name":"Vaglio Serra","province":"AT","istat":"005111","lat":44.80035,"lng":8.34385},{"name":"Vaie","province":"TO","istat":"001283","lat":45.09558,"lng":7.29404},{"name":"Val della Torre","province":"TO","istat":"001284","lat":45.15414,"lng":7.44742},{"name":"Val di Chy","province":"TO","istat":"001317","lat":45.45694,"lng":7.78432},{"name":"Valchiusa","province":"TO","istat":"001318","lat":45.50143,"lng":7.73798},{"name":"Valdengo","province":"BI","istat":"096071","lat":45.56733,"lng":8.13748},{"name":"Valdieri","province":"CN","istat":"004233","lat":44.22652,"lng":7.30269},{"name":"Valdilana","province":"BI","istat":"096088","lat":45.66986,"lng":8.13795},{"name":"Valduggia","province":"VC","istat":"002152","lat":45.72847,"lng":8.3486},{"name":"Valenza","province":"AL","istat":"006177","lat":45.01881,"lng":8.62953},{"name":"Valfenera","province":"AT","istat":"005112","lat":44.89148,"lng":7.95462},{"name":"Valgioie","province":"TO","istat":"001285","lat":45.07617,"lng":7.33387},{"name":"Valgrana","province":"CN","istat":"004234","lat":44.40251,"lng":7.36623},{"name":"Vallanzengo","province":"BI","istat":"096072","lat":45.68025,"lng":8.08128},{"name":"Valle Cannobina","province":"VB","istat":"103079","lat":46.08533,"lng":8.58091},{"name":"Valle San Nicolao","province":"BI","istat":"096074","lat":45.71605,"lng":8.04551},{"name":"Vallo Torinese","province":"TO","istat":"001286","lat":45.22575,"lng":7.4834},{"name":"Valloriate","province":"CN","istat":"004235","lat":44.34181,"lng":7.34774},{"name":"Valmacca","province":"AL","istat":"006178","lat":45.10453,"lng":8.5972},{"name":"Valperga","province":"TO","istat":"001287","lat":45.36791,"lng":7.66016},{"name":"Valprato Soana","province":"TO","istat":"001288","lat":45.55359,"lng":7.54188},{"name":"Valstrona","province":"VB","istat":"103069","lat":45.92681,"lng":8.28256},{"name":"Vanzone con San Carlo","province":"VB","istat":"103070","lat":45.98667,"lng":8.10593},{"name":"Vaprio d'Agogna","province":"NO","istat":"003153","lat":45.60335,"lng":8.55584},{"name":"Varallo","province":"VC","istat":"002156","lat":45.83205,"lng":8.25583},{"name":"Varallo Pombia","province":"NO","istat":"003154","lat":45.66846,"lng":8.64111},{"name":"Varisella","province":"TO","istat":"001289","lat":45.1993,"lng":7.47345},{"name":"Varzo","province":"VB","istat":"103071","lat":46.24328,"lng":8.19693},{"name":"Vauda Canavese","province":"TO","istat":"001290","lat":45.27771,"lng":7.63829},{"name":"Veglio","province":"BI","istat":"096075","lat":45.64765,"lng":8.10722},{"name":"Venaria Reale","province":"TO","istat":"001292","lat":45.1409,"lng":7.61898},{"name":"Venasca","province":"CN","istat":"004237","lat":44.55894,"lng":7.39699},{"name":"Venaus","province":"TO","istat":"001291","lat":45.17258,"lng":6.98831},{"name":"Verbania","province":"VB","istat":"103072","lat":45.93502,"lng":8.54027},{"name":"Vercelli","province":"VC","istat":"002158","lat":45.31988,"lng":8.41125},{"name":"Verduno","province":"CN","istat":"004238","lat":44.67231,"lng":7.93187},{"name":"Vernante","province":"CN","istat":"004239","lat":44.21708,"lng":7.51632},{"name":"Verolengo","province":"TO","istat":"001293","lat":45.19778,"lng":7.98514},{"name":"Verrone","province":"BI","istat":"096076","lat":45.49889,"lng":8.11715},{"name":"Verrua Savoia","province":"TO","istat":"001294","lat":45.15829,"lng":8.10598},{"name":"Verzuolo","province":"CN","istat":"004240","lat":44.59405,"lng":7.49428},{"name":"Vesime","province":"AT","istat":"005113","lat":44.62977,"lng":8.22622},{"name":"Vespolate","province":"NO","istat":"003158","lat":45.34595,"lng":8.66105},{"name":"Vestignè","province":"TO","istat":"001295","lat":45.38344,"lng":7.94418},{"name":"Vezza d'Alba","province":"CN","istat":"004241","lat":44.76149,"lng":7.9946},{"name":"Viale","province":"AT","istat":"005114","lat":45.0022,"lng":8.05008},{"name":"Vialfrè","province":"TO","istat":"001296","lat":45.37721,"lng":7.81492},{"name":"Viarigi","province":"AT","istat":"005115","lat":44.96663,"lng":8.36479},{"name":"Vicoforte","province":"CN","istat":"004242","lat":44.36378,"lng":7.86621},{"name":"Vicolungo","province":"NO","istat":"003159","lat":45.47608,"lng":8.45793},{"name":"Vidracco","province":"TO","istat":"001298","lat":45.42839,"lng":7.74831},{"name":"Vigliano Biellese","province":"BI","istat":"096077","lat":45.56268,"lng":8.11059},{"name":"Vigliano d'Asti","province":"AT","istat":"005116","lat":44.83451,"lng":8.22798},{"name":"Vignale Monferrato","province":"AL","istat":"006179","lat":45.00782,"lng":8.40067},{"name":"Vignole Borbera","province":"AL","istat":"006180","lat":44.70946,"lng":8.90902},{"name":"Vignolo","province":"CN","istat":"004243","lat":44.35872,"lng":7.47622},{"name":"Vignone","province":"VB","istat":"103074","lat":45.96405,"lng":8.56282},{"name":"Vigone","province":"TO","istat":"001299","lat":44.83751,"lng":7.49624},{"name":"Viguzzolo","province":"AL","istat":"006181","lat":44.90762,"lng":8.92452},{"name":"Villa San Secondo","province":"AT","istat":"005119","lat":45.00228,"lng":8.14606},{"name":"Villa del Bosco","province":"BI","istat":"096078","lat":45.62019,"lng":8.28041},{"name":"Villadeati","province":"AL","istat":"006182","lat":45.05853,"lng":8.16876},{"name":"Villadossola","province":"VB","istat":"103075","lat":46.0713,"lng":8.25861},{"name":"Villafalletto","province":"CN","istat":"004244","lat":44.55634,"lng":7.54658},{"name":"Villafranca Piemonte","province":"TO","istat":"001300","lat":44.79547,"lng":7.48497},{"name":"Villafranca d'Asti","province":"AT","istat":"005117","lat":44.91896,"lng":8.03648},{"name":"Villalvernia","province":"AL","istat":"006183","lat":44.81028,"lng":8.8583},{"name":"Villamiroglio","province":"AL","istat":"006184","lat":45.13385,"lng":8.1628},{"name":"Villanova Biellese","province":"BI","istat":"096079","lat":45.4719,"lng":8.19865},{"name":"Villanova Canavese","province":"TO","istat":"001301","lat":45.23648,"lng":7.54742},{"name":"Villanova Mondovì","province":"CN","istat":"004245","lat":44.35875,"lng":7.75013},{"name":"Villanova Monferrato","province":"AL","istat":"006185","lat":45.18361,"lng":8.47264},{"name":"Villanova Solaro","province":"CN","istat":"004246","lat":44.73413,"lng":7.57335},{"name":"Villanova d'Asti","province":"AT","istat":"005118","lat":44.95154,"lng":7.93457},{"name":"Villar Dora","province":"TO","istat":"001303","lat":45.11501,"lng":7.37851},{"name":"Villar Focchiardo","province":"TO","istat":"001305","lat":45.09341,"lng":7.21296},{"name":"Villar Pellice","province":"TO","istat":"001306","lat":44.78902,"lng":7.1347},{"name":"Villar Perosa","province":"TO","istat":"001307","lat":44.91932,"lng":7.25966},{"name":"Villar San Costanzo","province":"CN","istat":"004247","lat":44.48675,"lng":7.39382},{"name":"Villarbasse","province":"TO","istat":"001302","lat":45.04454,"lng":7.47394},{"name":"Villarboit","province":"VC","istat":"002163","lat":45.43983,"lng":8.33161},{"name":"Villareggia","province":"TO","istat":"001304","lat":45.2924,"lng":7.97575},{"name":"Villaromagnano","province":"AL","istat":"006186","lat":44.84686,"lng":8.89788},{"name":"Villastellone","province":"TO","istat":"001308","lat":44.91655,"lng":7.74017},{"name":"Villata","province":"VC","istat":"002164","lat":45.38692,"lng":8.425},{"name":"Villette","province":"VB","istat":"103076","lat":46.13431,"lng":8.53708},{"name":"Vinadio","province":"CN","istat":"004248","lat":44.26876,"lng":7.11761},{"name":"Vinchio","province":"AT","istat":"005120","lat":44.80776,"lng":8.31875},{"name":"Vinovo","province":"TO","istat":"001309","lat":44.95528,"lng":7.62907},{"name":"Vinzaglio","province":"NO","istat":"003164","lat":45.32944,"lng":8.50137},{"name":"Viola","province":"CN","istat":"004249","lat":44.27663,"lng":7.96684},{"name":"Virle Piemonte","province":"TO","istat":"001310","lat":44.8622,"lng":7.57618},{"name":"Vische","province":"TO","istat":"001311","lat":45.34271,"lng":7.93919},{"name":"Visone","province":"AL","istat":"006187","lat":44.65406,"lng":8.5085},{"name":"Vistrorio","province":"TO","istat":"001312","lat":45.43549,"lng":7.76437},{"name":"Viverone","province":"BI","istat":"096080","lat":45.4207,"lng":8.04427},{"name":"Viù","province":"TO","istat":"001313","lat":45.22531,"lng":7.37305},{"name":"Vocca","province":"VC","istat":"002166","lat":45.81292,"lng":8.17514},{"name":"Vogogna","province":"VB","istat":"103077","lat":46.02191,"lng":8.29805},{"name":"Volpedo","province":"AL","istat":"006188","lat":44.88775,"lng":8.9992},{"name":"Volpeglino","province":"AL","istat":"006189","lat":44.89452,"lng":8.9592},{"name":"Volpiano","province":"TO","istat":"001314","lat":45.19461,"lng":7.78359},{"name":"Voltaggio","province":"AL","istat":"006190","lat":44.60252,"lng":8.84538},{"name":"Volvera","province":"TO","istat":"001315","lat":44.95962,"lng":7.50876},{"name":"Vottignasco","province":"CN","istat":"004250","lat":44.56151,"lng":7.57895},{"name":"Zimone","province":"BI","istat":"096081","lat":45.44976,"lng":8.04167},{"name":"Zubiena","province":"BI","istat":"096082","lat":45.48987,"lng":8.00775},{"name":"Zumaglia","province":"BI","istat":"096083","lat":45.59416,"lng":8.08593}]} diff --git a/Nuvolari/app/lib/features/map/map_overlay_controls.dart b/Nuvolari/app/lib/features/map/map_overlay_controls.dart new file mode 100644 index 0000000..db2eeaf --- /dev/null +++ b/Nuvolari/app/lib/features/map/map_overlay_controls.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/region/region_config.dart'; +import '../../l10n/app_localizations.dart'; +import '../places/map_target.dart'; +import '../places/saved_place.dart'; +import '../places/saved_places.dart'; + +/// Identifies an entry in the target menu. +/// +/// A sealed value rather than a raw string so a typo cannot silently become a +/// menu entry that does nothing. +sealed class _TargetChoice { + const _TargetChoice(); +} + +class _ChooseFollow extends _TargetChoice { + const _ChooseFollow(); +} + +class _ChooseRegion extends _TargetChoice { + const _ChooseRegion(); +} + +class _ChoosePlace extends _TargetChoice { + const _ChoosePlace(this.place); + final SavedPlace place; +} + +class _ChooseSearch extends _TargetChoice { + const _ChooseSearch(); +} + +class _ChooseManage extends _TargetChoice { + const _ChooseManage(); +} + +/// The target selector that sits over the top of the map. +/// +/// Over the map rather than in the app bar because it is a map control: it +/// changes what the map is looking at, and belongs next to the thing it acts on. +class TargetSelector extends ConsumerWidget { + const TargetSelector({ + required this.region, + required this.onFollowRequested, + required this.onSearch, + required this.onManagePlaces, + super.key, + }); + + final RegionConfig region; + + /// Following needs location permission, and asking for it — with the + /// disclosure that has to come first — is not a menu's job. The screen that + /// owns the map handles it and only then changes the target. + final VoidCallback onFollowRequested; + + final VoidCallback onSearch; + final VoidCallback onManagePlaces; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final target = ref.watch(mapTargetProvider); + final places = ref.watch(savedPlacesProvider).value ?? const []; + + final label = switch (target) { + FollowUser() => l10n.targetFollowMe, + PlaceTarget(:final place) => place.name, + FreeTarget() => l10n.targetWholeRegion(region.displayName), + }; + + final icon = switch (target) { + FollowUser() => Icons.my_location, + PlaceTarget() => Icons.place, + FreeTarget() => Icons.map_outlined, + }; + + return Material( + elevation: 3, + borderRadius: BorderRadius.circular(24), + color: theme.colorScheme.surface, + child: PopupMenuButton<_TargetChoice>( + tooltip: l10n.targetChoose, + position: PopupMenuPosition.under, + onSelected: (choice) async { + switch (choice) { + case _ChooseFollow(): + onFollowRequested(); + case _ChooseRegion(): + await ref + .read(mapTargetProvider.notifier) + .select(const FreeTarget()); + case _ChoosePlace(:final place): + await ref + .read(mapTargetProvider.notifier) + .select(PlaceTarget(place)); + case _ChooseSearch(): + onSearch(); + case _ChooseManage(): + onManagePlaces(); + } + }, + itemBuilder: (context) => >[ + CheckedPopupMenuItem<_TargetChoice>( + value: const _ChooseFollow(), + checked: target is FollowUser, + child: Text(l10n.targetFollowMe), + ), + CheckedPopupMenuItem<_TargetChoice>( + value: const _ChooseRegion(), + checked: target is FreeTarget, + child: Text(l10n.targetWholeRegion(region.displayName)), + ), + if (places.isNotEmpty) const PopupMenuDivider(), + for (final place in places) + CheckedPopupMenuItem<_TargetChoice>( + value: _ChoosePlace(place), + checked: target is PlaceTarget && target.place.id == place.id, + child: Text(place.name, overflow: TextOverflow.ellipsis), + ), + const PopupMenuDivider(), + PopupMenuItem<_TargetChoice>( + value: const _ChooseSearch(), + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.search), + title: Text(l10n.searchTitle), + ), + ), + PopupMenuItem<_TargetChoice>( + value: const _ChooseManage(), + child: ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.place_outlined), + title: Text(l10n.placesTitle), + ), + ), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + ), + ); + } +} + +/// Search and recentre, stacked over the right edge of the map. +class MapActionButtons extends ConsumerWidget { + const MapActionButtons({ + required this.onSearch, + required this.onRecentre, + super.key, + }); + + final VoidCallback onSearch; + final VoidCallback onRecentre; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final target = ref.watch(mapTargetProvider); + + // The recentre button says what it will do. Following but panned away is + // the case that matters: the icon has to promise the blue dot, not a place. + final recentreIcon = switch (target) { + FollowUser() => Icons.my_location, + PlaceTarget() => Icons.place, + FreeTarget() => Icons.zoom_out_map, + }; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton.small( + heroTag: 'nuvolari-search', + tooltip: l10n.searchOpen, + onPressed: onSearch, + child: const Icon(Icons.search), + ), + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'nuvolari-recentre', + tooltip: l10n.mapCentreOnTarget, + onPressed: onRecentre, + child: Icon(recentreIcon), + ), + ], + ); + } +} diff --git a/Nuvolari/app/lib/features/map/radar_map_screen.dart b/Nuvolari/app/lib/features/map/radar_map_screen.dart index ef88d0e..b83dd4f 100644 --- a/Nuvolari/app/lib/features/map/radar_map_screen.dart +++ b/Nuvolari/app/lib/features/map/radar_map_screen.dart @@ -12,130 +12,21 @@ import '../../data/radar/radar_manifest.dart'; import '../../data/radar/radar_source.dart'; import '../../l10n/app_localizations.dart'; import '../places/location_flow.dart'; +import '../places/location_service.dart'; +import '../places/map_target.dart'; import '../places/places_screen.dart'; import '../places/saved_place.dart'; import '../places/saved_places.dart'; +import '../places/search_screen.dart'; import '../settings/settings_screen.dart'; import '../timeline/data_age_banner.dart'; import '../timeline/radar_timeline.dart'; import '../timeline/timeline_bar.dart'; import 'attribution_bar.dart'; +import 'map_overlay_controls.dart'; import 'map_style.dart'; import 'radar_overlay.dart'; -/// The live map controller, or null before the map is created. -/// -/// Held in a provider because the app bar sits above the map in the widget tree -/// and still needs to move the camera. Cleared on dispose so a stale controller -/// is never used after the map is gone. -class MapControllerHolder extends Notifier { - @override - MapLibreMapController? build() => null; - - void attach(MapLibreMapController controller) => state = controller; - - void detach() => state = null; -} - -final mapControllerProvider = - NotifierProvider( - MapControllerHolder.new, - ); - -/// Moves the map onto [point], keeping the current zoom. -Future _centreOn(WidgetRef ref, GeoPoint point) async { - final controller = ref.read(mapControllerProvider); - if (controller == null) return; - await controller.animateCamera( - CameraUpdate.newLatLng(LatLng(point.latitude, point.longitude)), - ); -} - -/// Centres the map on the device position. -class _LocateAction extends ConsumerWidget { - const _LocateAction(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = AppLocalizations.of(context); - - return IconButton( - tooltip: l10n.locateMe, - icon: const Icon(Icons.my_location), - onPressed: () async { - final point = await requestPosition(context, ref); - if (point == null || !context.mounted) return; - - // The app only has data for this region. Saying so beats silently - // refusing to move, and beats moving somewhere with an empty map. - final region = await ref.read(regionConfigProvider.future); - if (!context.mounted) return; - if (!region.bounds.contains(point)) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.locationOutsideRegion))); - return; - } - - ref.read(activePlaceProvider.notifier).select(null); - await _centreOn(ref, point); - }, - ); - } -} - -/// Opens the saved places list and centres on whatever comes back. -class _PlacesAction extends ConsumerWidget { - const _PlacesAction(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = AppLocalizations.of(context); - - return IconButton( - tooltip: l10n.placesOpen, - icon: const Icon(Icons.place_outlined), - onPressed: () async { - final controller = ref.read(mapControllerProvider); - final region = await ref.read(regionConfigProvider.future); - if (!context.mounted) return; - - final target = controller?.cameraPosition?.target; - final centre = target == null - ? region.map.center - : GeoPoint(target.longitude, target.latitude); - - final chosen = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlacesScreen(mapCentre: centre), - ), - ); - if (chosen == null) return; - - ref.read(activePlaceProvider.notifier).select(chosen); - await _centreOn(ref, chosen.point); - }, - ); - } -} - -class _SettingsAction extends StatelessWidget { - const _SettingsAction(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - - return IconButton( - tooltip: l10n.settingsOpen, - icon: const Icon(Icons.settings_outlined), - onPressed: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const SettingsScreen())), - ); - } -} - /// The radar map: base map, animated precipitation overlay, timeline. class RadarMapScreen extends ConsumerWidget { const RadarMapScreen({super.key}); @@ -148,7 +39,17 @@ class RadarMapScreen extends ConsumerWidget { return Scaffold( appBar: AppBar( title: Text(l10n.appTitle), - actions: const [_LocateAction(), _PlacesAction(), _SettingsAction()], + // Only settings here. Everything that changes what the map is looking + // at lives over the map itself, next to the thing it acts on. + actions: [ + IconButton( + tooltip: l10n.settingsOpen, + icon: const Icon(Icons.settings_outlined), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ), + ), + ], ), body: switch (region) { AsyncData(:final value) => _MapWithAttribution(region: value), @@ -185,19 +86,7 @@ class _MapWithAttribution extends ConsumerWidget { actions: const [SizedBox.shrink()], ), Expanded( - child: Stack( - children: [ - Positioned.fill( - child: _RegionMap(region: region, style: style), - ), - if (manifest != null) - Positioned( - left: 12, - bottom: 12, - child: _RadarLegend(legend: manifest.legend), - ), - ], - ), + child: _RegionMap(region: region, style: style), ), const DataAgeBanner(), const TimelineBar(), @@ -235,50 +124,53 @@ class _RegionMap extends ConsumerStatefulWidget { } class _RegionMapState extends ConsumerState<_RegionMap> { + /// Close enough to read a town and its surroundings, wide enough to see + /// weather arriving from the next valley. + static const double placeZoom = 9.5; + MapLibreMapController? _controller; RadarOverlay? _overlay; AppLifecycleListener? _lifecycle; - /// Guards against a frame that finished loading after the playhead moved on, - /// which would briefly show the wrong image. + /// Whether the map may draw the blue dot. + /// + /// Turning it on without permission makes the native layer complain, so it + /// waits until permission is actually held. + bool _locationEnabled = false; + + /// Guards against a frame that finished loading after the playhead moved on. int _requestId = 0; @override void initState() { super.initState(); - // Playback runs a timer and pushes a texture several times a second. - // Behind another app there is no one watching, so it is pure battery. _lifecycle = AppLifecycleListener( onPause: () => ref.read(radarTimelineProvider.notifier).onAppBackgrounded(), ); + unawaited(_syncLocationAvailability()); } @override void dispose() { _lifecycle?.dispose(); unawaited(_overlay?.detach()); - // The controller dies with the platform view; leaving it in the provider - // would let the app bar drive a dead map. - Future.microtask( - () => ref.read(mapControllerProvider.notifier).detach(), - ).ignore(); super.dispose(); } - /// Frames the whole region once the map is ready. - /// - /// The configured initial zoom is only a starting point: a single zoom number - /// cannot fit the region on both a small phone and a tablet, and on a 411dp - /// screen zoom 7.2 puts the viewport entirely inside the region, so the user - /// sees a flat expanse with no border or outline to orient by. Fitting the - /// bounds works on every screen size. - Future _onMapCreated(MapLibreMapController controller) async { - _controller = controller; - ref.read(mapControllerProvider.notifier).attach(controller); + Future _syncLocationAvailability() async { + final availability = await ref.read(locationServiceProvider).availability(); + if (!mounted) return; + setState( + () => _locationEnabled = availability == LocationAvailability.granted, + ); + } + // --- camera --------------------------------------------------------------- + + Future _frameRegion() async { final bounds = widget.region.bounds; - await controller.moveCamera( + await _controller?.moveCamera( CameraUpdate.newLatLngBounds( LatLngBounds( southwest: LatLng(bounds.south, bounds.west), @@ -292,9 +184,136 @@ class _RegionMapState extends ConsumerState<_RegionMap> { ); } - /// Layers can only be added once the style exists, and the style is reloaded - /// whenever it changes, so the overlay is rebuilt here rather than in - /// [_onMapCreated]. + Future _centreOn(GeoPoint point, {double? zoom}) async { + await _controller?.animateCamera( + zoom == null + ? CameraUpdate.newLatLng(LatLng(point.latitude, point.longitude)) + : CameraUpdate.newLatLngZoom( + LatLng(point.latitude, point.longitude), + zoom, + ), + ); + } + + /// Puts the camera where the current target says it should be. + Future _applyTarget(MapTarget target) async { + switch (target) { + case FollowUser(): + // The native tracking mode does the following; nothing to animate. + break; + case PlaceTarget(:final place): + await _centreOn(place.point, zoom: placeZoom); + case FreeTarget(): + await _frameRegion(); + } + } + + // --- actions -------------------------------------------------------------- + + /// Asks for permission, then starts following. + Future _requestFollow() async { + final point = await requestPosition(context, ref); + if (!mounted) return; + + if (point == null) { + // requestPosition has already explained why it could not. + await _syncLocationAvailability(); + return; + } + + if (!widget.region.bounds.contains(point)) { + // Worth following anyway — someone may be travelling into the region — + // but they should know why the map looks empty. + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).locationOutsideRegion), + ), + ); + } + + setState(() => _locationEnabled = true); + await ref.read(mapTargetProvider.notifier).select(const FollowUser()); + if (mounted) await _centreOn(point, zoom: placeZoom); + } + + Future _openSearch() async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SearchScreen()), + ); + if (result == null || !mounted) return; + + // A searched point is somewhere the user is looking, not somewhere they + // have committed to, so it moves the camera without becoming the target. + // Saving it is one tap away if they want it to stick. + await ref.read(mapTargetProvider.notifier).select(const FreeTarget()); + await _centreOn(result.point, zoom: placeZoom); + if (!mounted) return; + + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result.label), + action: SnackBarAction( + label: l10n.mapSaveThisPoint, + onPressed: () => unawaited(_savePoint(result)), + ), + ), + ); + } + + Future _savePoint(SearchResult result) async { + try { + final place = await ref + .read(savedPlacesProvider.notifier) + .add(name: result.label, point: result.point); + if (!mounted) return; + await ref.read(mapTargetProvider.notifier).select(PlaceTarget(place)); + } on SavedPlaceException catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(messageFor(AppLocalizations.of(context), error)), + ), + ); + } + } + + Future _openPlaces() async { + final target = _controller?.cameraPosition?.target; + final centre = target == null + ? widget.region.map.center + : GeoPoint(target.longitude, target.latitude); + + final chosen = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlacesScreen(mapCentre: centre), + ), + ); + if (chosen == null || !mounted) return; + await ref.read(mapTargetProvider.notifier).select(PlaceTarget(chosen)); + } + + /// Puts the camera back on whatever is being followed. + Future _recentre() async { + final target = ref.read(mapTargetProvider); + if (target is FollowUser) { + // Re-asserting the tracking mode is what pulls the camera back to the + // blue dot after the user has panned away from it. + await _controller?.updateMyLocationTrackingMode( + MyLocationTrackingMode.tracking, + ); + return; + } + await _applyTarget(target); + } + + // --- map lifecycle -------------------------------------------------------- + + Future _onMapCreated(MapLibreMapController controller) async { + _controller = controller; + await _applyTarget(ref.read(mapTargetProvider)); + } + Future _onStyleLoaded() async { final controller = _controller; if (controller == null) return; @@ -313,13 +332,11 @@ class _RegionMapState extends ConsumerState<_RegionMap> { final bytes = await ref .read(radarTimelineProvider.notifier) .bytesFor(frame); - // Someone scrubbed while this was loading; that newer frame wins. if (!mounted || request != _requestId) return; await overlay.show(frame.path, bytes); } on RadarUnavailableException { // Hold the previous frame. A gap mid-timeline is normal while the worker - // publishes, and blanking the map would be a worse answer than showing - // the neighbouring minute. + // publishes, and blanking the map is a worse answer. } } @@ -329,41 +346,91 @@ class _RegionMapState extends ConsumerState<_RegionMap> { radarTimelineProvider.select((state) => state.currentFrame), (_, frame) => unawaited(_showFrame(frame)), ); + ref.listen( + mapTargetProvider, + (_, target) => unawaited(_applyTarget(target)), + ); - final center = widget.region.map.center; + final target = ref.watch(mapTargetProvider); + final manifest = ref.watch( + radarTimelineProvider.select((state) => state.manifest), + ); + final centre = widget.region.map.center; final zoom = widget.region.map.zoom; final bounds = widget.region.bounds; - return MapLibreMap( - styleString: widget.style.styleString, - onMapCreated: _onMapCreated, - // Without this the controller's cameraPosition stays null, and "save the - // map centre" silently saves the region default instead of what the user - // is actually looking at. - trackCameraPosition: true, - onStyleLoadedCallback: () => unawaited(_onStyleLoaded()), - initialCameraPosition: CameraPosition( - target: LatLng(center.latitude, center.longitude), - zoom: zoom.initial, - ), - minMaxZoomPreference: MinMaxZoomPreference(zoom.min, zoom.max), - // Panning is confined to the region: this app has data for Piedmont and - // nowhere else, so letting the user drift away would only ever show an - // empty map. - cameraTargetBounds: CameraTargetBounds( - LatLngBounds( - southwest: LatLng(bounds.south, bounds.west), - northeast: LatLng(bounds.north, bounds.east), + return Stack( + children: [ + Positioned.fill( + child: MapLibreMap( + styleString: widget.style.styleString, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: () => unawaited(_onStyleLoaded()), + // Without this the controller's cameraPosition stays null, and + // "save the map centre" silently saves the region default instead + // of what the user is actually looking at. + trackCameraPosition: true, + initialCameraPosition: CameraPosition( + target: LatLng(centre.latitude, centre.longitude), + zoom: zoom.initial, + ), + minMaxZoomPreference: MinMaxZoomPreference(zoom.min, zoom.max), + cameraTargetBounds: CameraTargetBounds( + LatLngBounds( + southwest: LatLng(bounds.south, bounds.west), + northeast: LatLng(bounds.north, bounds.east), + ), + ), + attributionButtonPosition: AttributionButtonPosition.bottomRight, + compassEnabled: false, + rotateGesturesEnabled: false, + tiltGesturesEnabled: false, + myLocationEnabled: _locationEnabled, + myLocationTrackingMode: target is FollowUser + ? MyLocationTrackingMode.tracking + : MyLocationTrackingMode.none, + // Dragging the map while it is chasing the user means "let me look + // over here". The camera stops following, but what the app reopens + // on is left alone: that is a different decision. + onCameraTrackingDismissed: () => + ref.read(mapTargetProvider.notifier).releaseFollow(), + ), ), - ), - // Kept enabled on top of our own attribution bar: some tile providers - // require the plugin's own attribution control, and a duplicated credit - // is harmless where a missing one is a licence breach. - attributionButtonPosition: AttributionButtonPosition.bottomRight, - compassEnabled: false, - rotateGesturesEnabled: false, - tiltGesturesEnabled: false, - myLocationEnabled: false, + + Positioned( + left: 12, + right: 12, + top: 12, + child: SafeArea( + bottom: false, + child: Align( + alignment: Alignment.topCenter, + child: TargetSelector( + region: widget.region, + onFollowRequested: () => unawaited(_requestFollow()), + onSearch: () => unawaited(_openSearch()), + onManagePlaces: () => unawaited(_openPlaces()), + ), + ), + ), + ), + + if (manifest != null) + Positioned( + left: 12, + bottom: 12, + child: _RadarLegend(legend: manifest.legend), + ), + + Positioned( + right: 12, + bottom: 12, + child: MapActionButtons( + onSearch: () => unawaited(_openSearch()), + onRecentre: () => unawaited(_recentre()), + ), + ), + ], ); } } diff --git a/Nuvolari/app/lib/features/places/comune.dart b/Nuvolari/app/lib/features/places/comune.dart new file mode 100644 index 0000000..39bc530 --- /dev/null +++ b/Nuvolari/app/lib/features/places/comune.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import '../../core/region/geo.dart'; + +/// A municipality the user can search for. +/// +/// Bundled rather than geocoded online: the app only has radar for one region, +/// so a list of its 1180 municipalities is small enough to ship, searches +/// instantly, works with no network, needs no API key, and cannot return a +/// result somewhere the app has nothing to show. +class Comune { + Comune({ + required this.name, + required this.province, + required this.istatCode, + required this.point, + }); + + factory Comune.fromJson(Map json) { + final name = json['name']; + final province = json['province']; + final istat = json['istat']; + final latitude = json['lat']; + final longitude = json['lng']; + + if (name is! String || name.isEmpty) { + throw const FormatException('comune name must be a non-empty string'); + } + if (province is! String || province.isEmpty) { + throw const FormatException('comune province must be a non-empty string'); + } + if (istat is! String || istat.isEmpty) { + throw const FormatException( + 'comune istat code must be a non-empty string', + ); + } + if (latitude is! num || longitude is! num) { + throw const FormatException('comune coordinates must be numbers'); + } + + return Comune( + name: name, + province: province, + istatCode: istat, + point: GeoPoint(longitude.toDouble(), latitude.toDouble()), + ); + } + + final String name; + + /// Two-letter province abbreviation, for example `TO`. + final String province; + + /// Istat municipality code, stable across renames. + final String istatCode; + + /// A representative point inside the municipality — the centroid of its + /// largest part, so it is a few kilometres from the town centre at worst. + final GeoPoint point; + + /// How it reads in a list: `Agliè (TO)`. + String get label => '$name ($province)'; + + /// Lower-case and stripped of accents, for matching. + /// + /// Computed on first use rather than per keystroke: 1180 entries times a + /// character-by-character fold on every letter typed is work worth doing + /// exactly once. This is why the class is not const. + late final String searchKey = foldForSearch(name); + + @override + String toString() => 'Comune($name, $province)'; + + @override + bool operator ==(Object other) => + other is Comune && other.istatCode == istatCode; + + @override + int get hashCode => istatCode.hashCode; +} + +/// Lower-cases and removes the accents Italian place names use. +/// +/// Nobody types "Agliè" with the accent when searching, and several names differ +/// only by one. Dart has no Unicode normalisation in the core library, and the +/// full table would be overkill: Italian municipality names use exactly these. +String foldForSearch(String value) { + const folded = { + 'à': 'a', + 'á': 'a', + 'â': 'a', + 'ä': 'a', + 'ã': 'a', + 'å': 'a', + 'è': 'e', + 'é': 'e', + 'ê': 'e', + 'ë': 'e', + 'ì': 'i', + 'í': 'i', + 'î': 'i', + 'ï': 'i', + 'ò': 'o', + 'ó': 'o', + 'ô': 'o', + 'ö': 'o', + 'õ': 'o', + 'ù': 'u', + 'ú': 'u', + 'û': 'u', + 'ü': 'u', + 'ç': 'c', + 'ñ': 'n', + }; + + final buffer = StringBuffer(); + for (final rune in value.toLowerCase().runes) { + final char = String.fromCharCode(rune); + buffer.write(folded[char] ?? char); + } + return buffer.toString(); +} + +/// The bundled municipality list, with its provenance. +class ComuneIndex { + ComuneIndex({ + required this.comuni, + required this.source, + required this.license, + }); + + factory ComuneIndex.parse(String raw) { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw const FormatException('comuni index must be a JSON object'); + } + + final places = decoded['places']; + if (places is! List || places.isEmpty) { + throw const FormatException('comuni index must list places'); + } + + final source = decoded['source']; + final license = decoded['license']; + if (source is! String || source.isEmpty) { + throw const FormatException( + 'comuni index must name its source: the list is redistributed data and ' + 'the credit travels with it', + ); + } + + return ComuneIndex( + comuni: places + .whereType>() + .map(Comune.fromJson) + .toList(growable: false), + source: source, + license: license is String && license.isNotEmpty ? license : null, + ); + } + + final List comuni; + + /// Where the list came from, shown on the Sources screen. + final String source; + + final String? license; + + /// How many results a search returns at most. + /// + /// A list longer than this is not a search result, it is a phone book: the + /// user should type another letter instead of scrolling. + static const int maxResults = 30; + + /// Municipalities matching [query], best first. + /// + /// Ranking is deliberately simple and predictable: names that *start* with + /// the query come before names that merely contain it, and ties break + /// alphabetically. Someone typing "tor" wants Torino before Cavallermaggiore. + List search(String query) { + final needle = foldForSearch(query.trim()); + if (needle.isEmpty) return const []; + + final prefix = []; + final contains = []; + + for (final comune in comuni) { + final key = comune.searchKey; + if (key.startsWith(needle)) { + prefix.add(comune); + } else if (key.contains(needle)) { + contains.add(comune); + } + } + + int byName(Comune a, Comune b) => a.searchKey.compareTo(b.searchKey); + prefix.sort(byName); + contains.sort(byName); + + return [ + ...prefix, + ...contains, + ].take(maxResults).toList(growable: false); + } +} diff --git a/Nuvolari/app/lib/features/places/comuni_repository.dart b/Nuvolari/app/lib/features/places/comuni_repository.dart new file mode 100644 index 0000000..080961d --- /dev/null +++ b/Nuvolari/app/lib/features/places/comuni_repository.dart @@ -0,0 +1,37 @@ +import 'package:flutter/services.dart' show AssetBundle, rootBundle; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/region/region_repository.dart'; +import 'comune.dart'; + +/// Loads the bundled municipality list. +class ComuniRepository { + const ComuniRepository({this.bundle}); + + /// Overridden in tests; null means the real asset bundle. + final AssetBundle? bundle; + + AssetBundle get _assets => bundle ?? rootBundle; + + static String assetPathFor(String regionId) => + 'assets/regions/${regionId}_comuni.json'; + + Future load(String regionId) async { + final source = await _assets.loadString(assetPathFor(regionId)); + return ComuneIndex.parse(source); + } +} + +final comuniRepositoryProvider = Provider( + (ref) => const ComuniRepository(), +); + +/// The searchable municipality index for the active region. +/// +/// Loaded once and kept: 1180 entries with a precomputed fold key is a +/// negligible amount of memory next to a single radar frame, and rebuilding it +/// per search would make typing feel slow for no reason. +final comuneIndexProvider = FutureProvider((ref) async { + final regionId = ref.watch(regionIdProvider); + return ref.watch(comuniRepositoryProvider).load(regionId); +}); diff --git a/Nuvolari/app/lib/features/places/coordinate_input.dart b/Nuvolari/app/lib/features/places/coordinate_input.dart new file mode 100644 index 0000000..cbb99d7 --- /dev/null +++ b/Nuvolari/app/lib/features/places/coordinate_input.dart @@ -0,0 +1,133 @@ +import '../../core/region/geo.dart'; + +/// Parses coordinates typed or pasted by the user. +/// +/// People paste from wherever they found them, so this accepts the shapes that +/// actually turn up rather than one canonical form: decimal degrees separated +/// by a comma, a semicolon or spaces, with or without hemisphere letters, and +/// degrees-minutes-seconds. +/// +/// Latitude always comes first, matching every consumer-facing map. Returns +/// null when the text is not coordinates at all — that is the normal case while +/// someone is typing a town name into the same field. +GeoPoint? parseCoordinates(String input) { + final text = input.trim(); + if (text.isEmpty) return null; + + final dms = _parseDms(text); + if (dms != null) return dms; + + return _parseDecimal(text); +} + +final RegExp _decimalPair = RegExp( + r'^\s*([NS])?\s*([+-]?\d+(?:[.,]\d+)?)\s*°?\s*([NS])?' + r'\s*[,;\s]\s*' + r'([EWO])?\s*([+-]?\d+(?:[.,]\d+)?)\s*°?\s*([EWO])?\s*$', + caseSensitive: false, +); + +GeoPoint? _parseDecimal(String text) { + final match = _decimalPair.firstMatch(text); + if (match == null) return null; + + final latitude = _applyHemisphere( + _number(match.group(2)), + match.group(1) ?? match.group(3), + negative: 'S', + ); + final longitude = _applyHemisphere( + _number(match.group(5)), + match.group(4) ?? match.group(6), + negative: 'W', + ); + if (latitude == null || longitude == null) return null; + + return _validated(latitude, longitude); +} + +/// Degrees, minutes and optional seconds, for both halves of a pair. +/// +/// The seconds mark can be a double-prime, two apostrophes, a plain quote or +/// an `s`, because those are all shapes people paste. Built by string +/// concatenation rather than one long literal: a raw string cannot contain the +/// double quote this pattern needs, and a non-raw one would turn every +/// backslash in the pattern into an escape. +final RegExp _dmsPair = RegExp( + '^\\s*$_dmsPart([NS])\\s*[,;\\s]\\s*$_dmsPart([EWO])\\s*\$', + caseSensitive: false, +); + +const String _dmsPart = + r'(\d+)\s*[°d]\s*' + r'(\d+(?:[.,]\d+)?)\s*' + "['m′]?\\s*" + r'(?:(\d+(?:[.,]\d+)?)\s*' + "[″\"']*s?\\s*)?"; + +GeoPoint? _parseDms(String text) { + final match = _dmsPair.firstMatch(text); + if (match == null) return null; + + final latitude = _fromDms( + match.group(1), + match.group(2), + match.group(3), + match.group(4), + negative: 'S', + ); + final longitude = _fromDms( + match.group(5), + match.group(6), + match.group(7), + match.group(8), + negative: 'W', + ); + if (latitude == null || longitude == null) return null; + + return _validated(latitude, longitude); +} + +double? _fromDms( + String? degrees, + String? minutes, + String? seconds, + String? hemisphere, { + required String negative, +}) { + final d = _number(degrees); + final m = _number(minutes) ?? 0; + final s = _number(seconds) ?? 0; + if (d == null) return null; + if (m >= 60 || s >= 60) return null; + + final value = d + m / 60 + s / 3600; + return _applyHemisphere(value, hemisphere, negative: negative); +} + +double? _applyHemisphere( + double? value, + String? hemisphere, { + required String negative, +}) { + if (value == null) return null; + if (hemisphere == null) return value; + + final letter = hemisphere.toUpperCase(); + // "O" is Italian for west — someone reading coordinates off an Italian map + // will type it, and silently treating it as east would put them the wrong + // side of Greenwich. + final isNegative = letter == negative || (negative == 'W' && letter == 'O'); + final magnitude = value.abs(); + return isNegative ? -magnitude : magnitude; +} + +/// Accepts both the decimal point and the comma Italian keyboards produce. +double? _number(String? raw) => + raw == null ? null : double.tryParse(raw.replaceAll(',', '.')); + +GeoPoint? _validated(double latitude, double longitude) { + if (latitude < -90 || latitude > 90) return null; + if (longitude < -180 || longitude > 180) return null; + return GeoPoint(longitude, latitude); +} diff --git a/Nuvolari/app/lib/features/places/map_target.dart b/Nuvolari/app/lib/features/places/map_target.dart new file mode 100644 index 0000000..e714584 --- /dev/null +++ b/Nuvolari/app/lib/features/places/map_target.dart @@ -0,0 +1,186 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'saved_place.dart'; +import 'saved_places.dart'; + +/// What the map is pointed at. +sealed class MapTarget { + const MapTarget(); + + /// The value persisted as the preferred target, or null if it is not a + /// sensible thing to reopen on. + String? get storageValue; +} + +/// Follow the device: the blue dot stays centred until the user pans away. +class FollowUser extends MapTarget { + const FollowUser(); + + @override + String? get storageValue => 'follow'; + + @override + bool operator ==(Object other) => other is FollowUser; + + @override + int get hashCode => 'follow'.hashCode; +} + +/// Sit on a saved place. +class PlaceTarget extends MapTarget { + const PlaceTarget(this.place); + + final SavedPlace place; + + @override + String? get storageValue => 'place:${place.id}'; + + @override + bool operator ==(Object other) => + other is PlaceTarget && other.place.id == place.id; + + @override + int get hashCode => place.id.hashCode; +} + +/// The whole region, and wherever the user has panned to. +/// +/// Also what following degrades to the moment the user drags the map: the +/// camera stops chasing them, which is the behaviour every map app has trained +/// people to expect. +class FreeTarget extends MapTarget { + const FreeTarget(); + + @override + String? get storageValue => null; + + @override + bool operator ==(Object other) => other is FreeTarget; + + @override + int get hashCode => 'free'.hashCode; +} + +/// Remembers what the app should open on. +abstract interface class PreferredTargetStore { + Future load(); + + Future save(String? value); +} + +class SharedPreferencesPreferredTargetStore implements PreferredTargetStore { + const SharedPreferencesPreferredTargetStore(); + + static const String storageKey = 'nuvolari.preferred_target.v1'; + + @override + Future load() async => + (await SharedPreferences.getInstance()).getString(storageKey); + + @override + Future save(String? value) async { + final prefs = await SharedPreferences.getInstance(); + if (value == null) { + await prefs.remove(storageKey); + } else { + await prefs.setString(storageKey, value); + } + } +} + +class InMemoryPreferredTargetStore implements PreferredTargetStore { + InMemoryPreferredTargetStore([this._value]); + + String? _value; + + @override + Future load() async => _value; + + @override + Future save(String? value) async => _value = value; +} + +final preferredTargetStoreProvider = Provider( + (ref) => const SharedPreferencesPreferredTargetStore(), +); + +/// The map's current target, and the one it will reopen on. +/// +/// One notion rather than two, because "the place I marked as preferred" and +/// "what the app shows me when I open it" are the same thing to the person +/// using it. Selecting from the dropdown sets both. +class MapTargetController extends Notifier { + @override + MapTarget build() { + // Places load asynchronously, so the preferred target is resolved once they + // arrive rather than guessed now. + ref.listen>>(savedPlacesProvider, ( + previous, + next, + ) { + if (previous?.hasValue ?? false) return; + final places = next.value; + if (places != null) unawaited(_restorePreferred(places)); + }, fireImmediately: true); + + return const FreeTarget(); + } + + bool _restored = false; + + Future _restorePreferred(List places) async { + if (_restored) return; + _restored = true; + + final stored = await ref.read(preferredTargetStoreProvider).load(); + if (stored == null) return; + + if (stored == 'follow') { + state = const FollowUser(); + return; + } + if (stored.startsWith('place:')) { + final id = stored.substring('place:'.length); + for (final place in places) { + if (place.id == id) { + state = PlaceTarget(place); + return; + } + } + // The preferred place was deleted. Forget it rather than reopening on + // something that no longer exists. + await ref.read(preferredTargetStoreProvider).save(null); + } + } + + /// Selects [target] and remembers it as what to reopen on. + Future select(MapTarget target) async { + state = target; + await ref.read(preferredTargetStoreProvider).save(target.storageValue); + } + + /// Drops out of following without changing what the app reopens on. + /// + /// Called when the user drags the map while it is chasing them: they want to + /// look somewhere else *now*, which is not the same as changing their mind + /// about what the app should do next launch. + void releaseFollow() { + if (state is FollowUser) state = const FreeTarget(); + } + + /// Forgets a place that has been deleted, if it was the target. + Future forget(String placeId) async { + final current = state; + if (current is PlaceTarget && current.place.id == placeId) { + state = const FreeTarget(); + await ref.read(preferredTargetStoreProvider).save(null); + } + } +} + +final mapTargetProvider = NotifierProvider( + MapTargetController.new, +); diff --git a/Nuvolari/app/lib/features/places/search_screen.dart b/Nuvolari/app/lib/features/places/search_screen.dart new file mode 100644 index 0000000..ff45307 --- /dev/null +++ b/Nuvolari/app/lib/features/places/search_screen.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/region/geo.dart'; +import '../../core/region/region_config.dart'; +import '../../core/region/region_repository.dart'; +import '../../l10n/app_localizations.dart'; +import 'comune.dart'; +import 'comuni_repository.dart'; +import 'coordinate_input.dart'; + +/// What the user picked out of search. +class SearchResult { + const SearchResult({required this.point, required this.label}); + + final GeoPoint point; + + /// How to name it if it gets saved as a place. + final String label; +} + +/// One field that takes either a municipality name or coordinates. +/// +/// Two separate searches would make the user decide up front which kind of +/// thing they are looking for, which they should not have to: a string either +/// parses as coordinates or it does not, and the answer is obvious from the +/// text itself. +class SearchScreen extends ConsumerStatefulWidget { + const SearchScreen({super.key}); + + @override + ConsumerState createState() => _SearchScreenState(); +} + +class _SearchScreenState extends ConsumerState { + final TextEditingController _controller = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final index = ref.watch(comuneIndexProvider); + final region = ref.watch(regionConfigProvider).value; + + final coordinates = parseCoordinates(_query); + final matches = switch (index) { + AsyncData(:final value) => value.search(_query), + _ => const [], + }; + + return Scaffold( + appBar: AppBar( + title: TextField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: l10n.searchHint, + border: InputBorder.none, + ), + onChanged: (value) => setState(() => _query = value), + ), + actions: [ + if (_query.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _controller.clear(); + setState(() => _query = ''); + }, + ), + ], + ), + body: switch (index) { + AsyncError(:final error) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('${l10n.dataUnavailable}\n$error'), + ), + ), + AsyncData() => _results( + context, + coordinates: coordinates, + matches: matches, + region: region, + ), + _ => Center(child: Text(l10n.loading)), + }, + ); + } + + Widget _results( + BuildContext context, { + required GeoPoint? coordinates, + required List matches, + required RegionConfig? region, + }) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + + if (_query.trim().isEmpty) { + return Padding( + padding: const EdgeInsets.all(24), + child: Text(l10n.searchHelp, style: theme.textTheme.bodyMedium), + ); + } + + final outsideRegion = + coordinates != null && + region != null && + !region.bounds.contains(coordinates); + + return ListView( + children: [ + if (coordinates != null) + ListTile( + leading: const Icon(Icons.my_location), + title: Text(l10n.searchCoordinatesResult), + subtitle: Text( + '${coordinates.latitude.toStringAsFixed(4)}, ' + '${coordinates.longitude.toStringAsFixed(4)}' + // Saying so beats letting them travel to an empty map. + '${outsideRegion ? '\n${l10n.searchOutsideRegion}' : ''}', + style: outsideRegion + ? TextStyle(color: theme.colorScheme.error) + : null, + ), + onTap: () => Navigator.of(context).pop( + SearchResult( + point: coordinates, + label: + '${coordinates.latitude.toStringAsFixed(4)}, ' + '${coordinates.longitude.toStringAsFixed(4)}', + ), + ), + ), + + if (coordinates != null && matches.isNotEmpty) const Divider(), + + if (matches.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + l10n.searchComuniCount(matches.length), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + + for (final comune in matches) + ListTile( + leading: const Icon(Icons.location_city), + title: Text(comune.name), + subtitle: Text(comune.province), + onTap: () => + Navigator.of(context) + .pop(SearchResult(point: comune.point, label: comune.name)), + ), + + if (matches.isEmpty && coordinates == null) + Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.searchNoResults, style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Text(l10n.searchHelp, style: theme.textTheme.bodySmall), + ], + ), + ), + ], + ); + } +} diff --git a/Nuvolari/app/lib/l10n/app_it.arb b/Nuvolari/app/lib/l10n/app_it.arb index a4ae0f7..961e778 100644 --- a/Nuvolari/app/lib/l10n/app_it.arb +++ b/Nuvolari/app/lib/l10n/app_it.arb @@ -305,5 +305,77 @@ "save": "Salva", "@save": { "description": "Generic save button" + }, + "targetFollowMe": "Segui la mia posizione", + "@targetFollowMe": { + "description": "Dropdown entry that makes the map follow the device position" + }, + "targetWholeRegion": "Tutto il {region}", + "@targetWholeRegion": { + "description": "Dropdown entry that frames the whole region", + "placeholders": { + "region": { + "type": "String", + "example": "Piemonte" + } + } + }, + "targetChoose": "Scegli cosa seguire", + "@targetChoose": { + "description": "Accessibility label for the target dropdown on the map" + }, + "targetIsPreferred": "Si apre qui", + "@targetIsPreferred": { + "description": "Marks the entry the app will reopen on" + }, + "searchTitle": "Cerca", + "@searchTitle": { + "description": "Title of the search screen" + }, + "searchOpen": "Cerca una località", + "@searchOpen": { + "description": "Tooltip on the map button that opens search" + }, + "searchHint": "Comune o coordinate", + "@searchHint": { + "description": "Placeholder in the search field" + }, + "searchHelp": "Digita il nome di un comune piemontese, oppure delle coordinate come 45.07, 7.69 o 45°04'13\"N 7°41'13\"E.", + "@searchHelp": { + "description": "Explains what the search field accepts, shown before anything is typed" + }, + "searchNoResults": "Nessun comune trovato", + "@searchNoResults": { + "description": "Shown when the query matches no municipality" + }, + "searchCoordinatesResult": "Vai a queste coordinate", + "@searchCoordinatesResult": { + "description": "Result entry shown when the typed text parses as coordinates" + }, + "searchOutsideRegion": "Fuori dall'area coperta dai dati", + "@searchOutsideRegion": { + "description": "Warning under a coordinate result that falls outside the region" + }, + "searchComuniCount": "{count, plural, one{1 comune} other{{count} comuni}}", + "@searchComuniCount": { + "description": "How many municipalities matched", + "placeholders": { + "count": { + "type": "int", + "example": "12" + } + } + }, + "mapCentreOnTarget": "Centra", + "@mapCentreOnTarget": { + "description": "Tooltip on the button that recentres the map on whatever is being followed" + }, + "mapSaveThisPoint": "Salva questo punto", + "@mapSaveThisPoint": { + "description": "Action that saves a searched point as a place" + }, + "sourcesComuniList": "Elenco comuni", + "@sourcesComuniList": { + "description": "Sources screen heading for the bundled municipality list" } } diff --git a/Nuvolari/app/test/core/region/piemonte_asset_test.dart b/Nuvolari/app/test/core/region/piemonte_asset_test.dart index 49fe6ce..dfbd9ca 100644 --- a/Nuvolari/app/test/core/region/piemonte_asset_test.dart +++ b/Nuvolari/app/test/core/region/piemonte_asset_test.dart @@ -130,7 +130,7 @@ void main() { test('carries the mandatory attributions', () { final ids = config.attributions.map((a) => a.id).toSet(); - expect(ids, containsAll(['dpc', 'osm', 'openmaptiles'])); + expect(ids, containsAll(['dpc', 'osm', 'openmaptiles', 'istat'])); final dpc = config.attributions.firstWhere((a) => a.id == 'dpc'); expect(dpc.license, 'CC BY-SA'); diff --git a/Nuvolari/app/test/features/places/comune_test.dart b/Nuvolari/app/test/features/places/comune_test.dart new file mode 100644 index 0000000..a1714ee --- /dev/null +++ b/Nuvolari/app/test/features/places/comune_test.dart @@ -0,0 +1,181 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/core/region/region_config.dart'; +import 'package:nuvolari/features/places/comune.dart'; +import 'package:nuvolari/features/places/comuni_repository.dart'; + +ComuneIndex loadBundled() => ComuneIndex.parse( + File('assets/regions/piemonte_comuni.json').readAsStringSync(), +); + +void main() { + group('foldForSearch', () { + test('lower-cases and strips the accents Italian names use', () { + expect(foldForSearch('Agliè'), 'aglie'); + expect(foldForSearch('CIRIÈ'), 'cirie'); + expect(foldForSearch('Mondovì'), 'mondovi'); + expect(foldForSearch('Forlì'), 'forli'); + }); + + test('leaves unaccented text alone apart from case', () { + expect(foldForSearch('Torino'), 'torino'); + expect(foldForSearch("Reggio nell'Emilia"), "reggio nell'emilia"); + }); + }); + + group('the bundled Piedmont list', () { + late ComuneIndex index; + late RegionConfig region; + + setUpAll(() { + index = loadBundled(); + region = RegionConfig.parse( + File('assets/regions/piemonte.json').readAsStringSync(), + ); + }); + + test('holds every Piedmont municipality', () { + // Piedmont had 1180 municipalities at 1 January 2025. A number that + // drifts means the generator picked up the wrong region or year. + expect(index.comuni, hasLength(1180)); + }); + + test('names its source and licence', () { + expect(index.source, contains('Istat')); + expect(index.license, 'CC BY 4.0'); + }); + + // A name decoded with the wrong codepage is the failure mode this data has + // already produced once, and it is silent unless something checks. + test('no name is mis-decoded', () { + final suspicious = index.comuni + .where( + (comune) => comune.name.contains('Ã') || comune.name.contains('�'), + ) + .map((comune) => comune.name) + .toList(); + + expect(suspicious, isEmpty); + }); + + test('accented names survived the pipeline intact', () { + final aglie = index.comuni.firstWhere((c) => c.istatCode == '001001'); + + expect(aglie.name, 'Agliè'); + expect(aglie.province, 'TO'); + }); + + test('every municipality falls inside the region bounds', () { + final outside = index.comuni + .where((comune) => !region.bounds.contains(comune.point)) + .map((comune) => comune.label) + .toList(); + + expect(outside, isEmpty); + }); + + test('istat codes are unique', () { + final codes = index.comuni.map((c) => c.istatCode).toSet(); + + expect(codes, hasLength(index.comuni.length)); + }); + + test('provinces are the eight of Piedmont', () { + final provinces = index.comuni.map((c) => c.province).toSet(); + + expect(provinces, {'TO', 'VC', 'NO', 'CN', 'AT', 'AL', 'BI', 'VB'}); + }); + + test('the provincial capitals are where they should be', () { + final torino = index.comuni.firstWhere((c) => c.name == 'Torino'); + + // A centroid, so a few kilometres out is expected; tens would mean the + // projection maths is wrong. + expect(torino.point.latitude, closeTo(45.07, 0.1)); + expect(torino.point.longitude, closeTo(7.68, 0.1)); + }); + }); + + group('search', () { + late ComuneIndex index; + + setUpAll(() => index = loadBundled()); + + test('finds a town by its exact name', () { + final results = index.search('Torino'); + + expect(results.first.name, 'Torino'); + }); + + // Someone typing "tor" wants Torino, not the first alphabetical name that + // happens to contain those letters somewhere. + test('ranks names that start with the query first', () { + final results = index.search('tor'); + + expect(results.first.searchKey, startsWith('tor')); + expect(results.map((c) => c.name), contains('Torino')); + }); + + test('ignores accents in the query and in the data', () { + expect(index.search('aglie').map((c) => c.name), contains('Agliè')); + expect(index.search('Agliè').map((c) => c.name), contains('Agliè')); + expect(index.search('mondovi').map((c) => c.name), contains('Mondovì')); + }); + + test('is case-insensitive', () { + expect(index.search('TORINO').first.name, 'Torino'); + expect(index.search('torino').first.name, 'Torino'); + }); + + test('matches in the middle of a name too', () { + final results = index.search('mondov'); + + expect(results, isNotEmpty); + }); + + test('an empty query returns nothing rather than everything', () { + expect(index.search(''), isEmpty); + expect(index.search(' '), isEmpty); + }); + + test('caps the result list', () { + // A single letter matches hundreds of names. + expect( + index.search('a').length, + lessThanOrEqualTo(ComuneIndex.maxResults), + ); + }); + + test('an unmatched query returns nothing', () { + expect(index.search('zzzzzzzz'), isEmpty); + }); + }); + + group('ComuneIndex.parse', () { + test('rejects a document with no source', () { + expect( + () => ComuneIndex.parse( + '{"places":[{"name":"X","province":"TO","istat":"1","lat":45,"lng":7}]}', + ), + throwsFormatException, + ); + }); + + test('rejects an empty list', () { + expect( + () => ComuneIndex.parse('{"source":"x","places":[]}'), + throwsFormatException, + ); + }); + }); + + group('ComuniRepository', () { + test('derives the asset path from the region id', () { + expect( + ComuniRepository.assetPathFor('piemonte'), + 'assets/regions/piemonte_comuni.json', + ); + }); + }); +} diff --git a/Nuvolari/app/test/features/places/coordinate_input_test.dart b/Nuvolari/app/test/features/places/coordinate_input_test.dart new file mode 100644 index 0000000..3615628 --- /dev/null +++ b/Nuvolari/app/test/features/places/coordinate_input_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/features/places/coordinate_input.dart'; + +void main() { + group('decimal degrees', () { + test('parses what people paste from a map', () { + final point = parseCoordinates('45.0703, 7.6869'); + + expect(point, isNotNull); + expect(point!.latitude, closeTo(45.0703, 1e-9)); + expect(point.longitude, closeTo(7.6869, 1e-9)); + }); + + test('accepts a space or a semicolon as the separator', () { + for (final text in [ + '45.0703 7.6869', + '45.0703; 7.6869', + '45.0703,7.6869', + ]) { + final point = parseCoordinates(text); + expect(point, isNotNull, reason: text); + expect(point!.latitude, closeTo(45.0703, 1e-9), reason: text); + } + }); + + test('latitude comes first, as on every consumer map', () { + final point = parseCoordinates('7.6869, 45.0703')!; + + expect(point.latitude, closeTo(7.6869, 1e-9)); + expect(point.longitude, closeTo(45.0703, 1e-9)); + }); + + test('handles hemisphere letters before or after the number', () { + expect(parseCoordinates('45.07N, 7.68E')!.latitude, closeTo(45.07, 1e-9)); + expect(parseCoordinates('N45.07, E7.68')!.longitude, closeTo(7.68, 1e-9)); + expect( + parseCoordinates('45.07S, 7.68W')!.latitude, + closeTo(-45.07, 1e-9), + ); + expect( + parseCoordinates('45.07S, 7.68W')!.longitude, + closeTo(-7.68, 1e-9), + ); + }); + + // Someone reading coordinates off an Italian map types O for ovest. + // Treating it as east would put them the wrong side of Greenwich. + test('reads O as west', () { + expect( + parseCoordinates('45.07N, 7.68O')!.longitude, + closeTo(-7.68, 1e-9), + ); + }); + + test('accepts signed values', () { + final point = parseCoordinates('-33.87, 151.21')!; + + expect(point.latitude, closeTo(-33.87, 1e-9)); + expect(point.longitude, closeTo(151.21, 1e-9)); + }); + + test('accepts the decimal comma an Italian keyboard produces', () { + final point = parseCoordinates('45,0703 7,6869')!; + + expect(point.latitude, closeTo(45.0703, 1e-9)); + expect(point.longitude, closeTo(7.6869, 1e-9)); + }); + }); + + group('degrees, minutes and seconds', () { + test('parses the full form', () { + final point = parseCoordinates('45°04\'13"N 7°41\'13"E'); + + expect(point, isNotNull); + expect(point!.latitude, closeTo(45 + 4 / 60 + 13 / 3600, 1e-6)); + expect(point.longitude, closeTo(7 + 41 / 60 + 13 / 3600, 1e-6)); + }); + + test('parses degrees and minutes without seconds', () { + final point = parseCoordinates("45°04'N 7°41'E")!; + + expect(point.latitude, closeTo(45 + 4 / 60, 1e-6)); + }); + + test('applies the southern and western hemispheres', () { + final point = parseCoordinates('33°52\'04"S 151°12\'36"W')!; + + expect(point.latitude, lessThan(0)); + expect(point.longitude, lessThan(0)); + }); + + test('rejects minutes or seconds at 60 or above', () { + expect(parseCoordinates('45°60\'00"N 7°41\'13"E'), isNull); + expect(parseCoordinates('45°04\'60"N 7°41\'13"E'), isNull); + }); + }); + + group('rejection', () { + // The same field takes a town name, so anything that is not coordinates has + // to come back null rather than throw or guess. + test('returns null for a town name', () { + expect(parseCoordinates('Torino'), isNull); + expect(parseCoordinates('Reggio nell Emilia'), isNull); + expect(parseCoordinates(''), isNull); + expect(parseCoordinates(' '), isNull); + }); + + test('returns null for a single number', () { + expect(parseCoordinates('45.07'), isNull); + }); + + test('returns null for out-of-range values', () { + expect(parseCoordinates('91.0, 7.0'), isNull); + expect(parseCoordinates('45.0, 181.0'), isNull); + expect(parseCoordinates('-91.0, 7.0'), isNull); + }); + + test('returns null for trailing rubbish', () { + expect(parseCoordinates('45.07, 7.68 and then some'), isNull); + }); + }); +} diff --git a/Nuvolari/app/test/features/sources/sources_screen_test.dart b/Nuvolari/app/test/features/sources/sources_screen_test.dart index d1e7590..8e805a1 100644 --- a/Nuvolari/app/test/features/sources/sources_screen_test.dart +++ b/Nuvolari/app/test/features/sources/sources_screen_test.dart @@ -36,7 +36,16 @@ void main() { await tester.pumpWidget(wrap(const SourcesScreen(), region)); await tester.pumpAndSettle(); + // The list is longer than the test viewport, so each entry is scrolled to + // rather than expected to be on screen at once. Asserting only on what + // happens to fit would let a credit fall off the bottom unnoticed. for (final attribution in region.attributions) { + await tester.scrollUntilVisible( + find.text(attribution.text), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); expect( find.text(attribution.text), findsOneWidget, diff --git a/Nuvolari/docs/data-sources.md b/Nuvolari/docs/data-sources.md index b056722..dd248f5 100644 --- a/Nuvolari/docs/data-sources.md +++ b/Nuvolari/docs/data-sources.md @@ -286,7 +286,38 @@ come from the ARPA zone documentation. A captured copy of the live feed is kept as a test fixture so the parser is verified against the real document rather than a hand-written approximation. -## 6. Base map — OpenFreeMap +## 6. Municipalities — Istat boundaries + +The 1180 municipalities of Piedmont with a representative point each, bundled as +`app/assets/regions/piemonte_comuni.json` (98 KB) and generated by +`tool/generate_places.py`. + +**Source**: Istat, *Confini delle unita amministrative a fini statistici*, +generalised boundaries at 1 January 2025, **CC BY 4.0**. +`https://www.istat.it/storage/cartografia/confini_amministrativi/generalizzati/2025/Limiti01012025_g.zip` + +Bundled rather than geocoded online because the app is region-scoped: the search +is instant, works with no network, needs no API key, has no rate limit to +respect, and cannot return a result somewhere the app has no radar for. + +Two properties of the source file are worth knowing before touching the +generator: + +- **The shapefile is in WGS84 / UTM zone 32N**, not in degrees. The generator + runs an inverse transverse Mercator to get latitude and longitude. +- **The DBF is mixed-encoding.** It is UTF-8 — "Aglie" ends in a single U+00E8 — + but at least one bilingual Friulian record makes strict cp1252 fail, and a + terminal renders utf-8 and latin-1 output identically, so this cannot be + settled by looking at printed text. The generator asserts that no output name + contains the tell-tale mojibake sequences, and a test asserts it again against + the shipped asset. + +Points are the centroid of each municipality's **largest** part, not of the whole +shape: several are multipart, and averaging a main body with its exclaves can +land the point on a neighbour. Expect 1-4 km between the point and the town +centre, which is well inside what framing a map needs. + +## 7. Base map — OpenFreeMap OpenStreetMap vector tiles served by **OpenFreeMap** (). @@ -321,7 +352,7 @@ bounding box — flat background plus the extent outline, no network sources at deliberately plain so it is never mistaken for a finished map, and it is what the widget tests run against. -## 7. Lightning — out of scope +## 8. Lightning — out of scope Not part of the app. If it is ever revisited: **Blitzortung prohibits commercial use**, and the DPC `LTG` product is the source to reach for instead. diff --git a/Nuvolari/docs/licenses.md b/Nuvolari/docs/licenses.md index f463eee..30a19e1 100644 --- a/Nuvolari/docs/licenses.md +++ b/Nuvolari/docs/licenses.md @@ -14,6 +14,7 @@ to save screen space. | OpenStreetMap data | ODbL | "© OpenStreetMap contributors" | Must stay visible on the map | | OpenMapTiles schema | BSD 3-Clause | "© OpenMapTiles" | Must stay visible on the map | | OpenFreeMap service | MIT | "OpenFreeMap" — optional | Listed on the Sources screen | +| Istat municipality boundaries | CC BY 4.0 | "Istat" | Bundled municipality list, commercial use permitted | ## Share-alike is the constraint that shapes the backend diff --git a/Nuvolari/docs/privacy.md b/Nuvolari/docs/privacy.md index e0c3251..6ad5810 100644 --- a/Nuvolari/docs/privacy.md +++ b/Nuvolari/docs/privacy.md @@ -48,6 +48,17 @@ Accuracy", and declining it yields **no fix at all** — an absurd outcome for a that only ever wanted an approximate one. It also means location works on devices without Play Services. +### Following, and continuous updates + +Selecting "segui la mia posizione" hands tracking to the map's own location +component, which receives updates continuously while the app is in the +foreground. That is more than the single fix everything else needs, so it is +opt-in, it is visible — the chip at the top of the map says so — and it stops the +moment the user drags the map away. + +It never runs in the background: there is no background location permission and +no foreground service, so closing the app ends it. + ### The permission flow - A **prominent disclosure** is shown before the system dialog, as Google Play requires, diff --git a/Nuvolari/docs/roadmap.md b/Nuvolari/docs/roadmap.md index a862636..74fb0b3 100644 --- a/Nuvolari/docs/roadmap.md +++ b/Nuvolari/docs/roadmap.md @@ -78,7 +78,7 @@ and a disk layer belongs with the network adapter, where it would save a real re --- -## M4 — Saved places and device location ✅ +## M4 — Places, search and device location ✅ A named point the user keeps, used to frame the map and — once notifications exist — to anchor them. Stored in `SharedPreferences` on the device and nowhere else. @@ -104,6 +104,22 @@ A unit test also caught an id collision: ids came straight from the microsecond so two places saved in the same microsecond shared an id and rename, remove and the duplicate check all acted on the wrong one. +**Extended** with search and a target selector, also verified on the emulator: + +- One search field takes either a municipality name or coordinates, because a string + either parses as coordinates or it does not and the user should not have to declare + which they meant. Accent-insensitive: typing "aglie" finds "Agliè". Coordinates accept + decimal and degrees-minutes-seconds, comma or space separated, with hemisphere letters + including the Italian **O** for west. +- The 1180 municipalities are bundled from Istat boundaries rather than geocoded online. + A test asserts every one of them falls inside the region bounds, which is what + validates the UTM-to-degrees conversion end to end. +- A dropdown over the map switches between following the device, a saved place, and the + whole region; the selection is also what the app reopens on. Dragging the map while it + is following stops the camera chasing but leaves that preference alone. +- The blue position marker and the following are MapLibre's own, so there is no custom + location stream to keep in step with the map. + ## M5 — Backend worker ⛔ The critical path. Until this exists, `DpcRadarSource` has nothing to read and the app diff --git a/Nuvolari/tool/generate_places.py b/Nuvolari/tool/generate_places.py new file mode 100644 index 0000000..a4ee718 --- /dev/null +++ b/Nuvolari/tool/generate_places.py @@ -0,0 +1,280 @@ +"""Generate the searchable list of Piedmont municipalities. + +The app is region-scoped, so a bundled list beats a geocoding service on every +axis that matters here: the search is instant, works with no network, needs no +API key, has no rate limit to respect, and cannot return a result in a place the +app has no radar for. + +Source: Istat "Confini delle unità amministrative a fini statistici", +generalised boundaries, published under CC BY 4.0. The shapefile is in WGS84 / +UTM zone 32N, so each municipality's representative point is converted back to +latitude and longitude here rather than at run time. + +Dependencies: `pyshp` only. The inverse transverse Mercator is implemented below +instead of pulling in pyproj, because that is the single piece of geodesy this +script needs and a whole projection library is a heavy thing to make everyone +install for it. + +Usage: + python -m pip install pyshp + python tool/generate_places.py +""" + +from __future__ import annotations + +import io +import json +import math +import pathlib +import sys +import urllib.request +import zipfile + +import shapefile # pyshp + +SOURCE_URL = ( + "https://www.istat.it/storage/cartografia/confini_amministrativi/" + "generalizzati/2025/Limiti01012025_g.zip" +) +SHAPE_BASE = "Com01012025_g/Com01012025_g_WGS84" + +# Istat region code for Piemonte. +REGION_CODE = 1 + +# Istat province codes to the two-letter abbreviations people actually recognise. +PROVINCES = { + 1: "TO", + 2: "VC", + 3: "NO", + 4: "CN", + 5: "AT", + 6: "AL", + 96: "BI", + 103: "VB", +} + +OUT_PATH = ( + pathlib.Path(__file__).resolve().parent.parent + / "app" + / "assets" + / "regions" + / "piemonte_comuni.json" +) + +CACHE_PATH = pathlib.Path(__file__).resolve().parent / ".cache" / "istat_limiti.zip" + +# --- WGS84 / UTM 32N inverse ------------------------------------------------- + +_A = 6378137.0 # WGS84 semi-major axis +_F = 1 / 298.257223563 # flattening +_K0 = 0.9996 # UTM scale factor +_FALSE_EASTING = 500000.0 +_CENTRAL_MERIDIAN = math.radians(9.0) # zone 32 + + +def utm32n_to_wgs84(easting: float, northing: float) -> tuple[float, float]: + """Converts UTM zone 32N metres to (longitude, latitude) in degrees. + + Snyder's inverse transverse Mercator series, accurate to a few centimetres + inside the zone — orders of magnitude finer than a municipality centroid + needs to be. + """ + e2 = _F * (2 - _F) + e_prime2 = e2 / (1 - e2) + + x = easting - _FALSE_EASTING + m = northing / _K0 + + mu = m / (_A * (1 - e2 / 4 - 3 * e2**2 / 64 - 5 * e2**3 / 256)) + e1 = (1 - math.sqrt(1 - e2)) / (1 + math.sqrt(1 - e2)) + + phi1 = ( + mu + + (3 * e1 / 2 - 27 * e1**3 / 32) * math.sin(2 * mu) + + (21 * e1**2 / 16 - 55 * e1**4 / 32) * math.sin(4 * mu) + + (151 * e1**3 / 96) * math.sin(6 * mu) + + (1097 * e1**4 / 512) * math.sin(8 * mu) + ) + + sin_phi1 = math.sin(phi1) + cos_phi1 = math.cos(phi1) + tan_phi1 = math.tan(phi1) + + c1 = e_prime2 * cos_phi1**2 + t1 = tan_phi1**2 + n1 = _A / math.sqrt(1 - e2 * sin_phi1**2) + r1 = _A * (1 - e2) / (1 - e2 * sin_phi1**2) ** 1.5 + d = x / (n1 * _K0) + + latitude = phi1 - (n1 * tan_phi1 / r1) * ( + d**2 / 2 + - (5 + 3 * t1 + 10 * c1 - 4 * c1**2 - 9 * e_prime2) * d**4 / 24 + + (61 + 90 * t1 + 298 * c1 + 45 * t1**2 - 252 * e_prime2 - 3 * c1**2) + * d**6 + / 720 + ) + + longitude = _CENTRAL_MERIDIAN + ( + d + - (1 + 2 * t1 + c1) * d**3 / 6 + + (5 - 2 * c1 + 28 * t1 - 3 * c1**2 + 8 * e_prime2 + 24 * t1**2) + * d**5 + / 120 + ) / cos_phi1 + + return math.degrees(longitude), math.degrees(latitude) + + +# --- geometry ---------------------------------------------------------------- + + +def ring_area_and_centroid(ring: list[tuple[float, float]]) -> tuple[float, float, float]: + """Signed area and centroid of a closed ring, by the shoelace formula.""" + area2 = 0.0 + cx = 0.0 + cy = 0.0 + for i in range(len(ring) - 1): + x0, y0 = ring[i] + x1, y1 = ring[i + 1] + cross = x0 * y1 - x1 * y0 + area2 += cross + cx += (x0 + x1) * cross + cy += (y0 + y1) * cross + if area2 == 0: + # Degenerate ring: fall back to the mean of its vertices. + xs = [p[0] for p in ring] + ys = [p[1] for p in ring] + return 0.0, sum(xs) / len(xs), sum(ys) / len(ys) + return area2 / 2, cx / (3 * area2), cy / (3 * area2) + + +def representative_point(shape: shapefile.Shape) -> tuple[float, float]: + """A point inside the municipality, in the shapefile's own coordinates. + + Uses the centroid of the **largest** ring rather than of the whole shape. + Many Piedmont municipalities are multipart — a main body plus exclaves — and + averaging those together can land the point on a neighbour. + """ + parts = list(shape.parts) + [len(shape.points)] + best_area = -1.0 + best = (shape.points[0][0], shape.points[0][1]) + + for start, end in zip(parts, parts[1:]): + ring = [(p[0], p[1]) for p in shape.points[start:end]] + if len(ring) < 4: + continue + if ring[0] != ring[-1]: + ring.append(ring[0]) + area, cx, cy = ring_area_and_centroid(ring) + if abs(area) > best_area: + best_area = abs(area) + best = (cx, cy) + + return best + + +# --- main -------------------------------------------------------------------- + + +def fetch_archive() -> bytes: + if CACHE_PATH.exists(): + print(f"using cached {CACHE_PATH}") + return CACHE_PATH.read_bytes() + + print(f"downloading {SOURCE_URL}") + with urllib.request.urlopen(SOURCE_URL, timeout=300) as response: + data = response.read() + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + CACHE_PATH.write_bytes(data) + print(f"cached {len(data) / 1_000_000:.1f} MB at {CACHE_PATH}") + return data + + +def main() -> int: + archive = zipfile.ZipFile(io.BytesIO(fetch_archive())) + reader = shapefile.Reader( + shp=io.BytesIO(archive.read(SHAPE_BASE + ".shp")), + dbf=io.BytesIO(archive.read(SHAPE_BASE + ".dbf")), + shx=io.BytesIO(archive.read(SHAPE_BASE + ".shx")), + # UTF-8, confirmed by decoding a known name down to its codepoints: + # "Aglie" ends in a single U+00E8 under utf-8, and in U+00C3 U+00A8 + # under latin-1. A terminal renders both identically, so do not + # settle this by looking at printed output -- the guard below is + # what actually holds the line. + encoding="utf-8", + ) + + places = [] + unknown_provinces = set() + + for record, shape in zip(reader.iterRecords(), reader.iterShapes()): + if record["COD_REG"] != REGION_CODE: + continue + + province = PROVINCES.get(record["COD_PROV"]) + if province is None: + unknown_provinces.add(record["COD_PROV"]) + continue + + easting, northing = representative_point(shape) + longitude, latitude = utm32n_to_wgs84(easting, northing) + + places.append( + { + "name": record["COMUNE"], + "province": province, + "istat": record["PRO_COM_T"], + "lat": round(latitude, 5), + "lng": round(longitude, 5), + } + ) + + # Latin-1 is an assumption about someone else's file. If Istat ever + # re-encodes it, the accents turn into these tell-tale sequences rather + # than into an error, so check for them explicitly. Written as escapes + # so this guard cannot itself be broken by an encoding accident. + MOJIBAKE_MARKERS = ("Ã", "Â", "�") + mis_decoded = [ + place["name"] + for place in places + if any(marker in place["name"] for marker in MOJIBAKE_MARKERS) + ] + if mis_decoded: + print(f"ERROR: names look mis-decoded: {mis_decoded[:5]}", file=sys.stderr) + return 1 + + if unknown_provinces: + print(f"ERROR: unmapped province codes {sorted(unknown_provinces)}", file=sys.stderr) + return 1 + + places.sort(key=lambda place: place["name"]) + + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUT_PATH.write_text( + json.dumps( + { + "region": "piemonte", + "source": "Istat — Confini delle unità amministrative a fini statistici, 1 gennaio 2025", + "license": "CC BY 4.0", + "sourceUrl": SOURCE_URL, + "places": places, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + + "\n", + encoding="utf-8", + ) + + size_kb = OUT_PATH.stat().st_size / 1024 + print(f"{len(places)} comuni written to {OUT_PATH} ({size_kb:.0f} KB)") + + by_province: dict[str, int] = {} + for place in places: + by_province[place["province"]] = by_province.get(place["province"], 0) + 1 + print("by province:", dict(sorted(by_province.items()))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())