Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Multicolor outline performance

Run from the package root:

```sh
flutter test --no-pub benchmark/multicolor_outline_benchmark_test.dart --concurrency=1
```

This synthetic, tile-free benchmark compares ordinary polylines, uniform-color
multicolor polylines, gradients, and gradients with a separate uniform outline.
It exercises pan and zoom with 5,000 and 20,000 vertices. Each case warms up for
15 frames and reports the median and minimum of five 30-frame samples.

These are widget-test CPU build/paint timings, not GPU frame times or an FPS
prediction. Run before and after changes on the same machine, without concurrent
benchmarks. Both kinds of polyline now use the existing projection and floored-zoom
simplification cache. Multicolor lines additionally preserve changes exceeding
0.02 per RGBA channel, so they may retain more vertices than ordinary lines.
Setting the layer's `simplificationTolerance` to zero disables simplification.

The opaque uniform-color fast path uses the standard batched path painter while
retaining multicolor projection and culling.
Nonuniform and translucent lines retain the segment-gradient renderer. Immutable
vertex colors allow uniformity and transparency checks to be cached per line.

On the development machine, compared with cc3dc7f, the 5,000-point outlined route
decreased from 21.34 to 12.80 ms per pan frame and 22.00 to 12.78 ms per zoom frame.
The uniform outline alone decreased from about 10 ms to 2 ms. The gradient-only
case remained around 11 ms: this optimization does not eliminate gradient cost.

A subsequent segment optimization uses `Canvas.drawLine` instead of repeatedly
building and resetting a two-point path. The same 5,000-point benchmark measured
11.19 to 10.02 ms for gradient pan and 11.21 to 10.22 ms for gradient zoom;
outlined routes decreased from 13.03 to 12.02 ms and 13.05 to 11.91 ms respectively.
This preserves per-segment gradients, visibility checks, and explicit joins/caps;
it does not quantize colors or discard samples. Results are local CPU measurements
and should be rechecked on the target device and renderer.

Color-aware simplification stores explicit original sample indices, also sliced
when viewport culling splits a line. A separate outline should supply its
foreground's `vertexColors` as `simplificationColors`, selecting the same vertices
without changing the outline's paint. Separate polylines remain separate; no gaps
are joined. No predictive prewarming or raster cache is added.

The 5,000-point synthetic benchmark with warm zoom caches measured approximately
0.88 ms gradient pan, 0.83 ms gradient zoom, 0.98 ms outlined pan, and 0.85 ms
outlined zoom after simplification. These are dataset-dependent: noisy geometry
or colors deliberately retain more points. Warm timings exclude initial cache
construction and are not claims about first-open latency or GPU frame rate.

Pixel tests compare uniform multicolor and ordinary simplification across caps,
borders, duplicate vertices, and offscreen endpoints. Additional
tests cover original color indices after simplification and culling, brief color
and alpha peaks, duplicate coordinates, zoom changes, and zero-tolerance mode.
74 changes: 74 additions & 0 deletions benchmark/multicolor_outline_benchmark_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';

// CPU build/paint timings in the widget-test runner, not GPU frame timings.
// Run the identical file before/after with --concurrency=1 on the same machine.
void main() {
for (final count in [5000, 20000]) {
for (final mode in ['plain', 'solid-multicolor', 'gradient', 'outlined']) {
for (final zooming in [false, true]) {
testWidgets('$mode $count ${zooming ? 'zoom' : 'pan'} benchmark',
(tester) async {
final points = [
for (var i = 0; i < count; i++)
LatLng(43 + 0.008 * math.sin(i / count * math.pi * 8),
-79 + 0.012 * math.cos(i / count * math.pi * 6)),
];
final colors = [
for (var i = 0; i < count; i++)
Color.lerp(Colors.blue, Colors.red, (i % 100) / 99)!,
];
final lines = <Polyline>[
if (mode == 'plain')
Polyline(points: points, color: Colors.white, strokeWidth: 7),
if (mode == 'solid-multicolor' || mode == 'outlined')
MulticolorPolyline(
points: points,
defaultColor: Colors.white,
simplificationColors: mode == 'outlined' ? colors : null,
strokeWidth: 7),
if (mode == 'gradient' || mode == 'outlined')
MulticolorPolyline(
points: points, vertexColors: colors, strokeWidth: 4.5),
];
final controller = MapController();
await tester.pumpWidget(MaterialApp(
home: FlutterMap(
mapController: controller,
options: const MapOptions(
initialCenter: LatLng(43, -79), initialZoom: 14),
children: [PolylineLayer(polylines: lines)],
)));
Future<void> frame(int i) async {
controller.move(
LatLng(43 + (i % 7) * 0.00003, -79 + (i % 11) * 0.00003),
zooming ? 13.5 + (i % 20) * 0.05 : 14);
await tester.pump();
}

for (var i = 0; i < 15; i++) {
await frame(i);
}
final samples = <double>[];
for (var repeat = 0; repeat < 5; repeat++) {
final timer = Stopwatch()..start();
for (var i = 0; i < 30; i++) {
await frame(i);
}
samples.add(timer.elapsedMicroseconds / 30);
}
samples.sort();
debugPrint('RESULT $mode points=$count ${zooming ? 'zoom' : 'pan'} '
'median_us=${samples[2].round()} min_us=${samples.first.round()}');
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
controller.dispose();
});
}
}
}
}
29 changes: 29 additions & 0 deletions example/lib/pages/polyline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ import 'package:latlong2/latlong.dart';

