tune the app

This commit is contained in:
Shautvast 2026-04-05 18:05:06 +02:00
parent 8b53075a92
commit 58f1f3bbbc
11 changed files with 458 additions and 47 deletions

@ -0,0 +1 @@
Subproject commit a3aaf1b6de30f5134d8180dbeee145d598e6e9a8

View file

@ -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;

View file

@ -3,43 +3,31 @@ listen_addresses: '0.0.0.0:3001'
postgres: postgres:
connection_string: ${DATABASE_URL} connection_string: ${DATABASE_URL}
tables: functions:
planet_osm_polygon: planet_osm_polygon:
schema: public schema: public
table: planet_osm_polygon function: tile_polygon
srid: 3857 minzoom: 8
geometry_column: way
geometry_type: GEOMETRY
minzoom: 4
maxzoom: 14 maxzoom: 14
bounds: [-180.0, -85.0511, 180.0, 85.0511] bounds: [-180.0, -85.0511, 180.0, 85.0511]
planet_osm_line: planet_osm_line:
schema: public schema: public
table: planet_osm_line function: tile_line
srid: 3857 minzoom: 10
geometry_column: way
geometry_type: GEOMETRY
minzoom: 8
maxzoom: 14 maxzoom: 14
bounds: [-180.0, -85.0511, 180.0, 85.0511] bounds: [-180.0, -85.0511, 180.0, 85.0511]
planet_osm_point: planet_osm_point:
schema: public schema: public
table: planet_osm_point function: tile_point
srid: 3857 minzoom: 12
geometry_column: way
geometry_type: GEOMETRY
minzoom: 10
maxzoom: 14 maxzoom: 14
bounds: [-180.0, -85.0511, 180.0, 85.0511] bounds: [-180.0, -85.0511, 180.0, 85.0511]
planet_osm_roads: planet_osm_roads:
schema: public schema: public
table: planet_osm_roads function: tile_roads
srid: 3857 minzoom: 6
geometry_column: way
geometry_type: GEOMETRY
minzoom: 4
maxzoom: 14 maxzoom: 14
bounds: [-180.0, -85.0511, 180.0, 85.0511] bounds: [-180.0, -85.0511, 180.0, 85.0511]

View file

@ -6,13 +6,16 @@ class AppConstants {
/// Default backend URL when none is configured. /// Default backend URL when none is configured.
static const String defaultBackendUrl = 'http://192.168.2.59:8080'; 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. /// Default map center: Amsterdam.
static const double defaultLat = 52.3676; static const double defaultLat = 52.3676;
static const double defaultLon = 4.9041; static const double defaultLon = 4.9041;
static final LatLng defaultCenter = LatLng(defaultLat, defaultLon); static final LatLng defaultCenter = LatLng(defaultLat, defaultLon);
/// Zoom levels. /// Zoom levels.
static const double minZoom = 0; static const double minZoom = 6;
static const double maxZoom = 18; static const double maxZoom = 18;
static const double defaultZoom = 13; static const double defaultZoom = 13;
static const double poiZoom = 16; static const double poiZoom = 16;
@ -26,5 +29,6 @@ class AppConstants {
/// Settings keys. /// Settings keys.
static const String settingBackendUrl = 'backend_url'; static const String settingBackendUrl = 'backend_url';
static const String settingMartinUrl = 'martin_url';
static const String settingThemeMode = 'theme_mode'; static const String settingThemeMode = 'theme_mode';
} }

View file

