diff --git a/.claude/worktrees/relaxed-wright b/.claude/worktrees/relaxed-wright new file mode 160000 index 0000000..a3aaf1b --- /dev/null +++ b/.claude/worktrees/relaxed-wright @@ -0,0 +1 @@ +Subproject commit a3aaf1b6de30f5134d8180dbeee145d598e6e9a8 diff --git a/backend/initdb/02_tile_functions.sql b/backend/initdb/02_tile_functions.sql new file mode 100644 index 0000000..52e1e8d --- /dev/null +++ b/backend/initdb/02_tile_functions.sql @@ -0,0 +1,216 @@ +-- Zoom-dependent tile functions for Martin. +-- These apply geometry simplification and feature filtering so that +-- low-zoom tiles contain fewer, simpler features. + +-------------------------------------------------------------------------------- +-- planet_osm_polygon +-------------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tile_polygon(z integer, x integer, y integer) +RETURNS bytea AS $$ +DECLARE + bounds geometry; + tolerance double precision; + result bytea; +BEGIN + bounds := ST_TileEnvelope(z, x, y); + + -- Simplification tolerance scales with zoom: lower zoom = more aggressive + tolerance := CASE + WHEN z >= 13 THEN 0 -- full detail + WHEN z >= 11 THEN 1 -- light simplification + WHEN z >= 9 THEN 10 -- moderate + ELSE 50 -- heavy + END; + + WITH features AS ( + SELECT + osm_id, + name, + building, + landuse, + "natural", + amenity, + leisure, + water, + waterway, + CASE + WHEN tolerance = 0 THEN ST_AsMVTGeom(way, bounds) + ELSE ST_AsMVTGeom( + ST_Simplify(way, tolerance, true), + bounds + ) + END AS geom + FROM planet_osm_polygon + WHERE way && bounds + -- Feature filtering by zoom level: + AND CASE + -- zoom 8-9: only water, large forests/parks + WHEN z < 10 THEN + "natural" IN ('water', 'wood') OR landuse IN ('forest', 'reservoir') + -- zoom 10-11: add more landuse + WHEN z < 12 THEN + "natural" IN ('water', 'wood', 'wetland', 'heath', 'scrub') + OR landuse IN ('forest', 'reservoir', 'residential', 'industrial', + 'commercial', 'farmland', 'meadow', 'grass') + OR leisure IN ('park', 'nature_reserve', 'garden') + -- zoom 12: add buildings (large only) + WHEN z < 13 THEN + "natural" IS NOT NULL OR landuse IS NOT NULL OR leisure IS NOT NULL + OR water IS NOT NULL + OR (building IS NOT NULL AND ST_Area(way) > 5000) + -- zoom 13+: everything + ELSE true + END + ) + SELECT ST_AsMVT(features, 'planet_osm_polygon') INTO result FROM features; + + RETURN result; +END; +$$ LANGUAGE plpgsql STABLE PARALLEL SAFE; + +-------------------------------------------------------------------------------- +-- planet_osm_line +-------------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tile_line(z integer, x integer, y integer) +RETURNS bytea AS $$ +DECLARE + bounds geometry; + tolerance double precision; + result bytea; +BEGIN + bounds := ST_TileEnvelope(z, x, y); + + tolerance := CASE + WHEN z >= 13 THEN 0 + WHEN z >= 11 THEN 5 + ELSE 20 + END; + + WITH features AS ( + SELECT + osm_id, + name, + highway, + railway, + waterway, + CASE + WHEN tolerance = 0 THEN ST_AsMVTGeom(way, bounds) + ELSE ST_AsMVTGeom( + ST_Simplify(way, tolerance, true), + bounds + ) + END AS geom + FROM planet_osm_line + WHERE way && bounds + AND CASE + -- zoom 10-11: only major roads and rivers + WHEN z < 12 THEN + highway IN ('motorway', 'trunk', 'primary', 'secondary') + OR railway IN ('rail') + OR waterway IN ('river', 'canal') + -- zoom 12: add tertiary roads + WHEN z < 13 THEN + highway IN ('motorway', 'trunk', 'primary', 'secondary', + 'tertiary', 'motorway_link', 'trunk_link') + OR railway IS NOT NULL + OR waterway IS NOT NULL + -- zoom 13+: everything + ELSE true + END + ) + SELECT ST_AsMVT(features, 'planet_osm_line') INTO result FROM features; + + RETURN result; +END; +$$ LANGUAGE plpgsql STABLE PARALLEL SAFE; + +-------------------------------------------------------------------------------- +-- planet_osm_point +-------------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tile_point(z integer, x integer, y integer) +RETURNS bytea AS $$ +DECLARE + bounds geometry; + result bytea; +BEGIN + bounds := ST_TileEnvelope(z, x, y); + + WITH features AS ( + SELECT + osm_id, + name, + place, + amenity, + shop, + tourism, + "natural", + ST_AsMVTGeom(way, bounds) AS geom + FROM planet_osm_point + WHERE way && bounds + AND CASE + -- zoom 12: only cities/towns/villages and major POIs + WHEN z < 13 THEN + place IN ('city', 'town', 'village') + -- zoom 13: add hamlets and named POIs + WHEN z < 14 THEN + place IS NOT NULL + OR (name IS NOT NULL AND (amenity IS NOT NULL OR tourism IS NOT NULL)) + -- zoom 14+: everything + ELSE true + END + ) + SELECT ST_AsMVT(features, 'planet_osm_point') INTO result FROM features; + + RETURN result; +END; +$$ LANGUAGE plpgsql STABLE PARALLEL SAFE; + +-------------------------------------------------------------------------------- +-- planet_osm_roads +-------------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tile_roads(z integer, x integer, y integer) +RETURNS bytea AS $$ +DECLARE + bounds geometry; + tolerance double precision; + result bytea; +BEGIN + bounds := ST_TileEnvelope(z, x, y); + + tolerance := CASE + WHEN z >= 10 THEN 0 + WHEN z >= 8 THEN 10 + ELSE 50 + END; + + WITH features AS ( + SELECT + osm_id, + name, + highway, + ref, + CASE + WHEN tolerance = 0 THEN ST_AsMVTGeom(way, bounds) + ELSE ST_AsMVTGeom( + ST_Simplify(way, tolerance, true), + bounds + ) + END AS geom + FROM planet_osm_roads + WHERE way && bounds + AND CASE + -- zoom 6-7: motorways only + WHEN z < 8 THEN + highway IN ('motorway', 'trunk') + -- zoom 8-9: add primary roads + WHEN z < 10 THEN + highway IN ('motorway', 'trunk', 'primary') + -- zoom 10+: everything in the roads table + ELSE true + END + ) + SELECT ST_AsMVT(features, 'planet_osm_roads') INTO result FROM features; + + RETURN result; +END; +$$ LANGUAGE plpgsql STABLE PARALLEL SAFE; \ No newline at end of file diff --git a/backend/martin-config.yaml b/backend/martin-config.yaml index 1e945b0..c56cae6 100644 --- a/backend/martin-config.yaml +++ b/backend/martin-config.yaml @@ -3,43 +3,31 @@ listen_addresses: '0.0.0.0:3001' postgres: connection_string: ${DATABASE_URL} - tables: + functions: planet_osm_polygon: schema: public - table: planet_osm_polygon - srid: 3857 - geometry_column: way - geometry_type: GEOMETRY - minzoom: 4 + function: tile_polygon + minzoom: 8 maxzoom: 14 bounds: [-180.0, -85.0511, 180.0, 85.0511] planet_osm_line: schema: public - table: planet_osm_line - srid: 3857 - geometry_column: way - geometry_type: GEOMETRY - minzoom: 8 + function: tile_line + minzoom: 10 maxzoom: 14 bounds: [-180.0, -85.0511, 180.0, 85.0511] planet_osm_point: schema: public - table: planet_osm_point - srid: 3857 - geometry_column: way - geometry_type: GEOMETRY - minzoom: 10 + function: tile_point + minzoom: 12 maxzoom: 14 bounds: [-180.0, -85.0511, 180.0, 85.0511] planet_osm_roads: schema: public - table: planet_osm_roads - srid: 3857 - geometry_column: way - geometry_type: GEOMETRY - minzoom: 4 + function: tile_roads + minzoom: 6 maxzoom: 14 bounds: [-180.0, -85.0511, 180.0, 85.0511] diff --git a/mobile/lib/core/constants.dart b/mobile/lib/core/constants.dart index cd90ed6..f675ab9 100644 --- a/mobile/lib/core/constants.dart +++ b/mobile/lib/core/constants.dart @@ -6,13 +6,16 @@ class AppConstants { /// Default backend URL when none is configured. static const String defaultBackendUrl = 'http://192.168.2.59:8080'; + /// Default Martin tile server URL when none is configured. + static const String defaultMartinUrl = 'http://192.168.2.59:3001'; + /// Default map center: Amsterdam. static const double defaultLat = 52.3676; static const double defaultLon = 4.9041; static final LatLng defaultCenter = LatLng(defaultLat, defaultLon); /// Zoom levels. - static const double minZoom = 0; + static const double minZoom = 6; static const double maxZoom = 18; static const double defaultZoom = 13; static const double poiZoom = 16; @@ -26,5 +29,6 @@ class AppConstants { /// Settings keys. static const String settingBackendUrl = 'backend_url'; + static const String settingMartinUrl = 'martin_url'; static const String settingThemeMode = 'theme_mode'; } diff --git a/mobile/lib/features/map/presentation/screens/map_screen.dart b/mobile/lib/features/map/presentation/screens/map_screen.dart index ec3d261..14bc68d 100644 --- a/mobile/lib/features/map/presentation/screens/map_screen.dart +++ b/mobile/lib/features/map/presentation/screens/map_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:vector_map_tiles/vector_map_tiles.dart'; -import '../../../../core/api/api_client.dart'; import '../../providers/map_provider.dart'; import '../../providers/map_style_provider.dart'; import '../widgets/map_controls.dart'; @@ -38,8 +37,8 @@ class _MapScreenState extends ConsumerState { @override Widget build(BuildContext context) { final mapState = ref.watch(mapProvider); - final apiClient = ref.watch(apiClientProvider); - final styleAsync = ref.watch(mapStyleProvider(apiClient.baseUrl)); + final martinUrl = ref.watch(martinUrlProvider); + final styleAsync = ref.watch(mapStyleProvider(martinUrl)); // Move the map only for programmatic state changes (locateUser, // zoomIn/zoomOut). Gesture-driven pans no longer call updateCamera(), @@ -60,7 +59,7 @@ class _MapScreenState extends ConsumerState { backgroundColor: Colors.transparent, initialCenter: mapState.center, initialZoom: mapState.zoom, - minZoom: 0, + minZoom: 6, maxZoom: 18, onPositionChanged: (position, hasGesture) { // Intentionally not syncing gesture-driven position changes @@ -75,19 +74,26 @@ class _MapScreenState extends ConsumerState { }, ), children: [ - styleAsync.when( - data: (style) => VectorTileLayer( - tileProviders: style.providers, - theme: style.theme, - concurrency: 4, - memoryTileCacheMaxSize: 20 * 1024 * 1024, // 20 MB raw tiles - memoryTileDataCacheMaxSize: 50, // 50 parsed tiles - fileCacheMaximumSizeInBytes: 50 * 1024 * 1024, // 50 MB on disk - cacheFolder: () => getApplicationCacheDirectory(), + // Defer tile loading until the initial location is known so + // tiles are never fetched for the default center and then + // mass-cancelled (CancellationException crash) when the map + // jumps to the GPS position. + if (!mapState.isLocating) + styleAsync.when( + data: (style) => VectorTileLayer( + tileProviders: style.providers, + theme: style.theme, + layerMode: VectorTileLayerMode.vector, + concurrency: 4, + maximumTileSubstitutionDifference: 4, + memoryTileCacheMaxSize: 40 * 1024 * 1024, + memoryTileDataCacheMaxSize: 100, + fileCacheMaximumSizeInBytes: 150 * 1024 * 1024, + cacheFolder: () => getApplicationCacheDirectory(), + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => const SizedBox.shrink(), ), - loading: () => const SizedBox.shrink(), - error: (e, _) => const SizedBox.shrink(), - ), if (mapState.currentLocation != null) MarkerLayer( markers: [ diff --git a/mobile/lib/features/map/providers/map_provider.dart b/mobile/lib/features/map/providers/map_provider.dart index 4076cad..67c9701 100644 --- a/mobile/lib/features/map/providers/map_provider.dart +++ b/mobile/lib/features/map/providers/map_provider.dart @@ -70,6 +70,7 @@ class MapNotifier extends Notifier { MapState build() => MapState( center: AppConstants.defaultCenter, zoom: AppConstants.defaultZoom, + isLocating: true, ); void updateCamera(LatLng center, double zoom) { diff --git a/mobile/lib/features/map/providers/map_style_provider.dart b/mobile/lib/features/map/providers/map_style_provider.dart index 3ffe458..1613e30 100644 --- a/mobile/lib/features/map/providers/map_style_provider.dart +++ b/mobile/lib/features/map/providers/map_style_provider.dart @@ -1,10 +1,141 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:vector_map_tiles/vector_map_tiles.dart'; +import 'package:vector_tile_renderer/vector_tile_renderer.dart'; -/// Loads and caches the MapLibre style from the backend. -/// Keyed by base URL so a URL change triggers a reload. +/// Martin URL provider — overridden in main() with the saved value. +final martinUrlProvider = Provider((ref) => 'http://localhost:3001'); + +/// Builds a Style that fetches tiles directly from Martin. +/// Keyed by Martin URL so a URL change triggers a reload. final mapStyleProvider = - FutureProvider.family((ref, baseUrl) async { - final styleUrl = '$baseUrl/tiles/style.json'; - return StyleReader(uri: styleUrl).read(); -}); + FutureProvider.family((ref, martinUrl) async { + final urlTemplate = + '$martinUrl/planet_osm_polygon,planet_osm_line,planet_osm_point,planet_osm_roads/{z}/{x}/{y}'; + + final tileProvider = NetworkVectorTileProvider( + urlTemplate: urlTemplate, + maximumZoom: 14, + minimumZoom: 0, + ); + + final providers = TileProviders({ + 'osm_all': tileProvider, + }); + + final styleJson = { + "version": 8, + "name": "Privacy Maps", + "sources": { + "osm_all": { + "type": "vector", + "tiles": [urlTemplate], + "minzoom": 0, + "maxzoom": 14, + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": {"background-color": "#f0ebe3"} + }, + { + "id": "landuse", + "type": "fill", + "source": "osm_all", + "source-layer": "planet_osm_polygon", + "paint": {"fill-color": "#d4e5c9", "fill-opacity": 0.6} + }, + { + "id": "water", + "type": "fill", + "source": "osm_all", + "source-layer": "planet_osm_polygon", + "filter": ["==", "natural", "water"], + "paint": {"fill-color": "#a0c8f0"} + }, + { + "id": "roads-minor", + "type": "line", + "source": "osm_all", + "source-layer": "planet_osm_line", + "paint": {"line-color": "#ccc", "line-width": 1} + }, + { + "id": "roads-main", + "type": "line", + "source": "osm_all", + "source-layer": "planet_osm_roads", + "paint": {"line-color": "#f5a623", "line-width": 2} + }, + { + "id": "buildings", + "type": "fill", + "source": "osm_all", + "source-layer": "planet_osm_polygon", + "filter": ["has", "building"], + "paint": {"fill-color": "#d9d0c7", "fill-outline-color": "#bbb"} + }, + { + "id": "road-names", + "type": "symbol", + "source": "osm_all", + "source-layer": "planet_osm_line", + "minzoom": 13, + "filter": ["has", "name"], + "layout": { + "text-field": ["get", "name"], + "text-font": ["Noto Sans Regular", "Open Sans Regular"], + "text-size": 11, + "symbol-placement": "line", + "text-max-angle": 30, + "text-padding": 10 + }, + "paint": { + "text-color": "#444", + "text-halo-color": "#fff", + "text-halo-width": 1.5 + } + }, + { + "id": "place-names", + "type": "symbol", + "source": "osm_all", + "source-layer": "planet_osm_point", + "minzoom": 10, + "filter": [ + "all", + ["has", "name"], + ["has", "place"] + ], + "layout": { + "text-field": ["get", "name"], + "text-font": ["Noto Sans Regular", "Open Sans Regular"], + "text-size": [ + "interpolate", + ["linear"], + ["zoom"], + 10, + 11, + 14, + 14 + ], + "text-anchor": "center", + "text-padding": 5 + }, + "paint": { + "text-color": "#333", + "text-halo-color": "#fff", + "text-halo-width": 1.5 + } + } + ] + }; + + final theme = ThemeReader().read(styleJson); + + return Style( + theme: theme, + providers: providers, + ); +}); \ No newline at end of file diff --git a/mobile/lib/features/settings/presentation/screens/settings_screen.dart b/mobile/lib/features/settings/presentation/screens/settings_screen.dart index 0f462df..9ca13bf 100644 --- a/mobile/lib/features/settings/presentation/screens/settings_screen.dart +++ b/mobile/lib/features/settings/presentation/screens/settings_screen.dart @@ -51,24 +51,30 @@ class SettingsScreen extends ConsumerStatefulWidget { class _SettingsScreenState extends ConsumerState { late final TextEditingController _backendUrlController; + late final TextEditingController _martinUrlController; bool _urlSaved = false; + bool _martinUrlSaved = false; @override void initState() { super.initState(); _backendUrlController = TextEditingController(); - _loadBackendUrl(); + _martinUrlController = TextEditingController(); + _loadUrls(); } - Future _loadBackendUrl() async { + Future _loadUrls() async { final db = ref.read(appDatabaseProvider); final url = await db.getSetting(AppConstants.settingBackendUrl); _backendUrlController.text = url ?? AppConstants.defaultBackendUrl; + final martinUrl = await db.getSetting(AppConstants.settingMartinUrl); + _martinUrlController.text = martinUrl ?? AppConstants.defaultMartinUrl; } @override void dispose() { _backendUrlController.dispose(); + _martinUrlController.dispose(); super.dispose(); } @@ -117,6 +123,41 @@ class _SettingsScreenState extends ConsumerState { ), const Divider(), + // Martin tile server URL + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tile Server (Martin)', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + TextField( + controller: _martinUrlController, + decoration: InputDecoration( + labelText: 'Martin URL', + hintText: AppConstants.defaultMartinUrl, + suffixIcon: _martinUrlSaved + ? const Icon(Icons.check, color: Colors.green) + : null, + ), + keyboardType: TextInputType.url, + onChanged: (_) { + if (_martinUrlSaved) setState(() => _martinUrlSaved = false); + }, + ), + const SizedBox(height: 8), + FilledButton( + onPressed: _saveMartinUrl, + child: const Text('Save'), + ), + ], + ), + ), + const Divider(), + // Theme Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -222,6 +263,24 @@ class _SettingsScreenState extends ConsumerState { ); } } + + Future _saveMartinUrl() async { + final url = _martinUrlController.text.trim(); + if (url.isEmpty) return; + + final db = ref.read(appDatabaseProvider); + await db.setSetting(AppConstants.settingMartinUrl, url); + + setState(() => _martinUrlSaved = true); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Martin URL saved. Restart the app to apply.'), + ), + ); + } + } } class _AttributionTile extends StatelessWidget { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index d2f2e39..7dc75c1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,6 +4,7 @@ import 'app/app.dart'; import 'core/api/api_client.dart'; import 'core/constants.dart'; import 'core/database/app_database.dart'; +import 'features/map/providers/map_style_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -12,12 +13,15 @@ void main() async { final db = AppDatabase(); final savedUrl = await db.getSetting(AppConstants.settingBackendUrl); final backendUrl = savedUrl ?? AppConstants.defaultBackendUrl; + final savedMartinUrl = await db.getSetting(AppConstants.settingMartinUrl); + final martinUrl = savedMartinUrl ?? AppConstants.defaultMartinUrl; runApp( ProviderScope( overrides: [ appDatabaseProvider.overrideWithValue(db), apiClientProvider.overrideWithValue(ApiClient(baseUrl: backendUrl)), + martinUrlProvider.overrideWithValue(martinUrl), ], child: const PrivacyMapsApp(), ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9b67945..4ecf553 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1062,7 +1062,7 @@ packages: source: hosted version: "2.0.1" vector_tile_renderer: - dependency: transitive + dependency: "direct main" description: name: vector_tile_renderer sha256: "89746f1108eccbc0b6f33fbbef3fcf394cda3733fc0d5064ea03d53a459b56d3" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6538a68..70f0b91 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: sdk: flutter flutter_map: ^7.0.0 vector_map_tiles: ^8.0.0 + vector_tile_renderer: ^5.2.0 flutter_riverpod: ^3.0.0 riverpod_annotation: ^4.0.0 dio: ^5.4.0