-
-
Notifications
You must be signed in to change notification settings - Fork 888
Open
Description
What do you want implemented?
In the current version 8.2.2, there is no Arrow PolyLine, so I took some time to implement this feature.
What other alternatives are available?
The following code implements Arrow PolyLine with minor modifications based on version 8.2.2.
You only need to modify a small amount of code based on the Dashed property of _PolylinePainter to implement it.
This is Core code
if (isDashed) {
final DashedPixelHiker hiker = DashedPixelHiker(
offsets: offsets,
segmentValues: polyline.pattern.segments!,
patternFit: polyline.pattern.patternFit!,
closePath: false,
canvasSize: size,
strokeWidth: largestStrokeWidth,
);
for (final visibleSegment in hiker.getAllVisibleSegments()) {
final dx = visibleSegment.end.dx - visibleSegment.begin.dx;
final dy = visibleSegment.end.dy - visibleSegment.begin.dy;
var angle = atan2(dy, dx);
for (final path in paths) {
buildTriangle(visibleSegment.begin, strokeWidth, angle, path);
}
}
}
void buildTriangle(Offset center, double size, double angleRad, ui.Path path) {
final r = size;
final alpha = pi / 6; // 30度展开角
final baseR = r * 0.6;
final p0 = Offset(center.dx + r * cos(angleRad),
center.dy + r * sin(angleRad));
final p1 = Offset(center.dx + baseR * cos(angleRad + alpha),
center.dy + baseR * sin(angleRad + alpha));
final p2 = Offset(center.dx + baseR * cos(angleRad - alpha),
center.dy + baseR * sin(angleRad - alpha));
path
..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..close();
}Simulator.Screen.Recording.-.iPhone.16e.-.2026-01-16.at.11.28.46.mov
Complete demo code
import 'dart:core';
import 'dart:math' as math;
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/src/layer/shared/feature_layer_utils.dart';
import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart';
import 'package:flutter_map/src/layer/shared/layer_projection_simplification/state.dart';
import 'package:flutter_map/src/layer/shared/layer_projection_simplification/widget.dart';
import 'package:flutter_map/src/layer/shared/line_patterns/pixel_hiker.dart';
import 'package:flutter_map/src/misc/extensions.dart';
import 'package:flutter_map/src/misc/offsets.dart';
import 'package:flutter_map/src/misc/simplify.dart';
import 'package:latlong2/latlong.dart';
/// A [Polyline] (aka. LineString) layer for [FlutterMap].
@immutable
base class PolylineLayer<R extends Object>
extends ProjectionSimplificationManagementSupportedWidget {
/// [Polyline]s to draw
final List<Polyline<R>> polylines;
/// Acceptable extent outside of viewport before culling polyline segments
///
/// May need to be increased if the [Polyline.strokeWidth] +
/// [Polyline.borderStrokeWidth] is large. See the
/// [online documentation](https://docs.fleaflet.dev/layers/polyline-layer#culling)
/// for more info.
///
/// Defaults to 10. Set to `null` to disable culling.
final double? cullingMargin;
/// {@macro fm.lhn.layerHitNotifier.usage}
final LayerHitNotifier<R>? hitNotifier;
/// The minimum radius of the hittable area around each [Polyline] in logical
/// pixels
///
/// The entire visible area is always hittable, but if the visible area is
/// smaller than this, then this will be the hittable area.
///
/// Defaults to 10.
final double minimumHitbox;
/// Whether polylines should only be drawn/projected onto a single world
/// instead of potentially being drawn onto adjacent worlds (based on the
/// shortest distance)
///
/// When set `true` with a CRS which does support
/// [Crs.replicatesWorldLongitude], polylines will still be repeated across
/// worlds, but each polyline will only be drawn within one world.
///
/// Defaults to `false`.
final bool drawInSingleWorld;
/// Create a new [PolylineLayer] to use as child inside [FlutterMap.children].
const PolylineLayer({
super.key,
required this.polylines,
this.cullingMargin = 10,
this.hitNotifier,
this.minimumHitbox = 10,
this.drawInSingleWorld = false,
super.simplificationTolerance,
}) : super();
@override
State<PolylineLayer<R>> createState() => _PolylineLayerState<R>();
}
class _PolylineLayerState<R extends Object> extends State<PolylineLayer<R>>
with
ProjectionSimplificationManagement<_ProjectedPolyline<R>, Polyline<R>,
PolylineLayer<R>> {
@override
_ProjectedPolyline<R> projectElement({
required Projection projection,
required Polyline<R> element,
}) =>
_ProjectedPolyline._fromPolyline(
projection,
element,
widget.drawInSingleWorld,
);
@override
_ProjectedPolyline<R> simplifyProjectedElement({
required _ProjectedPolyline<R> projectedElement,
required double tolerance,
}) =>
_ProjectedPolyline._(
polyline: projectedElement.polyline,
points: simplifyPoints(
points: projectedElement.points,
tolerance: tolerance,
highQuality: true,
),
);
@override
List<Polyline<R>> get elements => widget.polylines;
@override
Widget build(BuildContext context) {
super.build(context);
final camera = MapCamera.of(context);
final culled = widget.cullingMargin == null
? simplifiedElements.toList()
: _aggressivelyCullPolylines(
projection: camera.crs.projection,
polylines: simplifiedElements,
camera: camera,
cullingMargin: widget.cullingMargin!,
).toList();
return MobileLayerTransformer(
child: CustomPaint(
painter: _PolylinePainter(
polylines: culled,
camera: camera,
hitNotifier: widget.hitNotifier,
minimumHitbox: widget.minimumHitbox,
),
size: camera.size,
),
);
}
Iterable<_ProjectedPolyline<R>> _aggressivelyCullPolylines({
required Projection projection,
required Iterable<_ProjectedPolyline<R>> polylines,
required MapCamera camera,
required double cullingMargin,
}) sync* {
final bounds = camera.visibleBounds;
final margin = cullingMargin / math.pow(2, camera.zoom);
// The min(-90), max(180), ... are used to get around the limits of LatLng
// the value cannot be greater or smaller than that
final boundsAdjusted = LatLngBounds.unsafe(
west: math.max(LatLngBounds.minLongitude, bounds.west - margin),
east: math.min(LatLngBounds.maxLongitude, bounds.east + margin),
south: math.max(LatLngBounds.minLatitude, bounds.south - margin),
north: math.min(LatLngBounds.maxLatitude, bounds.north + margin),
);
// segment is visible
final projBounds = Rect.fromPoints(
projection.project(boundsAdjusted.southWest),
projection.project(boundsAdjusted.northEast),
);
final (xWest, _) = projection.projectXY(const LatLng(0, -180));
final (xEast, _) = projection.projectXY(const LatLng(0, 180));
for (final projectedPolyline in polylines) {
final polyline = projectedPolyline.polyline;
final boundingBox = polyline.boundingBox;
/// Check if the camera and the polyline overlap, latitude-wise.
bool isOverlappingLatitude() {
if (boundsAdjusted.north < boundingBox.south) {
return false;
}
if (boundsAdjusted.south > boundingBox.north) {
return false;
}
return true;
}
/// Check if the camera and the polyline overlap, longitude-wise.
bool isOverlappingLongitude() {
if (boundsAdjusted.east < boundingBox.west) {
return false;
}
if (boundsAdjusted.west > boundingBox.east) {
return false;
}
return true;
}
/// Check if the camera longitude bounds are reliable, world-wise.
bool areLongitudeBoundsReliable() {
if (boundsAdjusted.east == LatLngBounds.maxLongitude) {
return false;
}
if (boundsAdjusted.west == LatLngBounds.minLongitude) {
return false;
}
return true;
}
// Test bounding boxes to avoid potentially expensive aggressive culling
// when none of the line is visible
// First check, bullet-proof, focusing on latitudes.
if (!isOverlappingLatitude()) continue;
// Gradient polylines cannot be easily segmented
if (polyline.gradientColors != null) {
yield projectedPolyline;
continue;
}
/// 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;
}
// 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.
if (stretchesBeyondTheLimits()) {
yield projectedPolyline;
continue;
}
// TODO: think about how to cull when the camera bounds go beyond -180/180.
if (!areLongitudeBoundsReliable()) {
yield projectedPolyline;
continue;
}
// Test bounding boxes to avoid potentially expensive aggressive culling
// when none of the line is visible. Here, focusing on longitudes.
if (!isOverlappingLongitude()) continue;
// pointer that indicates the start of the visible polyline segment
int start = -1;
bool containsSegment = false;
for (int i = 0; i < projectedPolyline.points.length - 1; i++) {
// Current segment (p1, p2).
final p1 = projectedPolyline.points[i];
final p2 = projectedPolyline.points[i + 1];
containsSegment =
projBounds.aabbContainsLine(p1.dx, p1.dy, p2.dx, p2.dy);
if (containsSegment) {
if (start == -1) {
start = i;
}
} else {
// If we cannot see this segment but have seen previous ones, flush the last polyline fragment.
if (start != -1) {
yield _ProjectedPolyline._(
polyline: polyline,
points: projectedPolyline.points.sublist(start, i + 1),
);
// Reset start.
start = -1;
}
}
}
// If the last segment was visible push that last visible polyline
// fragment, which may also be the entire polyline if `start == 0`.
if (containsSegment) {
yield start == 0
? projectedPolyline
: _ProjectedPolyline._(
polyline: polyline,
// Special case: the entire polyline is visible
points: projectedPolyline.points.sublist(start),
);
}
}
}
}
@immutable
class _ProjectedPolyline<R extends Object> with HitDetectableElement<R> {
final Polyline<R> polyline;
final List<Offset> points;
@override
R? get hitValue => polyline.hitValue;
const _ProjectedPolyline._({
required this.polyline,
required this.points,
});
_ProjectedPolyline._fromPolyline(
Projection projection,
Polyline<R> polyline,
bool drawInSingleWorld,
) : this._(
polyline: polyline,
points: projection.projectList(
polyline.points,
projectToSingleWorld: drawInSingleWorld,
),
);
}
/// The [CustomPainter] used to draw [Polyline]s for the [PolylineLayer].
// TODO: We should consider exposing this publicly, as with [CirclePainter] -
// but the projected objects are private at the moment.
class _PolylinePainter<R extends Object> extends CustomPainter
with HitDetectablePainter<R, _ProjectedPolyline<R>>, FeatureLayerUtils {
final List<_ProjectedPolyline<R>> polylines;
final double minimumHitbox;
@override
final MapCamera camera;
@override
final LayerHitNotifier<R>? hitNotifier;
/// Create a new [_PolylinePainter] instance
_PolylinePainter({
required this.polylines,
required this.minimumHitbox,
required this.camera,
required this.hitNotifier,
}) {
_helper = OffsetHelper(camera: camera);
}
late final OffsetHelper _helper;
@override
bool elementHitTest(
_ProjectedPolyline<R> projectedPolyline, {
required Offset point,
required LatLng coordinate,
}) {
final polyline = projectedPolyline.polyline;
// TODO: We should check the bounding box here, for efficiency
// However, we need to account for:
// * map rotation
// * extended bbox that accounts for `minimumHitbox`
//
// if (!polyline.boundingBox.contains(touch)) {
// continue;
// }
WorldWorkControl checkIfHit(double shift) {
final (offsets, _) = _helper.getOffsetsXY(
points: projectedPolyline.points,
shift: shift,
);
if (!areOffsetsVisible(offsets)) return WorldWorkControl.invisible;
final strokeWidth = polyline.useStrokeWidthInMeter
? metersToScreenPixels(
projectedPolyline.polyline.points.first,
polyline.strokeWidth,
)
: polyline.strokeWidth;
final hittableDistance = math.max(
strokeWidth / 2 + polyline.borderStrokeWidth / 2,
minimumHitbox,
);
for (int i = 0; i < offsets.length - 1; i++) {
final o1 = offsets[i];
final o2 = offsets[i + 1];
final distanceSq =
getSqSegDist(point.dx, point.dy, o1.dx, o1.dy, o2.dx, o2.dy);
if (distanceSq <= hittableDistance * hittableDistance) {
return WorldWorkControl.hit;
}
}
return WorldWorkControl.visible;
}
return workAcrossWorlds(checkIfHit);
}
@override
Iterable<_ProjectedPolyline<R>> get elements => polylines;
@override
void paint(Canvas canvas, Size size) {
super.paint(canvas, size);
var path = ui.Path();
var borderPath = ui.Path();
var filterPath = ui.Path();
var paint = Paint();
var needsLayerSaving = false;
Paint? borderPaint;
Paint? filterPaint;
int? lastHash;
void drawPaths() {
final hasBorder = borderPaint != null && filterPaint != null;
if (hasBorder) {
if (needsLayerSaving) {
canvas.saveLayer(viewportRect, Paint());
}
canvas.drawPath(borderPath, borderPaint!);
borderPath = ui.Path();
borderPaint = null;
if (needsLayerSaving) {
canvas.drawPath(filterPath, filterPaint!);
filterPath = ui.Path();
filterPaint = null;
canvas.restore();
}
}
canvas.drawPath(path, paint);
path = ui.Path();
paint = Paint();
}
for (final projectedPolyline in polylines) {
final polyline = projectedPolyline.polyline;
if (polyline.points.isEmpty) {
continue;
}
/// Draws on a "single-world"
WorldWorkControl drawIfVisible(double shift) {
final (offsets, _) = _helper.getOffsetsXY(
points: projectedPolyline.points,
shift: shift,
);
if (!areOffsetsVisible(offsets)) return WorldWorkControl.invisible;
final hash = polyline.renderHashCode;
if (needsLayerSaving || (lastHash != null && lastHash != hash)) {
drawPaths();
}
lastHash = hash;
needsLayerSaving = polyline.color.a < 1 ||
(polyline.gradientColors?.any((c) => c.a < 1) ?? false);
// strokeWidth, or strokeWidth + borderWidth if relevant.
late double largestStrokeWidth;
late final double strokeWidth;
if (polyline.useStrokeWidthInMeter) {
strokeWidth = metersToScreenPixels(
projectedPolyline.polyline.points.first,
polyline.strokeWidth,
);
} else {
strokeWidth = polyline.strokeWidth;
}
largestStrokeWidth = strokeWidth;
final isSolid = polyline.pattern == const StrokePattern.solid();
final isDashed = polyline.pattern.segments != null;
final isDotted = polyline.pattern.spacingFactor != null;
paint = Paint()
..strokeWidth = strokeWidth
..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke
..blendMode = BlendMode.srcOver;
if (polyline.gradientColors == null) {
paint.color = polyline.color;
} else {
polyline.gradientColors!.isNotEmpty
? paint.shader = _paintGradient(polyline, offsets)
: paint.color = polyline.color;
}
if (polyline.borderStrokeWidth > 0.0) {
// Outlined lines are drawn by drawing a thicker path underneath, then
// stenciling the middle (in case the line fill is transparent), and
// finally drawing the line fill.
largestStrokeWidth = strokeWidth + polyline.borderStrokeWidth;
borderPaint = Paint()
..color = polyline.borderColor
..strokeWidth = strokeWidth + polyline.borderStrokeWidth
..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke
..blendMode = BlendMode.srcOver;
filterPaint = Paint()
..color = polyline.borderColor.withAlpha(255)
..strokeWidth = strokeWidth
..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke
..blendMode = BlendMode.dstOut;
}
final radius = paint.strokeWidth / 2;
final borderRadius = (borderPaint?.strokeWidth ?? 0) / 2;
final List<ui.Path> paths = [];
if (borderPaint != null && filterPaint != null) {
paths.add(borderPath);
paths.add(filterPath);
}
paths.add(path);
if (isSolid) {
final SolidPixelHiker hiker = SolidPixelHiker(
offsets: offsets,
closePath: false,
canvasSize: size,
strokeWidth: largestStrokeWidth,
);
hiker.addAllVisibleSegments(paths);
} else if (isDotted) {
final DottedPixelHiker hiker = DottedPixelHiker(
offsets: offsets,
stepLength: strokeWidth * polyline.pattern.spacingFactor!,
patternFit: polyline.pattern.patternFit!,
closePath: false,
canvasSize: size,
strokeWidth: largestStrokeWidth,
);
final List<double> radii = [];
if (borderPaint != null && filterPaint != null) {
radii.add(borderRadius);
radii.add(radius);
}
radii.add(radius);
for (final visibleDot in hiker.getAllVisibleDots()) {
for (int i = 0; i < paths.length; i++) {
paths[i].addOval(
Rect.fromCircle(center: visibleDot, radius: radii[i]));
}
}
} else if (isDashed) {
final DashedPixelHiker hiker = DashedPixelHiker(
offsets: offsets,
segmentValues: polyline.pattern.segments!,
patternFit: polyline.pattern.patternFit!,
closePath: false,
canvasSize: size,
strokeWidth: largestStrokeWidth,
);
for (final visibleSegment in hiker.getAllVisibleSegments()) {
final dx = visibleSegment.end.dx - visibleSegment.begin.dx;
final dy = visibleSegment.end.dy - visibleSegment.begin.dy;
var angle = atan2(dy, dx);
for (final path in paths) {
buildTriangle(visibleSegment.begin, strokeWidth, angle, path);
}
}
}
return WorldWorkControl.visible;
}
workAcrossWorlds(drawIfVisible);
}
drawPaths();
}
void buildTriangle(Offset center, double size, double angleRad, ui.Path path) {
final r = size;
final alpha = pi / 6; // 30度展开角
final baseR = r * 0.6;
final p0 = Offset(center.dx + r * cos(angleRad),
center.dy + r * sin(angleRad));
final p1 = Offset(center.dx + baseR * cos(angleRad + alpha),
center.dy + baseR * sin(angleRad + alpha));
final p2 = Offset(center.dx + baseR * cos(angleRad - alpha),
center.dy + baseR * sin(angleRad - alpha));
path
..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..close();
}
ui.Gradient _paintGradient(Polyline polyline, List<Offset> offsets) =>
ui.Gradient.linear(offsets.first, offsets.last, polyline.gradientColors!,
_getColorsStop(polyline));
List<double>? _getColorsStop(Polyline polyline) =>
(polyline.colorsStop != null &&
polyline.colorsStop!.length == polyline.gradientColors!.length)
? polyline.colorsStop
: _calculateColorsStop(polyline);
List<double> _calculateColorsStop(Polyline polyline) {
final colorsStopInterval = 1.0 / polyline.gradientColors!.length;
return polyline.gradientColors!
.map((gradientColor) =>
polyline.gradientColors!.indexOf(gradientColor) *
colorsStopInterval)
.toList();
}
@override
bool shouldRepaint(_PolylinePainter<R> oldDelegate) =>
polylines != oldDelegate.polylines ||
camera != oldDelegate.camera ||
hitNotifier != oldDelegate.hitNotifier ||
minimumHitbox != oldDelegate.minimumHitbox;
}
class RasterLayer{
static Widget build(BuildContext context, {required String urlTemplate, required int tileMaxZoom, required int mapMaxZoom}){
return TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'com.demo.test',
maxZoom: mapMaxZoom.toDouble(),
maxNativeZoom: tileMaxZoom,
tileProvider: NetworkTileProvider(),
);
}
}import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'MyPolylineLayer.dart' as custom;
import 'RasterLayer.dart';
class UIMap extends StatefulWidget{
@override
State<StatefulWidget> createState() => _State();
}
class _State extends State<UIMap>{
final mapController = MapController();
final points = <LatLng>[];
@override
Widget build(BuildContext context) {
return FlutterMap(
mapController: mapController,
options: MapOptions(
initialCenter: LatLng(47.0451022, -122.8950075),
initialZoom: 16,
maxZoom: 20,
onTap: (tap, point){
points.add(point);
if(points.length < 2) return;
setState(() {});
},
),
children: [
RasterLayer.build(context, urlTemplate: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png', tileMaxZoom: 16, mapMaxZoom: 20),
if(points.isNotEmpty) custom.PolylineLayer(
polylines: [
Polyline(points: points, color: Colors.blue, strokeWidth: 10),
Polyline(points: points, color: Colors.black54, strokeWidth: 10, pattern: StrokePattern.dashed(segments: [10, 40])),
],
)
],
);
}
}hpoul
Metadata
Metadata
Assignees
Labels
No labels