Skip to content
Open
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
40 changes: 31 additions & 9 deletions example/lib/pages/markers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,11 @@ class _MarkerPageState extends State<MarkerPage> {
Flexible(
child: FlutterMap(
options: MapOptions(
initialCenter: const LatLng(51.5, -0.09),
initialZoom: 5,
initialCenter: const LatLng(
51.51868093513547,
-0.12835376940892318,
),
initialZoom: 15,
onTap: (_, p) => setState(() => customMarkers.add(buildPin(p))),
interactionOptions: const InteractionOptions(
flags: ~InteractiveFlag.doubleTapZoom,
Expand All @@ -128,9 +131,9 @@ class _MarkerPageState extends State<MarkerPage> {
openStreetMapTileLayer,
MarkerLayer(
rotate: counterRotate,
markers: const [
Marker(
point: LatLng(47.18664724067855, -1.5436768515939427),
markers: [
const Marker(
point: LatLng(47.18664, -1.54367),
width: 64,
height: 64,
alignment: Alignment.centerLeft,
Expand All @@ -142,8 +145,8 @@ class _MarkerPageState extends State<MarkerPage> {
),
),
),
Marker(
point: LatLng(47.18664724067855, -1.5436768515939427),
const Marker(
point: LatLng(47.18664, -1.54367),
width: 64,
height: 64,
alignment: Alignment.centerRight,
Expand All @@ -155,11 +158,30 @@ class _MarkerPageState extends State<MarkerPage> {
),
),
),
Marker(
point: LatLng(47.18664724067855, -1.5436768515939427),
const Marker(
point: LatLng(47.18664, -1.54367),
rotate: false,
child: ColoredBox(color: Colors.black),
),
Marker(
point: const LatLng(
51.51868,
-0.12835,
),
height: 1000,
width: 1000,
useDimensionsInMeters: const BoxConstraints(
minHeight: 30,
minWidth: 30,
),
child: SizedBox.expand(
child: LayoutBuilder(
builder: (context, constraints) => DecoratedBox(
decoration: BoxDecoration(border: BoxBorder.all()),
),
),
),
),
],
),
MarkerLayer(
Expand Down
37 changes: 30 additions & 7 deletions lib/src/layer/marker_layer/marker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,45 @@ class Marker {
/// This key will get passed through to the created marker widget.
final Key? key;

/// Coordinates of the marker
/// Coordinates of the marker.
///
/// This will be the center of the marker, assuming that [alignment] is
/// [Alignment.center] (default).
final LatLng point;

/// Widget tree of the marker, sized by [width] & [height]
/// Widget tree of the marker, sized by [width] & [height].
///
/// The [Marker] itself is not a widget.
final Widget child;

/// Width of [child]
/// Width of child, in pixels (unless [useDimensionsInMeters] is set).
final double width;

/// Height of [child]
/// Width of child, in pixels (unless [useDimensionsInMeters] is set).
final double height;

/// Alignment of the marker relative to the normal center at [point]
/// Whether to treat [width] and [height] as meters, with optional pixel size
/// constraints.
///
/// If `null` (as default), [width] and [height] are specified in pixels.
///
/// If set to [BoxConstraints], [width] and [height] are specified in meters.
/// They will constrain size of the marker on the screen in pixels. The
/// constraints must have finite minimum dimensions.
///
/// Set an empty [BoxConstraints] to display the marker as its true
/// geographical size (without constraints):
///
/// ```dart
/// useDimensionsInMeters: const BoxConstraints(),
/// ```
///
/// When using geographical sizing, the child can use [SizedBox.expand] to
/// expand itself to the available size. [LayoutBuilder] can be used to obtain
/// its calculated screen size, if necessary.
final BoxConstraints? useDimensionsInMeters;

/// Alignment of the marker relative to the normal center at [point].
///
/// For example, [Alignment.topCenter] will mean the entire marker widget is
/// located above the [point].
Expand All @@ -39,7 +60,7 @@ class Marker {
final Alignment? alignment;

/// Whether to counter rotate this marker to the map's rotation, to keep a
/// fixed orientation
/// fixed orientation.
///
/// When `true`, this marker will always appear upright and vertical from the
/// user's perspective. Defaults to `false` if also unset by [MarkerLayer].
Expand All @@ -59,11 +80,13 @@ class Marker {
required this.child,
this.width = 30,
this.height = 30,
this.useDimensionsInMeters,
this.alignment,
this.rotate,
});

/// Returns the alignment of a [width]x[height] rectangle by [left]x[top] pixels.
/// Returns the alignment of a [width]x[height] rectangle by [left]x[top]
/// pixels.
static Alignment computePixelAlignment({
required final double width,
required final double height,
Expand Down
108 changes: 90 additions & 18 deletions lib/src/layer/marker_layer/marker_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,43 @@ class MarkerLayer extends StatefulWidget {
/// markers. Use a widget inside [Marker.child] to perform this.
final bool rotate;

/// Whether to use a single meters to pixels conversion ratio for all markers
/// with [Marker.useDimensionsInMeters] enabled.
///
/// > [!IMPORTANT]
/// > This reduces the accuracy of the dimensions of markers. Depending on the
/// > location of the markers, this may or may not be significant.
///
/// Where all markers within this layer are geographically (particularly
/// latitudinally) close, the difference in the ratio between pixels and
/// meters between markers is likely to be small. Calculating this conversion
/// ratio is expensive, and is usually done for every marker to ensure
/// accuracy, as the ratio depends on the latitude. Setting this `true` means
/// the ratio is calculated based off the first marker only, then reused for
/// all other markers within this layer.
///
/// This should not be used where markers are geographically spread out - it
/// is best suited, for example, for markers located within a single city.
///
/// Defaults to `false`.
final bool optimizeDimensionsInMeters;

/// Create a new [MarkerLayer] to use inside of [FlutterMap.children].
const MarkerLayer({
super.key,
required this.markers,
this.alignment = Alignment.center,
this.rotate = false,
this.optimizeDimensionsInMeters = false,
});

@override
State<MarkerLayer> createState() => _MarkerLayerState();
}

class _MarkerLayerState extends State<MarkerLayer> {
static const _distance = Distance();

/// Projected (zoom-independent) coordinates of every [Marker.point], in the
/// same order as the markers list
///
Expand All @@ -54,6 +78,9 @@ class _MarkerLayerState extends State<MarkerLayer> {
List<Offset>? _projectedPoints;
Crs? _projectionCrs;

// Cached number of pixels per meter.
double? _pixelsPerMeter;

@override
void didUpdateWidget(MarkerLayer oldWidget) {
super.didUpdateWidget(oldWidget);
Expand Down Expand Up @@ -81,6 +108,50 @@ class _MarkerLayerState extends State<MarkerLayer> {
);
}

(double, double) _getDimensionsInPixels(Marker marker) {
final constraints = marker.useDimensionsInMeters;
if (constraints == null) return (marker.width, marker.height);

final camera = MapCamera.of(context);

(double, double) metersToScreenPixels() {
final baseOffset = camera.getOffsetFromOrigin(marker.point);
return (
(baseOffset -
camera.getOffsetFromOrigin(
_distance.offset(marker.point, marker.width / 2, 180)))
.distance *
2,
(baseOffset -
camera.getOffsetFromOrigin(
_distance.offset(marker.point, marker.height / 2, 180)))
.distance *
2
);
}

double width;
double height;
if (!widget.optimizeDimensionsInMeters) {
// If not optimizing, then we need to calculate this for every marker...
(width, height) = metersToScreenPixels();
} else {
// ...otherwise we use the cached ratio if available, or calculate it
// (using the first marker in the layer, given how this method is called)
_pixelsPerMeter ??= metersToScreenPixels().$1 / marker.width;
width = _pixelsPerMeter! * marker.width;
height = _pixelsPerMeter! * marker.height;
}

if (!constraints.minWidth.isFinite || !constraints.minHeight.isFinite) {
throw RangeError('`Marker.useSizeInMeters` must have finite minimums');
}
return (
constraints.constrainWidth(width),
constraints.constrainHeight(height)
);
}

@override
Widget build(BuildContext context) {
final map = MapCamera.of(context);
Expand All @@ -94,36 +165,37 @@ class _MarkerLayerState extends State<MarkerLayer> {

final worldWidth = map.getWorldWidthAtZoom();
final zoomScale = crs.scale(map.zoom);
final pixelBounds = map.pixelBounds;
final pixelOrigin = map.pixelOrigin;
final markers = widget.markers;

return MobileLayerTransformer(
child: Stack(
children: () sync* {
for (var i = 0; i < markers.length; i++) {
final m = markers[i];

// Resolve real alignment
// TODO: maybe just using Size, Offset, and Rect?
final left =
0.5 * m.width * ((m.alignment ?? widget.alignment).x + 1);
final top =
0.5 * m.height * ((m.alignment ?? widget.alignment).y + 1);
final right = m.width - left;
final bottom = m.height - top;
for (var i = 0; i < widget.markers.length; i++) {
final m = widget.markers[i];

// Scale the cached projection to the current zoom
final projected = projectedPoints[i];
final (px, py) =
crs.transform(projected.dx, projected.dy, zoomScale);
final pxPoint = Offset(px, py);

// Get marker dimensions
final double width;
final double height;
(width, height) = _getDimensionsInPixels(m);

// Resolve real alignment
final left =
0.5 * width * ((m.alignment ?? widget.alignment).x + 1);
final top =
0.5 * height * ((m.alignment ?? widget.alignment).y + 1);
final right = width - left;
final bottom = height - top;

Positioned? getPositioned(double worldShift) {
final shiftedX = pxPoint.dx + worldShift;

// Cull if out of bounds
if (!pixelBounds.overlaps(
if (!map.pixelBounds.overlaps(
Rect.fromPoints(
Offset(shiftedX + left, pxPoint.dy - bottom),
Offset(shiftedX - right, pxPoint.dy + top),
Expand All @@ -135,12 +207,12 @@ class _MarkerLayerState extends State<MarkerLayer> {
// Shift original coordinate along worlds, then move into relative
// to origin space
final shiftedLocalPoint =
Offset(shiftedX, pxPoint.dy) - pixelOrigin;
Offset(shiftedX, pxPoint.dy) - map.pixelOrigin;

return Positioned(
key: m.key,
width: m.width,
height: m.height,
width: width,
height: height,
left: shiftedLocalPoint.dx - right,
top: shiftedLocalPoint.dy - bottom,
child: (m.rotate ?? widget.rotate)
Expand Down
Loading