Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d590be2
feat: parameters to use marker in meters
LeonTenorio Jun 3, 2025
3bc637f
refactor: change positioned left, top, bottom and right variables cre…
LeonTenorio Jun 3, 2025
f1f8394
feat: width and height according the using meters implementation
LeonTenorio Jun 3, 2025
5f88ba8
feat: marker using size in meters example
LeonTenorio Jun 3, 2025
1d07e36
Merge branch 'feat/marker-in-meters'
LeonTenorio Jun 3, 2025
dbb1f99
feat: documentation of the new parameters
LeonTenorio Jun 3, 2025
5dd1c86
Merge branch 'master' into master
JaffaKetchup Jun 8, 2025
35e1f11
refactor: marker parameters to control the min and max height and wid…
LeonTenorio Jun 9, 2025
dd95b47
Merge remote-tracking branch 'origin/master'
LeonTenorio Jun 9, 2025
4a4f0b4
Merge branch 'master' into master
JaffaKetchup Apr 13, 2026
d24f8e1
Improved implementation and naming of marker meter sizing
JaffaKetchup Jun 30, 2026
481ea05
Collapse `useDimensionsInMeters` & `meterToPixelSizeConstraints` into…
JaffaKetchup Jun 30, 2026
89f95bd
Merge branch 'master' into master
JaffaKetchup Jul 1, 2026
d86b9ba
Fix support for Flutter 3.27
JaffaKetchup Jul 1, 2026
f2fe8f0
Minor fixes to workflows
JaffaKetchup Jul 1, 2026
d265978
Bump Windows workflow 3.29 matrix verison to 3.32 to attempt to resol…
JaffaKetchup Jul 1, 2026
4f0c90d
Revert changes to initial position on markers demo page
JaffaKetchup Jul 1, 2026
c8ca512
Minor typo fix
JaffaKetchup Jul 1, 2026
aab420e
Bump Windows workflow 3.32 matrix verison to 3.39 to attempt to resol…
JaffaKetchup Jul 1, 2026
5c060c0
Bump Windows workflow 3.39 matrix verison to 3.41 to attempt to resol…
JaffaKetchup Jul 1, 2026
44922d8
Minor documentation improvements
JaffaKetchup Jul 2, 2026
c587001
Improve efficiency by using cached projected marker points during met…
JaffaKetchup Jul 2, 2026
ddcdff4
Simplified internal calculations
JaffaKetchup Jul 2, 2026
42e95bf
Fixed bug
JaffaKetchup Jul 2, 2026
34fcd53
Revert inaccurate documentation fix
JaffaKetchup Jul 2, 2026
270896b
Cache projections used to calculate meter sizes
JaffaKetchup Jul 2, 2026
45ae43b
Minor performance improvement
JaffaKetchup Jul 2, 2026
8f5b33e
Discard changes to lib/src/layer/circle_layer/circle_layer.dart
JaffaKetchup Jul 2, 2026
21dfdb2
Merge branch 'master' into master
JaffaKetchup Jul 2, 2026
7fce640
Minor performance improvements
JaffaKetchup Jul 3, 2026
832a9f9
Fixed performance bug
JaffaKetchup Jul 8, 2026
ef11960
Improved example application
JaffaKetchup Jul 8, 2026
30b04dd
Use `HashMap` over `LinkedHashMap` to store projected meter cache
JaffaKetchup Jul 8, 2026
2de7cb8
Fix bug where a defined key on `Marker` would break across multiple w…
JaffaKetchup Jul 9, 2026
145fa7d
Revert previous commit
JaffaKetchup Jul 9, 2026
3fdb4fb
Minor fix to example app
JaffaKetchup Jul 9, 2026
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