Add municipality and coordinate search, and a map target selector

Puts the controls that change what the map is looking at over the map itself,
where they act, and gives them something to search.

Search is one field, not two. A string either parses as coordinates or it does
not, and the answer is obvious from the text, so making the user declare up
front which kind of thing they are looking for would be asking them to do the
program's job. Coordinates accept decimal and degrees-minutes-seconds, comma or
space separated, with hemisphere letters — including the Italian O for ovest,
because someone reading an Italian map will type it and silently reading it as
east would put them the wrong side of Greenwich.

The 1180 Piedmont municipalities are bundled rather than geocoded online. The
app is region-scoped, so a list of one region's towns is small enough to ship
(100 KB) and beats a geocoder on every axis that matters: instant, offline, no
API key, no rate limit, and it cannot return a result somewhere the app has no
radar for. Matching folds accents, so "aglie" finds "Agliè", and prefix matches
outrank substring ones — typing "tor" should surface Torino, not the first
alphabetical name that happens to contain those letters.

tool/generate_places.py derives the list from Istat boundary shapefiles
(CC BY 4.0). Two properties of that file cost time and are now written down:
the geometry is UTM 32N rather than degrees, and the DBF is UTF-8 despite one
bilingual Friulian record that makes strict cp1252 fail. A terminal renders
utf-8 and latin-1 output identically, so the encoding cannot be settled by
looking at printed text — it took dumping codepoints. A guard in the generator
and a test against the shipped asset both check for mojibake now, and the guard
caught a real mistake the moment it was written.

The target selector switches between following the device, a saved place, and
the whole region, and the selection doubles as what the app reopens on: "the
place I marked" and "what I see when I open the app" are one idea to the person
using it. Dragging the map while it is following stops the camera chasing them
but leaves that preference alone, because looking somewhere else now is not the
same as changing their mind about next launch.

The position marker and the following are MapLibre's own, driven by
onCameraTrackingDismissed, so there is no second location stream to keep in step
with the map.

A test asserts that all 1180 municipalities fall inside the region bounds, which
is what actually validates the UTM-to-degrees conversion end to end.