@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:vector_map_tiles/vector_map_tiles.dart'; import 'package:vector_map_tiles/vector_map_tiles.dart';
import '../../../../core/api/api_client.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/map_style_provider.dart'; import '../../providers/map_style_provider.dart';
import '../widgets/map_controls.dart'; import '../widgets/map_controls.dart';
@ -38,8 +37,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mapState = ref.watch(mapProvider); final mapState = ref.watch(mapProvider);
final apiClient = ref.watch(apiClientProvider); final martinUrl = ref.watch(martinUrlProvider);
final styleAsync = ref.watch(mapStyleProvider(apiClient.baseUrl)); final styleAsync = ref.watch(mapStyleProvider(martinUrl));
// Move the map only for programmatic state changes (locateUser, // Move the map only for programmatic state changes (locateUser,
// zoomIn/zoomOut). Gesture-driven pans no longer call updateCamera(), // zoomIn/zoomOut). Gesture-driven pans no longer call updateCamera(),
@ -60,7 +59,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
initialCenter: mapState.center, initialCenter: mapState.center,
initialZoom: mapState.zoom, initialZoom: mapState.zoom,
minZoom: 0, minZoom: 6,
maxZoom: 18, maxZoom: 18,
onPositionChanged: (position, hasGesture) { onPositionChanged: (position, hasGesture) {
// Intentionally not syncing gesture-driven position changes // Intentionally not syncing gesture-driven position changes
@ -75,19 +74,26 @@ class _MapScreenState extends ConsumerState<MapScreen> {
}, },
), ),
children: [ children: [
styleAsync.when( // Defer tile loading until the initial location is known so
data: (style) => VectorTileLayer( // tiles are never fetched for the default center and then
tileProviders: style.providers, // mass-cancelled (CancellationException crash) when the map
theme: style.theme, // jumps to the GPS position.
concurrency: 4, if (!mapState.isLocating)
memoryTileCacheMaxSize: 20 * 1024 * 1024, // 20 MB raw tiles styleAsync.when(
memoryTileDataCacheMaxSize: 50, // 50 parsed tiles data: (style) => VectorTileLayer(
fileCacheMaximumSizeInBytes: 50 * 1024 * 1024, // 50 MB on disk tileProviders: style.providers,
cacheFolder: () => getApplicationCacheDirectory(), 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) if (mapState.currentLocation != null)
MarkerLayer( MarkerLayer(
markers: [ markers: [

View file

@ -70,6 +70,7 @@ class MapNotifier extends Notifier<MapState> {
MapState build() => MapState( MapState build() => MapState(
center: AppConstants.defaultCenter, center: AppConstants.defaultCenter,
zoom: AppConstants.defaultZoom, zoom: AppConstants.defaultZoom,
isLocating: true,
); );
void updateCamera(LatLng center, double zoom) { void updateCamera(LatLng center, double zoom) {

View file

@ -1,10 +1,141 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:vector_map_tiles/vector_map_tiles.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. /// Martin URL provider overridden in main() with the saved value.
/// Keyed by base URL so a URL change triggers a reload. final martinUrlProvider = Provider<String>((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 = final mapStyleProvider =
FutureProvider.family<Style, String>((ref, baseUrl) async { FutureProvider.family<Style, String>((ref, martinUrl) async {
final styleUrl = '$baseUrl/tiles/style.json'; final urlTemplate =
return StyleReader(uri: styleUrl).read(); '$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 = <String, dynamic>{
"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,
);
}); });

View file

@ -51,24 +51,30 @@ class SettingsScreen extends ConsumerStatefulWidget {
class _SettingsScreenState extends ConsumerState<SettingsScreen> { class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late final TextEditingController _backendUrlController; late final TextEditingController _backendUrlController;
late final TextEditingController _martinUrlController;
bool _urlSaved = false; bool _urlSaved = false;
bool _martinUrlSaved = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_backendUrlController = TextEditingController(); _backendUrlController = TextEditingController();
_loadBackendUrl(); _martinUrlController = TextEditingController();
_loadUrls();
} }
Future<void> _loadBackendUrl() async { Future<void> _loadUrls() async {
final db = ref.read(appDatabaseProvider); final db = ref.read(appDatabaseProvider);
final url = await db.getSetting(AppConstants.settingBackendUrl); final url = await db.getSetting(AppConstants.settingBackendUrl);
_backendUrlController.text = url ?? AppConstants.defaultBackendUrl; _backendUrlController.text = url ?? AppConstants.defaultBackendUrl;
final martinUrl = await db.getSetting(AppConstants.settingMartinUrl);
_martinUrlController.text = martinUrl ?? AppConstants.defaultMartinUrl;
} }
@override @override
void dispose() { void dispose() {
_backendUrlController.dispose(); _backendUrlController.dispose();
_martinUrlController.dispose();
super.dispose(); super.dispose();
} }
@ -117,6 +123,41 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
const Divider(), 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 // Theme
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@ -222,6 +263,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
); );
} }
} }
Future<void> _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 { class _AttributionTile extends StatelessWidget {

View file

@ -4,6 +4,7 @@ import 'app/app.dart';
import 'core/api/api_client.dart'; import 'core/api/api_client.dart';
import 'core/constants.dart'; import 'core/constants.dart';
import 'core/database/app_database.dart'; import 'core/database/app_database.dart';
import 'features/map/providers/map_style_provider.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -12,12 +13,15 @@ void main() async {
final db = AppDatabase(); final db = AppDatabase();
final savedUrl = await db.getSetting(AppConstants.settingBackendUrl); final savedUrl = await db.getSetting(AppConstants.settingBackendUrl);
final backendUrl = savedUrl ?? AppConstants.defaultBackendUrl; final backendUrl = savedUrl ?? AppConstants.defaultBackendUrl;
final savedMartinUrl = await db.getSetting(AppConstants.settingMartinUrl);
final martinUrl = savedMartinUrl ?? AppConstants.defaultMartinUrl;
runApp( runApp(
ProviderScope( ProviderScope(
overrides: [ overrides: [
appDatabaseProvider.overrideWithValue(db), appDatabaseProvider.overrideWithValue(db),
apiClientProvider.overrideWithValue(ApiClient(baseUrl: backendUrl)), apiClientProvider.overrideWithValue(ApiClient(baseUrl: backendUrl)),
martinUrlProvider.overrideWithValue(martinUrl),
], ],
child: const PrivacyMapsApp(), child: const PrivacyMapsApp(),
), ),

View file

@ -1062,7 +1062,7 @@ packages:
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
vector_tile_renderer: vector_tile_renderer:
dependency: transitive dependency: "direct main"
description: description:
name: vector_tile_renderer name: vector_tile_renderer
sha256: "89746f1108eccbc0b6f33fbbef3fcf394cda3733fc0d5064ea03d53a459b56d3" sha256: "89746f1108eccbc0b6f33fbbef3fcf394cda3733fc0d5064ea03d53a459b56d3"

View file

@ -32,6 +32,7 @@ dependencies:
sdk: flutter sdk: flutter
flutter_map: ^7.0.0 flutter_map: ^7.0.0
vector_map_tiles: ^8.0.0 vector_map_tiles: ^8.0.0
vector_tile_renderer: ^5.2.0
flutter_riverpod: ^3.0.0 flutter_riverpod: ^3.0.0
riverpod_annotation: ^4.0.0 riverpod_annotation: ^4.0.0
dio: ^5.4.0 dio: ^5.4.0