typedef HitValue = ({String title, String subtitle});

const _gpsMetricPoints = [
LatLng(50.8, -4.2),
LatLng(51.2, -3),
LatLng(51, -1.8),
LatLng(51.5, -0.4),
LatLng(52, 0.8),
LatLng(52.4, 2),
];

// GPS metrics contain one value per recorded coordinate.
const _gpsSpeedKph = <double>[8, 14, 27, 21, 35, 18];

Color _speedColor(double speedKph) => Color.lerp(
const Color(0xFF1565C0),
const Color(0xFFF4511E),
((speedKph - 5) / 30).clamp(0.0, 1.0),
)!;

class PolylinePage extends StatefulWidget {
static const String route = '/polyline';

Expand All @@ -22,6 +40,17 @@ class _PolylinePageState extends State<PolylinePage> {
List<Polyline<HitValue>>? _hoverLines;

final _polylinesRaw = <Polyline<HitValue>>[
MulticolorPolyline(
points: _gpsMetricPoints,
vertexColors: _gpsSpeedKph.map(_speedColor).toList(growable: false),
strokeWidth: 8,
borderStrokeWidth: 2,
borderColor: Colors.white,
hitValue: (
title: 'GPS Speed Line',
subtitle: 'Color encodes speed at each recorded GPS point',
),
),
Polyline(
points: const [
LatLng(40, 150),
Expand Down
124 changes: 124 additions & 0 deletions lib/src/layer/polyline_layer/multicolor_polyline.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
part of 'polyline_layer.dart';

/// A solid [Polyline] that paints a smooth gradient between vertex colors.
///
/// Map metrics recorded alongside GPS coordinates, such as speed, elevation,
/// heart rate, or accuracy, to one color per coordinate.
///
/// Layer simplification retains source indices and limits sampled color error
/// to 0.02 per RGBA channel. Set [PolylineLayer.simplificationTolerance] to zero
/// to render every recorded sample.
///
/// Consecutive segments use round joins. Line endings use [strokeCap].
class MulticolorPolyline<R extends Object> extends Polyline<R> {
/// The color applied at each vertex in [points].
///
/// When `null` or empty, [defaultColor] is used instead.
final List<Color>? vertexColors;

/// The fallback color used when no [vertexColors] are provided.
final Color defaultColor;

/// Optional colors used only to select simplification vertices.
///
/// Supply the foreground's vertex colors for a separate solid outline so
/// both strokes retain identical geometry. Defaults to [vertexColors].
final List<Color>? simplificationColors;

int? _multicolorRenderHashCode;
int? _multicolorHashCode;
List<Color>? _resolvedColors;

/// Create a multicolor polyline that interpolates between the supplied
/// [vertexColors].
// Super parameters cannot initialize `color`, which is derived from
// `vertexColors` and `defaultColor`.
// ignore: use_super_parameters
MulticolorPolyline({
required List<LatLng> points,
List<Color>? vertexColors,
List<Color>? simplificationColors,
this.defaultColor = const Color(0xFF00FF00),
double strokeWidth = 1.0,
double borderStrokeWidth = 0.0,
Color borderColor = const Color(0xFFFFFF00),
StrokeCap strokeCap = StrokeCap.round,
bool useStrokeWidthInMeter = false,
R? hitValue,
}) : vertexColors = _validateVertexColors(points, vertexColors),
simplificationColors =
_validateVertexColors(points, simplificationColors),
super(
points: points,
strokeWidth: strokeWidth,
pattern: const StrokePattern.solid(),
color: (vertexColors != null && vertexColors.isNotEmpty)
? vertexColors.first
: defaultColor,
borderStrokeWidth: borderStrokeWidth,
borderColor: borderColor,
gradientColors: null,
colorsStop: null,
strokeCap: strokeCap,
strokeJoin: StrokeJoin.round,
useStrokeWidthInMeter: useStrokeWidthInMeter,
hitValue: hitValue,
);

late final bool _hasTransparentVertices = vertexColors == null
? defaultColor.a < 1
: vertexColors!.any((color) => color.a < 1);

late final bool _isOpaqueSolid = color.a == 1 &&
(vertexColors == null ||
vertexColors!.every((vertex) => vertex == color));

List<Color> get _resolvedVertexColors => _resolvedColors ??=
vertexColors ?? List<Color>.filled(points.length, defaultColor);

@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is MulticolorPolyline<R> &&
super == other &&
defaultColor == other.defaultColor &&
listEquals(simplificationColors, other.simplificationColors) &&
listEquals(vertexColors, other.vertexColors));

@override
int get renderHashCode => _multicolorRenderHashCode ??= Object.hash(
super.renderHashCode,
defaultColor,
simplificationColors == null
? null
: Object.hashAll(simplificationColors!),
vertexColors == null ? null : Object.hashAll(vertexColors!));

@override
int get hashCode => _multicolorHashCode ??= Object.hash(
super.hashCode,
defaultColor,
vertexColors == null ? null : Object.hashAll(vertexColors!));
}

List<Color>? _validateVertexColors(
List<LatLng> points,
List<Color>? vertexColors,
) {
if (points.length < 2) {
throw ArgumentError.value(
points,
'points',
'MulticolorPolyline requires at least two points',
);
}
if (vertexColors == null || vertexColors.isEmpty) return null;
if (points.length != vertexColors.length) {
throw ArgumentError.value(
vertexColors,
'vertexColors',
'length must match points length',
);
}
return List<Color>.unmodifiable(vertexColors);
}
Loading