Skip to content

Commit

Permalink
Added support for interactivity to Polygons
Browse files Browse the repository at this point in the history
Improved example application to showcase Polygon interactivity
  • Loading branch information
JaffaKetchup committed Feb 10, 2024
1 parent 87e0bd5 commit 908421e
Show file tree
Hide file tree
Showing 10 changed files with 525 additions and 191 deletions.
474 changes: 333 additions & 141 deletions example/lib/pages/polygon.dart

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions example/lib/pages/polyline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:flutter_map_example/misc/tile_providers.dart';
import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart';
import 'package:latlong2/latlong.dart';

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

class PolylinePage extends StatefulWidget {
static const String route = '/polyline';
Expand All @@ -17,11 +17,11 @@ class PolylinePage extends StatefulWidget {
}

class _PolylinePageState extends State<PolylinePage> {
final LayerHitNotifier<PolylineHitValue> _hitNotifier = ValueNotifier(null);
List<PolylineHitValue>? _prevHitValues;
List<Polyline<PolylineHitValue>>? _hoverLines;
final LayerHitNotifier<HitValue> _hitNotifier = ValueNotifier(null);
List<HitValue>? _prevHitValues;
List<Polyline<HitValue>>? _hoverLines;

final _polylinesRaw = <Polyline<PolylineHitValue>>[
final _polylinesRaw = <Polyline<HitValue>>[
Polyline(
points: [
const LatLng(51.5, -0.09),
Expand Down Expand Up @@ -142,7 +142,7 @@ class _PolylinePageState extends State<PolylinePage> {
final hoverLines = hitValues.map((v) {
final original = _polylines[v]!;

return Polyline<PolylineHitValue>(
return Polyline<HitValue>(
points: original.points,
strokeWidth:
original.strokeWidth + original.borderStrokeWidth,
Expand All @@ -162,17 +162,17 @@ class _PolylinePageState extends State<PolylinePage> {
onTap: () => _openTouchedLinesModal(
'Tapped',
_hitNotifier.value!.hitValues,
_hitNotifier.value!.point,
_hitNotifier.value!.coordinate,
),
onLongPress: () => _openTouchedLinesModal(
'Long pressed',
_hitNotifier.value!.hitValues,
_hitNotifier.value!.point,
_hitNotifier.value!.coordinate,
),
onSecondaryTap: () => _openTouchedLinesModal(
'Secondary tapped',
_hitNotifier.value!.hitValues,
_hitNotifier.value!.point,
_hitNotifier.value!.coordinate,
),
child: PolylineLayer(
hitNotifier: _hitNotifier,
Expand All @@ -190,7 +190,7 @@ class _PolylinePageState extends State<PolylinePage> {

void _openTouchedLinesModal(
String eventType,
List<PolylineHitValue> tappedLines,
List<HitValue> tappedLines,
LatLng coords,
) {
showModalBottomSheet<void>(
Expand Down
19 changes: 16 additions & 3 deletions lib/src/layer/general/hit_detection.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart';
import 'package:meta/meta.dart';
Expand All @@ -16,13 +18,24 @@ class LayerHitResult<R extends Object> {
/// top-to-bottom.
final List<R> hitValues;

/// Coordinates of the detected hit
/// Geographical coordinates of the detected hit
///
/// Note that this may not lie on a feature.
final LatLng point;
///
/// See [point] for the screen point which was hit.
final LatLng coordinate;

/// Screen point of the detected hit
///
/// See [coordinate] for the geographical coordinate which was hit.
final Point<double> point;

@internal
const LayerHitResult({required this.hitValues, required this.point});
const LayerHitResult({
required this.hitValues,
required this.coordinate,
required this.point,
});
}

/// A [ValueNotifier] that notifies:
Expand Down
117 changes: 109 additions & 8 deletions lib/src/layer/polygon_layer/painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ part of 'polygon_layer.dart';

/// The [_PolygonPainter] class is used to render [Polygon]s for
/// the [PolygonLayer].
class _PolygonPainter extends CustomPainter {
class _PolygonPainter<R extends Object> extends CustomPainter {
/// Reference to the list of [_ProjectedPolygon]s
final List<_ProjectedPolygon> polygons;
final List<_ProjectedPolygon<R>> polygons;

/// Triangulated [polygons] if available
///
Expand All @@ -23,21 +23,114 @@ class _PolygonPainter extends CustomPainter {
/// Whether to draw labels last and thus over all the polygons
final bool drawLabelsLast;

/// See [PolylineLayer.hitNotifier]
final LayerHitNotifier<R>? hitNotifier;

final _hits = <R>[]; // Avoids repetitive memory reallocation

/// Create a new [_PolygonPainter] instance.
_PolygonPainter({
required this.polygons,
required this.triangles,
required this.camera,
required this.polygonLabels,
required this.drawLabelsLast,
required this.hitNotifier,
}) : bounds = camera.visibleBounds;

({Offset min, Offset max}) getBounds(Offset origin, Polygon polygon) {
final bbox = polygon.boundingBox;
return (
min: getOffset(camera, origin, bbox.southWest),
max: getOffset(camera, origin, bbox.northEast),
@override
bool? hitTest(Offset position) {
const eps = 1e-7; // Ensures polygons have non-zero size

if (hitNotifier == null) return null;

_hits.clear();

final origin =
camera.project(camera.center).toOffset() - camera.size.toOffset() / 2;

for (final projectedPolygon in polygons.reversed) {
final polygon = projectedPolygon.polygon;
if (polygon.hitValue == null) continue;

// TODO: For efficiency we'd ideally filter by bounding box here. However
// we'd need to compute an extended bounding box that accounts account for
// the `borderStrokeWidth`
// if (!polygon.boundingBox.contains(touch)) {
// continue;
// }

final projectedCoords = getOffsetsXY(
camera: camera,
origin: origin,
points: projectedPolygon.points,
).map((c) => polybool.Coordinate(c.dx, c.dy)).toList();

// 'polybool' requires polygons to be closed loops
if (projectedCoords.first != projectedCoords.last) {
projectedCoords.add(projectedCoords.first);
}

final hasHoles = projectedPolygon.holePoints.isNotEmpty;
late final List<List<polybool.Coordinate>> projectedHoleCoords;
if (hasHoles) {
projectedHoleCoords = projectedPolygon.holePoints
.map(
(p) => getOffsetsXY(
camera: camera,
origin: origin,
points: p,
).map((c) => polybool.Coordinate(c.dx, c.dy)).toList(),
)
.toList();

// 'polybool' requires polygons to be closed loops
if (projectedHoleCoords.firstOrNull != projectedHoleCoords.lastOrNull) {
projectedHoleCoords.add(projectedHoleCoords.first);
}
}

final touch = polybool.Polygon(
regions: [
[
polybool.Coordinate(position.dx, position.dy),
polybool.Coordinate(position.dx + eps, position.dy),
polybool.Coordinate(position.dx + eps, position.dy + eps),
polybool.Coordinate(position.dx, position.dy + eps),
polybool.Coordinate(position.dx, position.dy),
],
],
);

if (polybool.Polygon(regions: [projectedCoords])
.intersect(touch)
.regions
.isNotEmpty &&
((!hasHoles) ||
projectedHoleCoords
.map(
(c) => polybool.Polygon(regions: [c])
.intersect(touch)
.regions
.isEmpty,
)
.every((e) => e))) {
_hits.add(polygon.hitValue!);
}
}

if (_hits.isEmpty) {
hitNotifier!.value = null;
return false;
}

final point = position.toPoint();
hitNotifier!.value = LayerHitResult(
hitValues: _hits,
coordinate: camera.pointToLatLng(point),
point: point,
);
return true;
}

@override
Expand Down Expand Up @@ -306,9 +399,17 @@ class _PolygonPainter extends CustomPainter {
path.addPolygon(offsets, true);
}

({Offset min, Offset max}) getBounds(Offset origin, Polygon polygon) {
final bbox = polygon.boundingBox;
return (
min: getOffset(camera, origin, bbox.southWest),
max: getOffset(camera, origin, bbox.northEast),
);
}

// TODO: Fix bug where wrapping layer in some widgets (eg. opacity) causes the
// features to not move unless this is `true`, but `true` significantly impacts
// performance
@override
bool shouldRepaint(_PolygonPainter oldDelegate) => false;
bool shouldRepaint(_PolygonPainter<R> oldDelegate) => false;
}
12 changes: 11 additions & 1 deletion lib/src/layer/polygon_layer/polygon.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ enum PolygonLabelPlacement {
}

/// [Polygon] class, to be used for the [PolygonLayer].
class Polygon {
class Polygon<R extends Object> {
/// The points for the outline of the [Polygon].
final List<LatLng> points;

Expand Down Expand Up @@ -69,6 +69,14 @@ class Polygon {
/// Also see [PolygonLayer.performantRendering].
final bool performantRendering;

/// Value notified in [PolygonLayer.hitNotifier]
///
/// Polylines without a defined [hitValue] are still hit tested, but are not
/// notified about.
///
/// Should implement an equality operator to avoid breaking [Polygon.==].
final R? hitValue;

/// Designates whether the given polygon points follow a clock or
/// anti-clockwise direction.
/// This is respected during draw call batching for filled polygons.
Expand Down Expand Up @@ -128,6 +136,7 @@ class Polygon {
this.labelPlacement = PolygonLabelPlacement.centroid,
this.rotateLabel = false,
this.performantRendering = true,
this.hitValue,
}) : _filledAndClockwise =
(isFilled ?? (color != null)) && isClockwise(points);

Expand Down Expand Up @@ -160,6 +169,7 @@ class Polygon {
labelPlacement == other.labelPlacement &&
rotateLabel == other.rotateLabel &&
performantRendering == other.performantRendering &&
hitValue == other.hitValue &&
// Expensive computations last to take advantage of lazy logic gates
listEquals(holePointsList, other.holePointsList) &&
listEquals(points, other.points));
Expand Down
38 changes: 26 additions & 12 deletions lib/src/layer/polygon_layer/polygon_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/src/misc/offsets.dart';
import 'package:flutter_map/src/misc/simplify.dart';
import 'package:latlong2/latlong.dart' hide Path;
import 'package:polylabel/polylabel.dart'; // conflict with Path from UI
import 'package:polybool/polybool.dart' as polybool;
import 'package:polylabel/polylabel.dart';

part 'label.dart';
part 'painter.dart';
Expand All @@ -18,9 +19,9 @@ part 'projected_polygon.dart';

/// A polygon layer for [FlutterMap].
@immutable
class PolygonLayer extends StatefulWidget {
class PolygonLayer<R extends Object> extends StatefulWidget {
/// [Polygon]s to draw
final List<Polygon> polygons;
final List<Polygon<R>> polygons;

/// {@template fm.PolygonLayer.performantRendering}
/// Whether to use an alternative, specialised, rendering pathway to draw
Expand Down Expand Up @@ -72,6 +73,17 @@ class PolygonLayer extends StatefulWidget {
/// Defaults to `false`.
final bool drawLabelsLast;

/// A notifier to be notified when a hit test occurs on the layer
///
/// If a notifier is not provided, hit testing is not performed.
///
/// Notified with a [LayerHitResult] if any polylines are hit, otherwise
/// notified with `null`.
///
/// See online documentation for more detailed usage instructions. See the
/// example project for an example implementation.
final LayerHitNotifier<R>? hitNotifier;

/// Create a new [PolygonLayer] for the [FlutterMap] widget.
const PolygonLayer({
super.key,
Expand All @@ -81,20 +93,21 @@ class PolygonLayer extends StatefulWidget {
this.simplificationTolerance = 0.5,
this.polygonLabels = true,
this.drawLabelsLast = false,
this.hitNotifier,
});

@override
State<PolygonLayer> createState() => _PolygonLayerState();
State<PolygonLayer<R>> createState() => _PolygonLayerState<R>();
}

class _PolygonLayerState extends State<PolygonLayer> {
List<_ProjectedPolygon>? _cachedProjectedPolygons;
final _cachedSimplifiedPolygons = <int, List<_ProjectedPolygon>>{};
class _PolygonLayerState<R extends Object> extends State<PolygonLayer<R>> {
List<_ProjectedPolygon<R>>? _cachedProjectedPolygons;
final _cachedSimplifiedPolygons = <int, List<_ProjectedPolygon<R>>>{};

double? _devicePixelRatio;

@override
void didUpdateWidget(PolygonLayer oldWidget) {
void didUpdateWidget(PolygonLayer<R> oldWidget) {
super.didUpdateWidget(oldWidget);

if (!listEquals(oldWidget.polygons, widget.polygons)) {
Expand Down Expand Up @@ -124,7 +137,7 @@ class _PolygonLayerState extends State<PolygonLayer> {
growable: false,
);

late final List<_ProjectedPolygon> simplified;
late final List<_ProjectedPolygon<R>> simplified;
if (widget.simplificationTolerance == 0) {
simplified = projected;
} else {
Expand Down Expand Up @@ -190,15 +203,16 @@ class _PolygonLayerState extends State<PolygonLayer> {
camera: camera,
polygonLabels: widget.polygonLabels,
drawLabelsLast: widget.drawLabelsLast,
hitNotifier: widget.hitNotifier,
),
size: Size(camera.size.x, camera.size.y),
),
);
}

static List<_ProjectedPolygon> _computeZoomLevelSimplification({
List<_ProjectedPolygon<R>> _computeZoomLevelSimplification({
required MapCamera camera,
required List<_ProjectedPolygon> polygons,
required List<_ProjectedPolygon<R>> polygons,
required double pixelTolerance,
required double devicePixelRatio,
}) {
Expand All @@ -209,7 +223,7 @@ class _PolygonLayerState extends State<PolygonLayer> {
devicePixelRatio: devicePixelRatio,
);

return List<_ProjectedPolygon>.generate(
return List<_ProjectedPolygon<R>>.generate(
polygons.length,
(i) {
final polygon = polygons[i];
Expand Down
Loading

0 comments on commit 908421e

Please sign in to comment.