From be2fe84db57bf875da5f24a97654e7a8be2de408 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 15 Jul 2026 18:01:29 -0500 Subject: [PATCH 1/4] first pass --- DEVELOPER.md | 9 +++-- lib/dev_config.dart | 5 +-- lib/services/map_data_provider.dart | 2 +- lib/services/node_data_manager.dart | 26 +++++++++++-- lib/services/overpass_service.dart | 48 +++++++++++++++++------- test/services/overpass_service_test.dart | 9 +++-- 6 files changed, 68 insertions(+), 31 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 00bb23d..17c480b 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -297,10 +297,11 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl - **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits) **Smart error handling & splitting:** -- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep -- **Timeout errors**: Also triggers splitting (dense areas with many profiles) -- **Rate limiting**: Extended backoff (30s), no splitting (would make it worse) -- **Surgical detection**: Only splits on actual limit errors, not network issues +- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep. This is a deterministic result-set size limit, so shrinking the queried area is the correct fix. +- **Timeout errors**: Treated as a plain retryable network error — no splitting. Timeouts usually indicate server load or an expensive query, not "too much area", so firing off more sub-requests would only add load to an already-struggling server. +- **Rate limiting (HTTP 429)**: Extended backoff (30s), no splitting (would make it worse). Still surfaces the same "slow" status indicator as the node-limit-splitting case, so the user sees consistent feedback either way. +- **Surgical detection**: Only splits on the actual 50k node limit, not timeouts or other network issues. + **Query optimization & deduplication:** - **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 0d16a25..9fe96d6 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -99,13 +99,10 @@ const Duration kMarkerTapTimeout = Duration(milliseconds: 250); const Duration kMapLongPressTimeout = Duration(milliseconds: 600); // Duration to trigger "add node here" on empty map area const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); -// Pre-fetch area configuration -const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation -const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes -const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit + // Data refresh configuration const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 6f5e99b..89ca2bf 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -54,8 +54,8 @@ class MapDataProvider { required List profiles, UploadMode uploadMode = UploadMode.production, int maxResults = 0, // 0 = no limit for offline downloads - int maxTries = 3, }) async { + if (AppState.instance.offlineMode) { throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode."); } diff --git a/lib/services/node_data_manager.dart b/lib/services/node_data_manager.dart index 2dbaeeb..c4e1603 100644 --- a/lib/services/node_data_manager.dart +++ b/lib/services/node_data_manager.dart @@ -215,7 +215,18 @@ class NodeDataManager extends ChangeNotifier { } } - /// Fetch nodes with automatic area splitting if needed + /// Fetch nodes, splitting the query area into quadrants only when Overpass + /// reports the query would exceed its hard 50k node limit ([NodeLimitError]). + /// + /// Splitting is deliberately NOT used for other failure modes: + /// - [RateLimitError] (HTTP 429): the server explicitly asked us to slow + /// down, so firing off up to 4x more requests would be counterproductive. + /// We back off instead, while still surfacing the "slow" status + /// indicator to the user (same UI feedback as the splitting case). + /// - Timeouts / other transient failures now surface as [NetworkError] + /// (see [OverpassService]) and are not caught here at all — they're + /// handled by the normal retry/backoff/fallback logic and reported as a + /// plain timeout/error status by the caller. Future> fetchWithSplitting( LatLngBounds bounds, List profiles, { @@ -238,7 +249,7 @@ class NodeDataManager extends ChangeNotifier { return nodes; } on NodeLimitError { - // Hit node limit or timeout - split area if not too deep + // Hit the deterministic 50k node limit - split area if not too deep if (splitDepth >= maxSplitDepth) { debugPrint('[NodeDataManager] Max split depth reached, giving up'); return []; @@ -254,13 +265,20 @@ class NodeDataManager extends ChangeNotifier { return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated); } on RateLimitError { - // Rate limited - wait and return empty - debugPrint('[NodeDataManager] Rate limited, backing off'); + // Rate limited - back off without splitting the area. Splitting here + // would multiply request volume against a server that just told us to + // slow down. We still show the same "slow" status indicator so the + // user gets feedback, but there is no recursive quadrant fetching. + debugPrint('[NodeDataManager] Rate limited, backing off (no area splitting)'); + if (isUserInitiated) { + NetworkStatus.instance.setSplitting(); + } await Future.delayed(const Duration(seconds: 30)); return []; } } + /// Fetch data by splitting area into quadrants Future> _fetchSplitAreas( LatLngBounds bounds, diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 760a303..bb47a86 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -32,7 +32,20 @@ class OverpassService { String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint; /// Fetch surveillance nodes from Overpass API with retry and fallback. - /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. + /// + /// Throws: + /// - [NodeLimitError] when the query would exceed Overpass's hard 50k node + /// limit. This is the *only* case where the caller should split the + /// query area into smaller regions, since it's a deterministic function + /// of how much data lives in the requested bounds. + /// - [RateLimitError] when rate limited (HTTP 429 or equivalent). Callers + /// should back off, not split/retry — splitting would only amplify load + /// on a server that just told us to slow down. + /// - [NetworkError] for timeouts and other retryable HTTP/network failures. + /// Timeouts are usually a sign of server load or an expensive query, not + /// "too much area" — retrying with a hail of smaller sub-requests makes + /// the problem worse, so timeouts are treated like any other transient + /// network failure (handled by the existing retry/backoff/fallback logic). Future> fetchNodes({ required LatLngBounds bounds, required List profiles, @@ -70,22 +83,16 @@ class OverpassService { final errorBody = response.body; - // Node limit error - caller should split area + // Node limit error - a deterministic result-set size limit. This is + // the one case where splitting the query area into smaller regions is + // the correct fix, since it directly reduces nodes-per-request. if (response.statusCode == 400 && (errorBody.contains('too many nodes') && errorBody.contains('50000'))) { - debugPrint('[OverpassService] Node limit exceeded, area should be split'); + debugPrint('[OverpassService] Node limit exceeded (50k), area should be split'); throw NodeLimitError('Query exceeded 50k node limit'); } - // Timeout error - also try splitting (complex query) - if (errorBody.contains('timeout') || - errorBody.contains('runtime limit exceeded') || - errorBody.contains('Query timed out')) { - debugPrint('[OverpassService] Query timeout, area should be split'); - throw NodeLimitError('Query timed out - area too complex'); - } - - // Rate limit + // Rate limit - back off, don't split or hammer the server with more requests. if (response.statusCode == 429 || errorBody.contains('rate limited') || errorBody.contains('too many requests')) { @@ -93,6 +100,17 @@ class OverpassService { throw RateLimitError('Rate limited by Overpass API'); } + // Timeout / runtime limit exceeded - treat as a plain retryable network + // error. This is usually server load or query cost, not "area too + // big" — splitting into up to 64 sub-requests would only make load + // on the server worse. + if (errorBody.contains('timeout') || + errorBody.contains('runtime limit exceeded') || + errorBody.contains('Query timed out')) { + debugPrint('[OverpassService] Query timed out'); + throw NetworkError('Query timed out'); + } + throw NetworkError('HTTP ${response.statusCode}: $errorBody'); } catch (e) { if (e is NodeLimitError || e is RateLimitError || e is NetworkError) { @@ -173,7 +191,9 @@ out ids; } } -/// Error thrown when query exceeds node limits or is too complex - area should be split +/// Error thrown when a query would exceed Overpass's 50k node limit. +/// The caller should split the query area into smaller regions to resolve this +/// — it's a deterministic function of how much data lives within the bounds. class NodeLimitError extends Error { final String message; NodeLimitError(this.message); @@ -189,7 +209,7 @@ class RateLimitError extends Error { String toString() => 'RateLimitError: $message'; } -/// Error thrown for network/HTTP issues - retryable +/// Error thrown for network/HTTP issues (including timeouts) - retryable class NetworkError extends Error { final String message; NetworkError(this.message); diff --git a/test/services/overpass_service_test.dart b/test/services/overpass_service_test.dart index 0c067d7..91a19da 100644 --- a/test/services/overpass_service_test.dart +++ b/test/services/overpass_service_test.dart @@ -255,27 +255,28 @@ void main() { ); }); - test('response with "timeout" throws NodeLimitError', () async { + test('response with "timeout" throws NetworkError (not split)', () async { stubErrorResponse(400, 'runtime error: timeout in query execution'); await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), - throwsA(isA()), + throwsA(isA()), ); }); - test('response with "runtime limit exceeded" throws NodeLimitError', + test('response with "runtime limit exceeded" throws NetworkError (not split)', () async { stubErrorResponse(400, 'runtime limit exceeded'); await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), - throwsA(isA()), + throwsA(isA()), ); }); + test('HTTP 429 throws RateLimitError', () async { stubErrorResponse(429, 'Too Many Requests'); From f8632b6a0622b14d5fd5e3954e2fcc2d7ed245bc Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 15 Jul 2026 19:39:06 -0500 Subject: [PATCH 2/4] remove extraneous unreferenced file --- .../overpass_node_limit_exception.dart | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 lib/services/overpass_node_limit_exception.dart diff --git a/lib/services/overpass_node_limit_exception.dart b/lib/services/overpass_node_limit_exception.dart deleted file mode 100644 index 9df915b..0000000 --- a/lib/services/overpass_node_limit_exception.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// Exception thrown when Overpass API returns an error indicating too many nodes were requested. -/// This typically happens when querying large areas that would return more than 50k nodes. -class OverpassNodeLimitException implements Exception { - final String message; - final String? serverResponse; - - OverpassNodeLimitException(this.message, {this.serverResponse}); - - @override - String toString() => 'OverpassNodeLimitException: $message'; -} - -/// Exception thrown when Overpass API rate limits the request. -/// Should trigger longer backoff delays, not area splitting. -class OverpassRateLimitException implements Exception { - final String message; - final String? serverResponse; - - OverpassRateLimitException(this.message, {this.serverResponse}); - - @override - String toString() => 'OverpassRateLimitException: $message'; -} \ No newline at end of file From 38eee8d12536fde44b88b12f7c35b5cafbf065cb Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 15 Jul 2026 21:20:44 -0500 Subject: [PATCH 3/4] Bring back profile subsumption elimination for overpass queries --- DEVELOPER.md | 16 ++- lib/dev_config.dart | 8 +- lib/services/overpass_service.dart | 87 +++++++++++++++- test/services/overpass_service_test.dart | 123 +++++++++++++++++++++++ 4 files changed, 218 insertions(+), 16 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 17c480b..be814ba 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -303,21 +303,17 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl - **Surgical detection**: Only splits on the actual 50k node limit, not timeouts or other network issues. -**Query optimization & deduplication:** -- **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit) -- **Profile deduplication**: Automatically removes redundant profiles from queries using subsumption analysis -- **User-initiated detection**: Only reports loading status for user-facing operations -- **Background operations**: Pre-fetch runs silently, doesn't trigger loading states - -**Profile subsumption optimization (v2.1.1+):** -To reduce Overpass query complexity, profiles are deduplicated before query generation: +**Query optimization & deduplication (in `OverpassService._buildQuery`):** +Profiles are deduplicated before query generation via subsumption analysis: - **Subsumption rule**: Profile A subsumes profile B if all of A's non-empty tags exist in B with identical values - **Example**: `Generic ALPR` (tags: `man_made=surveillance, surveillance:type=ALPR`) subsumes `Flock` (same tags + `manufacturer=Flock Safety`) - **Result**: Default profile set reduces from ~11 to ~2 query clauses (Generic ALPR + Generic Gunshot) -- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching +- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching (see `NodeProviderWithCache._matchesAnyProfile`), independent of which clauses were sent to Overpass +- **Safety fallback**: if dedup would remove every profile (e.g. all profiles have only empty tags), the original full list is used instead of sending an empty query **Why this approach:** -Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Profile deduplication reduces query complexity by ~80% for default setups, while automatic splitting handles remaining edge cases. Surgical error detection avoids unnecessary API load from network issues. +Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 45s timeouts. Profile deduplication reduces query complexity by ~80% for default setups. Surgical error detection avoids unnecessary API load from network issues. + ### 6. Uploader Service Architecture (Refactored v1.5.3) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 9fe96d6..d5127b0 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -162,11 +162,11 @@ const int kAbsoluteMaxTileCount = 50000; const int kAbsoluteMaxZoom = 23; // Node icon configuration -const double kNodeIconDiameter = 18.0; -const double _kNodeRingThicknessBase = 2.5; -const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior +const double kNodeIconDiameter = 17.0; +const double _kNodeRingThicknessBase = 2.6; +const double kNodeDotOpacity = 0.28; // Opacity for the grey dot interior const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue -const Color kNodeRingColorMock = Color(0xD0FFFFFF); // Add node mock point - white +const Color kNodeRingColorMock = Color(0xE0AAAAAA); // Add node mock point - white const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index bb47a86..b354fd1 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -126,9 +126,24 @@ class OverpassService { return ErrorDisposition.retry; } - /// Build Overpass QL query for given bounds and profiles + /// Build Overpass QL query for given bounds and profiles. + /// + /// Profiles are deduplicated first: if one profile's non-empty tags are a + /// subset of another's (with identical values), the broader profile's query + /// clause already returns every node the narrower one would, so the + /// narrower clause is redundant and can be dropped. This directly reduces + /// query size/complexity without changing the result set — client-side + /// filtering (matching returned nodes against the full profile list) still + /// happens independently, so nothing is lost. String _buildQuery(LatLngBounds bounds, List profiles) { - final nodeClauses = profiles.map((profile) { + final profilesToQuery = _deduplicateProfilesForQuery(profiles); + + if (profilesToQuery.length < profiles.length) { + debugPrint( + '[OverpassService] Deduplicated ${profiles.length} profiles to ${profilesToQuery.length} for query efficiency'); + } + + final nodeClauses = profilesToQuery.map((profile) { // Convert profile tags to Overpass filter format, excluding empty values final tagFilters = profile.tags.entries .where((entry) => entry.value.trim().isNotEmpty) @@ -149,6 +164,74 @@ out ids; '''; } + /// Deduplicate profiles for Overpass queries by removing profiles that are + /// subsumed by others. A profile A subsumes profile B if all of A's + /// non-empty tags exist in B with identical values — meaning every node + /// matching B's filter also matches A's filter, so B's clause is redundant. + List _deduplicateProfilesForQuery(List profiles) { + if (profiles.length <= 1) return profiles; + + final result = []; + + for (final candidate in profiles) { + // Skip profiles that only have empty tags - they would match + // everything and shouldn't be treated as subsuming or subsumable. + final candidateNonEmptyTags = candidate.tags.entries + .where((entry) => entry.value.trim().isNotEmpty) + .toList(); + + if (candidateNonEmptyTags.isEmpty) continue; + + // Check if any existing profile in our result subsumes this candidate + bool isSubsumed = false; + for (final existing in result) { + if (_profileSubsumes(existing, candidate)) { + isSubsumed = true; + break; + } + } + + if (!isSubsumed) { + // This candidate is not subsumed, so add it. But first, remove any + // existing profiles that this candidate subsumes (candidate is + // broader, so those clauses are now redundant). + result.removeWhere((existing) => _profileSubsumes(candidate, existing)); + result.add(candidate); + } + } + + // Safety check: if dedup removed everything (e.g. all-empty-tag + // profiles), fall back to the original list rather than sending an + // empty query. + return result.isNotEmpty ? result : profiles; + } + + /// Check if [broaderProfile] subsumes [specificProfile]: true if all + /// non-empty tags in [broaderProfile] exist in [specificProfile] with + /// identical values. + bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) { + final broaderTags = Map.fromEntries( + broaderProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty), + ); + final specificTags = Map.fromEntries( + specificProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty), + ); + + // If broader has no non-empty tags, it doesn't subsume anything (it + // would match everything, which isn't a meaningful "broader filter"). + if (broaderTags.isEmpty) return false; + + // If broader has more non-empty tags than specific, it can't subsume it. + if (broaderTags.length > specificTags.length) return false; + + for (final entry in broaderTags.entries) { + if (specificTags[entry.key] != entry.value) return false; + } + + return true; + } + + /// Parse Overpass JSON response into OsmNode objects List _parseResponse(String responseBody) { final data = jsonDecode(responseBody) as Map; diff --git a/test/services/overpass_service_test.dart b/test/services/overpass_service_test.dart index 91a19da..6c52566 100644 --- a/test/services/overpass_service_test.dart +++ b/test/services/overpass_service_test.dart @@ -99,6 +99,129 @@ void main() { }); }); + group('profile deduplication (subsumption)', () { + test('a specific profile subsumed by a generic one is dropped', () async { + final generic = NodeProfile( + id: 'generic-alpr', + name: 'Generic ALPR', + tags: const { + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + final specific = NodeProfile( + id: 'flock', + name: 'Flock', + tags: const { + 'man_made': 'surveillance', + 'surveillance': 'public', + 'surveillance:type': 'ALPR', + 'surveillance:zone': 'traffic', + 'camera:type': 'fixed', + 'manufacturer': 'Flock Safety', + 'manufacturer:wikidata': 'Q108485435', + }, + ); + + stubOverpassResponse([]); + + await service.fetchNodes(bounds: bounds, profiles: [generic, specific]); + + final captured = verify( + () => mockClient.post(any(), body: captureAny(named: 'body')), + ).captured; + final query = (captured.last as Map)['data']!; + + // Only the generic clause should remain. + expect(query, contains('["man_made"="surveillance"]["surveillance:type"="ALPR"]')); + expect(query, isNot(contains('manufacturer'))); + expect(query, isNot(contains('Flock Safety'))); + + // Only one node clause total. + final nodeClauseCount = RegExp(r'node\[').allMatches(query).length; + expect(nodeClauseCount, equals(1)); + }); + + + test('unrelated profiles are all kept', () async { + final alprGeneric = NodeProfile( + id: 'generic-alpr', + name: 'Generic ALPR', + tags: const { + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + final gunshotGeneric = NodeProfile( + id: 'generic-gunshot', + name: 'Generic Gunshot Detector', + tags: const { + 'man_made': 'surveillance', + 'surveillance:type': 'gunshot_detector', + }, + ); + + stubOverpassResponse([]); + + await service.fetchNodes(bounds: bounds, profiles: [alprGeneric, gunshotGeneric]); + + final captured = verify( + () => mockClient.post(any(), body: captureAny(named: 'body')), + ).captured; + final query = (captured.last as Map)['data']!; + + expect(query, contains('["surveillance:type"="ALPR"]')); + expect(query, contains('["surveillance:type"="gunshot_detector"]')); + }); + + test('full default profile set reduces to the two generic clauses', () async { + final profiles = NodeProfile.getDefaults(); + + stubOverpassResponse([]); + + await service.fetchNodes(bounds: bounds, profiles: profiles); + + final captured = verify( + () => mockClient.post(any(), body: captureAny(named: 'body')), + ).captured; + final query = (captured.last as Map)['data']!; + + final nodeClauseCount = RegExp(r'node\[').allMatches(query).length; + expect(nodeClauseCount, equals(2)); + + expect(query, contains('["man_made"="surveillance"]["surveillance:type"="ALPR"]')); + expect(query, contains('["man_made"="surveillance"]["surveillance:type"="gunshot_detector"]')); + // Brand-specific tags should not appear anywhere in the reduced query. + expect(query, isNot(contains('manufacturer'))); + expect(query, isNot(contains('ShotSpotter'))); + }); + + test('profiles with only empty tags do not subsume or break the query', () async { + final emptyTagsProfile = NodeProfile( + id: 'empty', + name: 'Empty', + tags: const {'camera:mount': ''}, + ); + final normalProfile = NodeProfile( + id: 'normal', + name: 'Normal', + tags: const {'man_made': 'surveillance'}, + ); + + stubOverpassResponse([]); + + await service.fetchNodes(bounds: bounds, profiles: [emptyTagsProfile, normalProfile]); + + final captured = verify( + () => mockClient.post(any(), body: captureAny(named: 'body')), + ).captured; + final query = (captured.last as Map)['data']!; + + expect(query, contains('["man_made"="surveillance"]')); + }); + }); + + group('response parsing — constraint detection', () { test('nodes referenced by a way are constrained', () async { stubOverpassResponse([ From b9d82d6de7397abd8b20623d7483549dbefeaa45 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 15 Jul 2026 21:29:54 -0500 Subject: [PATCH 4/4] change mock node color to green --- lib/dev_config.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index d5127b0..67d6963 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -166,7 +166,7 @@ const double kNodeIconDiameter = 17.0; const double _kNodeRingThicknessBase = 2.6; const double kNodeDotOpacity = 0.28; // Opacity for the grey dot interior const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue -const Color kNodeRingColorMock = Color(0xE0AAAAAA); // Add node mock point - white +const Color kNodeRingColorMock = Color(0xE044BB55); // Add node mock point - white const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey