From 06911d192e8d94b2ac04dd38455ebe014efaa2e2 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Wed, 10 Jun 2026 22:51:43 +1000 Subject: [PATCH 1/3] perf: cull polylines via cached projected bounding boxes Cache the projected-space bounding box on each projected polyline (lazily, so culled fragments never compute one). This replaces two per-frame O(points) scans in aggressive culling: - world-stretch detection now compares the bbox against the world east and west edges, instead of iterating every point of every polyline that overlaps the viewport latitudes - fully-visible polylines (bbox contained in the viewport bounds) skip the per-segment aabbContainsLine scan and its sublist allocations Benchmark (benchmark/feature_layer_benchmark_test.dart, JIT): 600 polylines x 60 pts, all visible, panning: 2631 -> 2484 us/frame. Mostly-culled scenario unchanged. --- .../layer/polyline_layer/polyline_layer.dart | 22 ++++++++++++------- .../polyline_layer/projected_polyline.dart | 7 +++++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index 6da20ffc5..a705802b6 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -208,15 +208,11 @@ class _PolylineLayerState extends State> continue; } + final projectedBounds = projectedPolyline.boundingBox; + /// Returns true if the points stretch on different versions of the world. - bool stretchesBeyondTheLimits() { - for (final point in projectedPolyline.points) { - if (point.dx > xEast || point.dx < xWest) { - return true; - } - } - return false; - } + bool stretchesBeyondTheLimits() => + projectedBounds.right > xEast || projectedBounds.left < xWest; // TODO: think about how to cull polylines that go beyond -180/180. // As the notions of projected west/east as min/max are not reliable. @@ -235,6 +231,16 @@ class _PolylineLayerState extends State> // when none of the line is visible. Here, focusing on longitudes. if (!isOverlappingLongitude()) continue; + // Fast path: when the whole polyline is visible there is nothing to + // cull, so skip the per-segment scan (and its sublist allocations). + if (projBounds.left <= projectedBounds.left && + projBounds.top <= projectedBounds.top && + projBounds.right >= projectedBounds.right && + projBounds.bottom >= projectedBounds.bottom) { + yield projectedPolyline; + continue; + } + // pointer that indicates the start of the visible polyline segment int start = -1; bool containsSegment = false; diff --git a/lib/src/layer/polyline_layer/projected_polyline.dart b/lib/src/layer/polyline_layer/projected_polyline.dart index 49707db41..bc57c2f8e 100644 --- a/lib/src/layer/polyline_layer/projected_polyline.dart +++ b/lib/src/layer/polyline_layer/projected_polyline.dart @@ -5,10 +5,15 @@ class _ProjectedPolyline with HitDetectableElement { final Polyline polyline; final List points; + /// Bounding box of [points], in projected space (cached) + /// + /// Computed lazily: culled fragments never use it. + late final Rect boundingBox = RectExtension.containing(points); + @override R? get hitValue => polyline.hitValue; - const _ProjectedPolyline._({ + _ProjectedPolyline._({ required this.polyline, required this.points, }); From aa0681da3ad188421c94156bb050ae6947ff82b9 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Wed, 10 Jun 2026 22:50:09 +1000 Subject: [PATCH 2/3] test: add feature-layer CPU benchmark harness Widget- and kernel-level CPU benchmarks for the polyline, polygon, and marker layers, plus a direct getOffsetsXY benchmark. Lives in benchmark/ (not test/) so it is opt-in and does not extend CI runtime: flutter test benchmark/feature_layer_benchmark_test.dart Numbers are JIT and only meaningful relative to each other (before vs after a change on the same machine). (cherry picked from commit ed76dc69f0d25f07a388a0e7086bb75924a24814) --- benchmark/feature_layer_benchmark_test.dart | 277 ++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 benchmark/feature_layer_benchmark_test.dart diff --git a/benchmark/feature_layer_benchmark_test.dart b/benchmark/feature_layer_benchmark_test.dart new file mode 100644 index 000000000..a7f9ab2b8 --- /dev/null +++ b/benchmark/feature_layer_benchmark_test.dart @@ -0,0 +1,277 @@ +// Layer-level CPU benchmarks, used to validate performance work. +// +// Run with: +// flutter test benchmark/feature_layer_benchmark_test.dart --plain-name=benchmark -r expanded +// +// Numbers are JIT/debug-mode and only meaningful *relative* to each other +// (before/after a change on the same machine). Each scenario warms up, then +// times repeated pumps while the camera pans, and reports the best repetition +// (min) to reduce GC/scheduling noise. +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/misc/offsets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +const _center = LatLng(-37.8136, 144.9631); + +List _randomWalk( + math.Random rng, + LatLng start, + int count, [ + double stepDeg = 0.0004, +]) { + final points = []; + var lat = start.latitude; + var lng = start.longitude; + for (var i = 0; i < count; i++) { + lat += (rng.nextDouble() - 0.5) * stepDeg; + lng += (rng.nextDouble() - 0.5) * stepDeg; + points.add(LatLng(lat, lng)); + } + return points; +} + +LatLng _randomNear(math.Random rng, LatLng base, double spreadDeg) => LatLng( + base.latitude + (rng.nextDouble() - 0.5) * spreadDeg, + base.longitude + (rng.nextDouble() - 0.5) * spreadDeg, + ); + +Widget _app(MapController controller, double zoom, List layers) => + MaterialApp( + home: FlutterMap( + mapController: controller, + options: MapOptions(initialCenter: _center, initialZoom: zoom), + children: layers, + ), + ); + +/// Pans the camera back and forth and reports the best-rep average frame +/// build+paint time, in microseconds. +Future _benchPans( + WidgetTester tester, + MapController controller, + double zoom, { + int reps = 3, + int framesPerRep = 40, +}) async { + // Warm-up (fills projection/simplification caches, JIT). + for (var i = 0; i < 10; i++) { + controller.move( + LatLng(_center.latitude, _center.longitude + 0.00001 * (i + 1)), + zoom, + ); + await tester.pump(); + } + + var best = double.infinity; + for (var rep = 0; rep < reps; rep++) { + final sw = Stopwatch()..start(); + for (var i = 0; i < framesPerRep; i++) { + // Small alternating pan, never repeating the previous camera. + controller.move( + LatLng( + _center.latitude + 0.0001 * (i % 7), + _center.longitude + 0.0001 * (i % 11) + 0.000001 * i, + ), + zoom, + ); + await tester.pump(); + } + sw.stop(); + final perFrame = sw.elapsedMicroseconds / framesPerRep; + if (perFrame < best) best = perFrame; + } + return best; +} + +void main() { + testWidgets('benchmark: polylines pan (all visible)', (tester) async { + final rng = math.Random(42); + final polylines = [ + for (var i = 0; i < 600; i++) + Polyline( + points: _randomWalk(rng, _randomNear(rng, _center, 0.02), 60), + strokeWidth: 2, + color: Colors.blue, + ), + ]; + final controller = MapController(); + // Zoom 13: ~0.05° viewport, the 0.02° spread keeps everything visible. + await tester.pumpWidget( + _app(controller, 13, [PolylineLayer(polylines: polylines)]), + ); + final us = await _benchPans(tester, controller, 13); + debugPrint('RESULT polylines_pan_all_visible: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: polylines pan (mostly culled)', (tester) async { + final rng = math.Random(42); + final polylines = [ + for (var i = 0; i < 600; i++) + Polyline( + points: _randomWalk(rng, _randomNear(rng, _center, 0.5), 60), + strokeWidth: 2, + color: Colors.blue, + ), + ]; + final controller = MapController(); + // Zoom 16: viewport much smaller than the 0.5° spread. + await tester.pumpWidget( + _app(controller, 16, [PolylineLayer(polylines: polylines)]), + ); + final us = await _benchPans(tester, controller, 16); + debugPrint('RESULT polylines_pan_mostly_culled: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: polygons with holes pan', (tester) async { + final rng = math.Random(7); + final polygons = [ + for (var i = 0; i < 200; i++) + () { + final base = _randomNear(rng, _center, 0.02); + return Polygon( + points: _randomWalk(rng, base, 40), + holePointsList: [ + for (var h = 0; h < 3; h++) + _randomWalk(rng, _randomNear(rng, base, 0.001), 20, 0.0001), + ], + color: Colors.green.withValues(alpha: 0.5), + ); + }(), + ]; + final controller = MapController(); + await tester.pumpWidget( + _app(controller, 13, [PolygonLayer(polygons: polygons)]), + ); + final us = await _benchPans(tester, controller, 13); + debugPrint('RESULT polygons_holes_pan: ${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: markers pan', (tester) async { + final rng = math.Random(3); + final markers = [ + for (var i = 0; i < 3000; i++) + Marker( + point: _randomNear(rng, _center, 0.05), + width: 20, + height: 20, + child: const SizedBox.shrink(), + ), + ]; + final controller = MapController(); + await tester.pumpWidget( + _app(controller, 14, [MarkerLayer(markers: markers)]), + ); + final us = await _benchPans(tester, controller, 14); + debugPrint('RESULT markers_pan: ${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: markers pan (mostly culled)', (tester) async { + final rng = math.Random(3); + final markers = [ + for (var i = 0; i < 10000; i++) + Marker( + point: _randomNear(rng, _center, 1), + width: 20, + height: 20, + child: const SizedBox.shrink(), + ), + ]; + final controller = MapController(); + // Zoom 16: viewport much smaller than the 1° spread, so per-frame cost is + // dominated by the per-marker projection + cull check. + await tester.pumpWidget( + _app(controller, 16, [MarkerLayer(markers: markers)]), + ); + final us = await _benchPans(tester, controller, 16); + debugPrint('RESULT markers_pan_mostly_culled: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + test('benchmark: marker projection kernel', () { + final rng = math.Random(5); + final camera = MapCamera( + crs: const Epsg3857(), + center: _center, + zoom: 14, + rotation: 0, + nonRotatedSize: const Size(800, 600), + ); + const crs = Epsg3857(); + final points = [ + for (var i = 0; i < 10000; i++) _randomNear(rng, _center, 1), + ]; + final projected = [for (final p in points) crs.projection.project(p)]; + const frames = 100; + + // Old per-frame path: full LatLng -> screen projection (trigonometry). + var sink = 0.0; + var sw = Stopwatch()..start(); + for (var f = 0; f < frames; f++) { + for (final p in points) { + sink += camera.projectAtZoom(p).dx; + } + } + sw.stop(); + final oldNs = sw.elapsedMicroseconds * 1000 / (frames * points.length); + + // New per-frame path: linear transform of the cached projection. + final zoomScale = crs.scale(camera.zoom); + sw = Stopwatch()..start(); + for (var f = 0; f < frames; f++) { + for (final p in projected) { + final (x, _) = crs.transform(p.dx, p.dy, zoomScale); + sink += x; + } + } + sw.stop(); + final newNs = sw.elapsedMicroseconds * 1000 / (frames * points.length); + + debugPrint('RESULT marker_projection_kernel: sink=${sink.isFinite} ' + 'full=${oldNs.toStringAsFixed(1)} ns/marker ' + 'cached=${newNs.toStringAsFixed(1)} ns/marker'); + }); + + test('benchmark: getOffsetsXY holed polygon (direct)', () { + final rng = math.Random(11); + final camera = MapCamera( + crs: const Epsg3857(), + center: _center, + zoom: 14, + rotation: 0, + nonRotatedSize: const Size(800, 600), + ); + const projection = SphericalMercator(); + final points = projection.projectList(_randomWalk(rng, _center, 500)); + final holePoints = [ + for (var h = 0; h < 10; h++) + projection.projectList( + _randomWalk(rng, _randomNear(rng, _center, 0.005), 200, 0.0001), + ), + ]; + + final helper = OffsetHelper(camera: camera); + // Warm-up. + for (var i = 0; i < 20; i++) { + helper.getOffsetsXY(points: points, holePoints: holePoints); + } + var best = double.infinity; + for (var rep = 0; rep < 5; rep++) { + const n = 200; + final sw = Stopwatch()..start(); + for (var i = 0; i < n; i++) { + helper.getOffsetsXY(points: points, holePoints: holePoints); + } + sw.stop(); + final per = sw.elapsedMicroseconds / n; + if (per < best) best = per; + } + debugPrint('RESULT get_offsets_xy_holed: ' + '${best.toStringAsFixed(1)} us/call'); + }); +} From aac199b3472dee45c1132e71f2736adb03da0878 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 30 Jun 2026 23:56:51 +0100 Subject: [PATCH 3/3] Prepare for v8.3.1 release Change demo app user agent to fix tile loading Add simple AI guidelines to CONTRIBUTING.md --- CHANGELOG.md | 23 ++++++++++++++++++++ CONTRIBUTING.md | 4 ++++ example/lib/misc/tile_providers.dart | 2 +- example/pubspec.lock | 32 +++++++++++----------------- example/pubspec.yaml | 2 +- pubspec.yaml | 2 +- windowsApplicationInstallerSetup.iss | 2 +- 7 files changed, 43 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 493bf182d..0be94b618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ Please consider [donating](https://github.com/sponsors/fleaflet) or [contributin This CHANGELOG does not include every commit and/or PR - it is a hand picked selection of the ones that have an effect on most users. For a full list of changes, please check the GitHub repository releases/tags. We also release highlights for some releases on the docs site. +## [8.3.1] - 2026/07/xx + +Contains the following user-affecting bug fixes: + +- Revert [#2182](https://github.com/fleaflet/flutter_map/pull/2182) to fix multiple internal errors - [#2218](https://github.com/fleaflet/flutter_map/pull/2218) for [#2199](https://github.com/fleaflet/flutter_map/issues/2199) +- Prevent memory leak and crash when `Marker`s with NaN coordinates provided - [#2213](https://github.com/fleaflet/flutter_map/pull/2213) for [#2178](https://github.com/fleaflet/flutter_map/issues/2178) + +Contains the following user-affecting performance improvements: + +- Improve `MarkerLayer` performance by caching projections - [#2213](https://github.com/fleaflet/flutter_map/pull/2213) +- Improve `PolygonLayer` performance, particularly for polygons with holes - [#2211](https://github.com/fleaflet/flutter_map/pull/2211) +- Improve `PolylineLayer` performance by improving culling algorithm - [#2212](https://github.com/fleaflet/flutter_map/pull/2212) + +Contains the following other notable changes: + +- Support 'pkg:latlong2' v0.10.x dependency - [#2207](https://github.com/fleaflet/flutter_map/pull/2207) + +Many thanks to these contributors (in no particular order): + +- @ThexXTURBOXx +- @ben-milanko +- ... and all the maintainers + ## [8.3.0] - 2026/04/14 Contains the following user-affecting changes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 179dd623c..30d35e024 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,3 +17,7 @@ We rely on a standardized process and procedure to ensure top-quality releases. * **Use a clear (preferably [Conventional](https://www.conventionalcommits.org/)) PR title.** This makes it easier for us to group commits for release and write correct CHANGELOGs. + +* **Using AI?** +If you're using AI in any way to write code or otherwise assist you, please let us know in the PR description. +If the code is of insufficient quality, it will be rejected. Human submitters take all responsibility for their code. diff --git a/example/lib/misc/tile_providers.dart b/example/lib/misc/tile_providers.dart index 5419b7425..5a5f25914 100644 --- a/example/lib/misc/tile_providers.dart +++ b/example/lib/misc/tile_providers.dart @@ -7,6 +7,6 @@ final httpClient = RetryClient(Client()); // TODO: This causes unneccessary rebuilding TileLayer get openStreetMapTileLayer => TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'dev.fleaflet.flutter_map.example', + userAgentPackageName: 'dev.fleaflet.flutter_map.demo', tileProvider: NetworkTileProvider(httpClient: httpClient), ); diff --git a/example/pubspec.lock b/example/pubspec.lock index 28720b875..50adcec9a 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -124,7 +124,7 @@ packages: path: ".." relative: true source: path - version: "8.2.2" + version: "8.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -207,22 +207,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - logger: - dependency: transitive - description: - name: logger - sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c" - url: "https://pub.dev" - source: hosted - version: "2.6.1" matcher: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -235,10 +227,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.3" mgrs_dart: dependency: transitive description: @@ -456,10 +448,10 @@ packages: dependency: transitive description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.12" typed_data: dependency: transitive description: @@ -552,10 +544,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.0" vm_service: dependency: transitive description: @@ -589,5 +581,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0 <4.0.0" flutter: ">=3.35.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1d6196f59..2135861ce 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_example description: Example application for 'flutter_map' package publish_to: "none" -version: 8.3.0 +version: 8.3.1 environment: sdk: ">=3.6.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index a969a06da..ae7eb7ada 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_map description: "Flutter's №1 non-commercially aimed map client: it's easy-to-use, versatile, vendor-free, fully cross-platform, and 100% pure-Flutter" -version: 8.3.0 +version: 8.3.1 repository: https://github.com/fleaflet/flutter_map issue_tracker: https://github.com/fleaflet/flutter_map/issues diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index b896f67ba..b2b526741 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "flutter_map Demo" -#define MyAppVersion "for 8.3.0" +#define MyAppVersion "for 8.3.1" #define MyAppPublisher "fleaflet" #define MyAppURL "https://github.com/fleaflet/flutter_map" #define MyAppSupportURL "https://github.com/fleaflet/flutter_map/issues"