Verified on the emulator: the dropdown lists follow, region and both saved
places; "aglie" finds Agliè; "44.3841 7.5426" offers the coordinate jump and
lands on Cuneo at town-reading zoom; selecting follow moves the map to the
device position with the blue dot on it and changes the recentre button to
match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:47:20 +02:00
co-authored by Claude Opus 5
parent 06ab816ebe
commit b4b8d089e6
21 changed files with 2062 additions and 210 deletions
+3
View File
@@ -62,3 +62,6 @@ backend/.cache/
# Editors # Editors
.idea/ .idea/
# Downloaded source archives for tool/ generators.
tool/.cache/
+6 -2
View File
@@ -10,8 +10,8 @@ regions via configuration.
In scope: In scope:
- Animated precipitation radar over an OpenStreetMap base map - Animated precipitation radar over an OpenStreetMap base map
- Saved places and optional device location, used to frame the map and later to anchor - Saved places, municipality and coordinate search, and optional device location with a
notifications 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, - Ground-station rain accumulations and 72-hour temperature from the ARPA realtime API,
published through the worker published through the worker
- ARPA Piemonte official alert bulletin (XML-CAP), republished verbatim - 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 - Base map: OpenFreeMap (https://openfreemap.org), free OSM vector tiles with no API key
and no request limits. Mandatory credits: "© OpenMapTiles" and and no request limits. Mandatory credits: "© OpenMapTiles" and
"© OpenStreetMap contributors" (ODbL). OpenFreeMap's own credit is optional. "© 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) ## Verification loop (run after each milestone)
- `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle` - `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle`
+121 -21
View File
@@ -2,42 +2,136 @@
"id": "piemonte", "id": "piemonte",
"displayName": "Piemonte", "displayName": "Piemonte",
"timeZone": "Europe/Rome", "timeZone": "Europe/Rome",
"bbox": [
"bbox": [6.55, 43.95, 9.30, 46.55], 6.55,
43.95,
9.3,
46.55
],
"map": { "map": {
"center": [7.95, 45.25], "center": [
"zoom": { "min": 6.0, "max": 13.0, "initial": 7.2 } 7.95,
45.25
],
"zoom": {
"min": 6.0,
"max": 13.0,
"initial": 7.2
}
}, },
"sources": { "sources": {
"radar": { "radar": {
"defaultAdapter": "mock", "defaultAdapter": "mock",
"availableAdapters": ["mock", "dpc"], "availableAdapters": [
"mock",
"dpc"
],
"product": "VMI", "product": "VMI",
"frameIntervalMinutes": 5 "frameIntervalMinutes": 5
}, },
"alerts": { "alerts": {
"defaultAdapter": "arpaCap", "defaultAdapter": "arpaCap",
"availableAdapters": ["arpaCap"], "availableAdapters": [
"arpaCap"
],
"officialBulletinUrl": "https://www.arpa.piemonte.it/rischi_naturali/boll/bollettino_allerta.pdf" "officialBulletinUrl": "https://www.arpa.piemonte.it/rischi_naturali/boll/bollettino_allerta.pdf"
} }
}, },
"alertZones": [ "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-A",
{ "code": "Piem-C", "name": "Valli Orco, Lanzo, bassa Val Susa e Sangone", "provinces": ["TO"] }, "name": "Toce",
{ "code": "Piem-D", "name": "Alta Val Susa, Valli Chisone, Pellice e Po", "provinces": ["CN", "TO"] }, "provinces": [
{ "code": "Piem-E", "name": "Valli Varaita, Maira e Stura", "provinces": ["CN"] }, "NO",
{ "code": "Piem-F", "name": "Valle Tanaro", "provinces": ["CN"] }, "VB"
{ "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-B",
{ "code": "Piem-M", "name": "Pianura Cuneese", "provinces": ["CN", "TO"] } "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": [ "attributions": [
{ {
"id": "dpc", "id": "dpc",
@@ -68,6 +162,12 @@
"text": "OpenFreeMap", "text": "OpenFreeMap",
"license": "MIT", "license": "MIT",
"url": "https://openfreemap.org/" "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/"
} }
] ]
} }
File diff suppressed because one or more lines are too long
@@ -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 <SavedPlace>[];
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) => <PopupMenuEntry<_TargetChoice>>[
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),
),
],
);
}
}
@@ -12,130 +12,21 @@ import '../../data/radar/radar_manifest.dart';
import '../../data/radar/radar_source.dart'; import '../../data/radar/radar_source.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../places/location_flow.dart'; import '../places/location_flow.dart';
import '../places/location_service.dart';
import '../places/map_target.dart';
import '../places/places_screen.dart'; import '../places/places_screen.dart';
import '../places/saved_place.dart'; import '../places/saved_place.dart';
import '../places/saved_places.dart'; import '../places/saved_places.dart';
import '../places/search_screen.dart';
import '../settings/settings_screen.dart'; import '../settings/settings_screen.dart';
import '../timeline/data_age_banner.dart'; import '../timeline/data_age_banner.dart';
import '../timeline/radar_timeline.dart'; import '../timeline/radar_timeline.dart';
import '../timeline/timeline_bar.dart'; import '../timeline/timeline_bar.dart';
import 'attribution_bar.dart'; import 'attribution_bar.dart';
import 'map_overlay_controls.dart';
import 'map_style.dart'; import 'map_style.dart';
import 'radar_overlay.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<MapLibreMapController?> {
@override
MapLibreMapController? build() => null;
void attach(MapLibreMapController controller) => state = controller;
void detach() => state = null;
}
final mapControllerProvider =
NotifierProvider<MapControllerHolder, MapLibreMapController?>(
MapControllerHolder.new,
);
/// Moves the map onto [point], keeping the current zoom.
Future<void> _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<SavedPlace>(
MaterialPageRoute<SavedPlace>(
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<void>(builder: (_) => const SettingsScreen())),
);
}
}
/// The radar map: base map, animated precipitation overlay, timeline. /// The radar map: base map, animated precipitation overlay, timeline.
class RadarMapScreen extends ConsumerWidget { class RadarMapScreen extends ConsumerWidget {
const RadarMapScreen({super.key}); const RadarMapScreen({super.key});
@@ -148,7 +39,17 @@ class RadarMapScreen extends ConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(l10n.appTitle), 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<void>(builder: (_) => const SettingsScreen()),
),
),
],
), ),
body: switch (region) { body: switch (region) {
AsyncData(:final value) => _MapWithAttribution(region: value), AsyncData(:final value) => _MapWithAttribution(region: value),
@@ -185,19 +86,7 @@ class _MapWithAttribution extends ConsumerWidget {
actions: const [SizedBox.shrink()], actions: const [SizedBox.shrink()],
), ),
Expanded( Expanded(
child: Stack( child: _RegionMap(region: region, style: style),
children: [
Positioned.fill(
child: _RegionMap(region: region, style: style),
),
if (manifest != null)
Positioned(
left: 12,
bottom: 12,
child: _RadarLegend(legend: manifest.legend),
),
],
),
), ),
const DataAgeBanner(), const DataAgeBanner(),
const TimelineBar(), const TimelineBar(),
@@ -235,50 +124,53 @@ class _RegionMap extends ConsumerStatefulWidget {
} }
class _RegionMapState extends ConsumerState<_RegionMap> { 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; MapLibreMapController? _controller;
RadarOverlay? _overlay; RadarOverlay? _overlay;
AppLifecycleListener? _lifecycle; AppLifecycleListener? _lifecycle;
/// Guards against a frame that finished loading after the playhead moved on, /// Whether the map may draw the blue dot.
/// which would briefly show the wrong image. ///
/// 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; int _requestId = 0;
@override @override
void initState() { void initState() {
super.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( _lifecycle = AppLifecycleListener(
onPause: () => onPause: () =>
ref.read(radarTimelineProvider.notifier).onAppBackgrounded(), ref.read(radarTimelineProvider.notifier).onAppBackgrounded(),
); );
unawaited(_syncLocationAvailability());
} }
@override @override
void dispose() { void dispose() {
_lifecycle?.dispose(); _lifecycle?.dispose();
unawaited(_overlay?.detach()); 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<void>.microtask(
() => ref.read(mapControllerProvider.notifier).detach(),
).ignore();
super.dispose(); super.dispose();
} }
/// Frames the whole region once the map is ready. Future<void> _syncLocationAvailability() async {
/// final availability = await ref.read(locationServiceProvider).availability();
/// The configured initial zoom is only a starting point: a single zoom number if (!mounted) return;
/// cannot fit the region on both a small phone and a tablet, and on a 411dp setState(
/// screen zoom 7.2 puts the viewport entirely inside the region, so the user () => _locationEnabled = availability == LocationAvailability.granted,
/// sees a flat expanse with no border or outline to orient by. Fitting the );
/// bounds works on every screen size. }
Future<void> _onMapCreated(MapLibreMapController controller) async {
_controller = controller;
ref.read(mapControllerProvider.notifier).attach(controller);
// --- camera ---------------------------------------------------------------
Future<void> _frameRegion() async {
final bounds = widget.region.bounds; final bounds = widget.region.bounds;
await controller.moveCamera( await _controller?.moveCamera(
CameraUpdate.newLatLngBounds( CameraUpdate.newLatLngBounds(
LatLngBounds( LatLngBounds(
southwest: LatLng(bounds.south, bounds.west), 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 Future<void> _centreOn(GeoPoint point, {double? zoom}) async {
/// whenever it changes, so the overlay is rebuilt here rather than in await _controller?.animateCamera(
/// [_onMapCreated]. 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<void> _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<void> _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<void> _openSearch() async {
final result = await Navigator.of(context).push<SearchResult>(
MaterialPageRoute<SearchResult>(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<void> _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<void> _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<SavedPlace>(
MaterialPageRoute<SavedPlace>(
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<void> _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<void> _onMapCreated(MapLibreMapController controller) async {
_controller = controller;
await _applyTarget(ref.read(mapTargetProvider));
}
Future<void> _onStyleLoaded() async { Future<void> _onStyleLoaded() async {
final controller = _controller; final controller = _controller;
if (controller == null) return; if (controller == null) return;
@@ -313,13 +332,11 @@ class _RegionMapState extends ConsumerState<_RegionMap> {
final bytes = await ref final bytes = await ref
.read(radarTimelineProvider.notifier) .read(radarTimelineProvider.notifier)
.bytesFor(frame); .bytesFor(frame);
// Someone scrubbed while this was loading; that newer frame wins.
if (!mounted || request != _requestId) return; if (!mounted || request != _requestId) return;
await overlay.show(frame.path, bytes); await overlay.show(frame.path, bytes);
} on RadarUnavailableException { } on RadarUnavailableException {
// Hold the previous frame. A gap mid-timeline is normal while the worker // 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 // publishes, and blanking the map is a worse answer.
// the neighbouring minute.
} }
} }
@@ -329,41 +346,91 @@ class _RegionMapState extends ConsumerState<_RegionMap> {
radarTimelineProvider.select((state) => state.currentFrame), radarTimelineProvider.select((state) => state.currentFrame),
(_, frame) => unawaited(_showFrame(frame)), (_, frame) => unawaited(_showFrame(frame)),
); );
ref.listen<MapTarget>(
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 zoom = widget.region.map.zoom;
final bounds = widget.region.bounds; final bounds = widget.region.bounds;
return MapLibreMap( return Stack(
styleString: widget.style.styleString, children: [
onMapCreated: _onMapCreated, Positioned.fill(
// Without this the controller's cameraPosition stays null, and "save the child: MapLibreMap(
// map centre" silently saves the region default instead of what the user styleString: widget.style.styleString,
// is actually looking at. onMapCreated: _onMapCreated,
trackCameraPosition: true, onStyleLoadedCallback: () => unawaited(_onStyleLoaded()),
onStyleLoadedCallback: () => unawaited(_onStyleLoaded()), // Without this the controller's cameraPosition stays null, and
initialCameraPosition: CameraPosition( // "save the map centre" silently saves the region default instead
target: LatLng(center.latitude, center.longitude), // of what the user is actually looking at.
zoom: zoom.initial, trackCameraPosition: true,
), initialCameraPosition: CameraPosition(
minMaxZoomPreference: MinMaxZoomPreference(zoom.min, zoom.max), target: LatLng(centre.latitude, centre.longitude),
// Panning is confined to the region: this app has data for Piedmont and zoom: zoom.initial,
// nowhere else, so letting the user drift away would only ever show an ),
// empty map. minMaxZoomPreference: MinMaxZoomPreference(zoom.min, zoom.max),
cameraTargetBounds: CameraTargetBounds( cameraTargetBounds: CameraTargetBounds(
LatLngBounds( LatLngBounds(
southwest: LatLng(bounds.south, bounds.west), southwest: LatLng(bounds.south, bounds.west),
northeast: LatLng(bounds.north, bounds.east), 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 Positioned(
// require the plugin's own attribution control, and a duplicated credit left: 12,
// is harmless where a missing one is a licence breach. right: 12,
attributionButtonPosition: AttributionButtonPosition.bottomRight, top: 12,
compassEnabled: false, child: SafeArea(
rotateGesturesEnabled: false, bottom: false,
tiltGesturesEnabled: false, child: Align(
myLocationEnabled: false, 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()),
),
),
],
); );
} }
} }
@@ -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<String, Object?> 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 = <String, String>{
'à': '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<String, Object?>) {
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<String, Object?>>()
.map(Comune.fromJson)
.toList(growable: false),
source: source,
license: license is String && license.isNotEmpty ? license : null,
);
}
final List<Comune> 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<Comune> search(String query) {
final needle = foldForSearch(query.trim());
if (needle.isEmpty) return const <Comune>[];
final prefix = <Comune>[];
final contains = <Comune>[];
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 <Comune>[
...prefix,
...contains,
].take(maxResults).toList(growable: false);
}
}
@@ -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<ComuneIndex> load(String regionId) async {
final source = await _assets.loadString(assetPathFor(regionId));
return ComuneIndex.parse(source);
}
}
final comuniRepositoryProvider = Provider<ComuniRepository>(
(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<ComuneIndex>((ref) async {
final regionId = ref.watch(regionIdProvider);
return ref.watch(comuniRepositoryProvider).load(regionId);
});
@@ -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);
}
@@ -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<String?> load();
Future<void> save(String? value);
}
class SharedPreferencesPreferredTargetStore implements PreferredTargetStore {
const SharedPreferencesPreferredTargetStore();
static const String storageKey = 'nuvolari.preferred_target.v1';
@override
Future<String?> load() async =>
(await SharedPreferences.getInstance()).getString(storageKey);
@override
Future<void> 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<String?> load() async => _value;
@override
Future<void> save(String? value) async => _value = value;
}
final preferredTargetStoreProvider = Provider<PreferredTargetStore>(
(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<MapTarget> {
@override
MapTarget build() {
// Places load asynchronously, so the preferred target is resolved once they
// arrive rather than guessed now.
ref.listen<AsyncValue<List<SavedPlace>>>(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<void> _restorePreferred(List<SavedPlace> 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<void> 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<void> 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, MapTarget>(
MapTargetController.new,
);
@@ -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<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
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 <Comune>[],
};
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<Comune> 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),
],
),
),
],
);
}
}
+72
View File
@@ -305,5 +305,77 @@
"save": "Salva", "save": "Salva",
"@save": { "@save": {
"description": "Generic save button" "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"
} }
} }
@@ -130,7 +130,7 @@ void main() {
test('carries the mandatory attributions', () { test('carries the mandatory attributions', () {
final ids = config.attributions.map((a) => a.id).toSet(); final ids = config.attributions.map((a) => a.id).toSet();
expect(ids, containsAll(<String>['dpc', 'osm', 'openmaptiles'])); expect(ids, containsAll(<String>['dpc', 'osm', 'openmaptiles', 'istat']));
final dpc = config.attributions.firstWhere((a) => a.id == 'dpc'); final dpc = config.attributions.firstWhere((a) => a.id == 'dpc');
expect(dpc.license, 'CC BY-SA'); expect(dpc.license, 'CC BY-SA');
@@ -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',
);
});
});
}
@@ -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 <String>[
'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);
});
});
}
@@ -36,7 +36,16 @@ void main() {
await tester.pumpWidget(wrap(const SourcesScreen(), region)); await tester.pumpWidget(wrap(const SourcesScreen(), region));
await tester.pumpAndSettle(); 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) { for (final attribution in region.attributions) {
await tester.scrollUntilVisible(
find.text(attribution.text),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect( expect(
find.text(attribution.text), find.text(attribution.text),
findsOneWidget, findsOneWidget,
+33 -2
View File
@@ -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 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. 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** (<https://openfreemap.org>). OpenStreetMap vector tiles served by **OpenFreeMap** (<https://openfreemap.org>).
@@ -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 deliberately plain so it is never mistaken for a finished map, and it is what the widget
tests run against. 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**, 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. and the DPC `LTG` product is the source to reach for instead.
+1
View File
@@ -14,6 +14,7 @@ to save screen space.
| OpenStreetMap data | ODbL | "© OpenStreetMap contributors" | Must stay visible on the map | | OpenStreetMap data | ODbL | "© OpenStreetMap contributors" | Must stay visible on the map |
| OpenMapTiles schema | BSD 3-Clause | "© OpenMapTiles" | 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 | | 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 ## Share-alike is the constraint that shapes the backend
+11
View File
@@ -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 that only ever wanted an approximate one. It also means location works on devices
without Play Services. 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 ### The permission flow
- A **prominent disclosure** is shown before the system dialog, as Google Play requires, - A **prominent disclosure** is shown before the system dialog, as Google Play requires,
+17 -1
View File
@@ -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 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. 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 so two places saved in the same microsecond shared an id and rename, remove and the
duplicate check all acted on the wrong one. 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 ⛔ ## M5 — Backend worker ⛔
The critical path. Until this exists, `DpcRadarSource` has nothing to read and the app The critical path. Until this exists, `DpcRadarSource` has nothing to read and the app
+280
View File
@@ -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())