diff --git a/lib/screens/home.dart b/lib/screens/home.dart index 2431201e..3e430c3b 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -141,10 +141,7 @@ class HomeScreen extends View with PromptHandler { viewModel.uploadQueue.length; return OsmElementLayer( elements: viewModel.elements, - currentZoom: viewModel.mapZoomRound, - onOsmElementTap: viewModel.onElementTap, - selectedElement: viewModel.selectedElement, - uploadQueue: viewModel.uploadQueue, + // onSelect: viewModel.onElementTap, ); }, ), diff --git a/lib/widgets/completed_area_layer/completed_area_layer.dart b/lib/widgets/completed_area_layer/completed_area_layer.dart index 4294db69..0af1cb42 100644 --- a/lib/widgets/completed_area_layer/completed_area_layer.dart +++ b/lib/widgets/completed_area_layer/completed_area_layer.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart' hide Path; -import '/widgets/loading_area_layer/map_layer.dart'; +import '../map_layer.dart'; import '/widgets/animated_path.dart'; diff --git a/lib/widgets/loading_area_layer/loading_area_layer.dart b/lib/widgets/loading_area_layer/loading_area_layer.dart index a55b120e..bace82c5 100644 --- a/lib/widgets/loading_area_layer/loading_area_layer.dart +++ b/lib/widgets/loading_area_layer/loading_area_layer.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import 'ripple_indicator.dart'; -import 'map_layer.dart'; +import '../map_layer.dart'; /// Layer to show ripple animations for geo circles and automatically remove them when expired. diff --git a/lib/widgets/loading_area_layer/map_layer.dart b/lib/widgets/map_layer.dart similarity index 81% rename from lib/widgets/loading_area_layer/map_layer.dart rename to lib/widgets/map_layer.dart index 8ba37063..e7dc588c 100644 --- a/lib/widgets/loading_area_layer/map_layer.dart +++ b/lib/widgets/map_layer.dart @@ -1,7 +1,9 @@ import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -197,6 +199,8 @@ class RenderMapLayer extends RenderBox mapCamera.nonRotatedSize.y, ); + final occupancyList = []; + var child = firstChild; while (child != null) { final childParentData = child.parentData! as _MapLayerParentData; @@ -205,11 +209,42 @@ class RenderMapLayer extends RenderBox // only render child if bounds are inside the viewport if (viewport.overlaps(childRect)) { context.paintChild(child, relativePixelPosition + offset); + + if (childParentData.collider != null) { + final pos = _computeRelativeOffset(childParentData.offset & childParentData.collider!, childParentData.align!); + + _handleCollision(pos, childParentData.collider!, occupancyList); + } } child = childParentData.nextSibling; } } + Offset _computeRelativeOffset(Rect rect, Alignment align) { + var globalPixelPosition = rect.topLeft; + // apply rotation + if (mapCamera.rotation != 0.0) { + globalPixelPosition = mapCamera.rotatePoint( + _pixelMapCenter, + globalPixelPosition.toPoint(), + counterRotation: false, + ).toOffset(); + } + // apply alignment + return globalPixelPosition - align.alongSize(rect.size) - _nonRotatedPixelOrigin; + } + + void _handleCollision(Offset offset, BoxCollider collider, List occupancyList) { + final rect = offset & collider; + if (occupancyList.any((box) => box.overlaps(rect))) { + collider.reportCollision(true, postFrame: true); + } + else { + occupancyList.add(rect); + collider.reportCollision(false, postFrame: true); + } + } + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -232,11 +267,14 @@ class MapLayerPositioned extends ParentDataWidget<_MapLayerParentData> { final Alignment align; + final BoxCollider? collider; + const MapLayerPositioned({ required this.position, required super.child, this.align = Alignment.center, this.size, + this.collider, super.key, }); @@ -263,6 +301,11 @@ class MapLayerPositioned extends ParentDataWidget<_MapLayerParentData> { parentData.align = align; targetParent.markNeedsPaint(); } + + if (parentData.collider != collider) { + parentData.collider = collider; + targetParent.markNeedsPaint(); + } } @override @@ -283,4 +326,41 @@ class _MapLayerParentData extends ContainerBoxParentData { Size? size; Alignment? align; + + BoxCollider? collider; +} + + + + + + + + + + + +class BoxCollider extends Size with ChangeNotifier implements ValueListenable { + bool _collision = false; + + BoxCollider(super.width, super.height); + + // ignore: avoid_positional_boolean_parameters + void reportCollision(bool collision, { bool postFrame = false }) { + if (collision != _collision) { + _collision = collision; + + if (postFrame) { + SchedulerBinding.instance.addPostFrameCallback( + (_) => notifyListeners(), + ); + } + else { + notifyListeners(); + } + } + } + + @override + bool get value => _collision; } diff --git a/lib/widgets/osm_element_layer/osm_element_layer.dart b/lib/widgets/osm_element_layer/osm_element_layer.dart index a028d635..d603c057 100644 --- a/lib/widgets/osm_element_layer/osm_element_layer.dart +++ b/lib/widgets/osm_element_layer/osm_element_layer.dart @@ -1,295 +1,327 @@ import 'dart:async'; import 'dart:math'; -import 'package:animated_marker_layer/animated_marker_layer.dart'; import 'package:flutter/material.dart'; -import 'package:supercluster/supercluster.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:mobx/mobx.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import '/models/map_features/map_feature_representation.dart'; -import '/api/app_worker/element_handler.dart'; -import '/widgets/osm_element_layer/osm_element_marker.dart'; +import '../../api/app_worker/element_handler.dart'; +import '../../models/map_features/map_feature_representation.dart'; +import '../map_layer.dart'; +import 'osm_element_marker.dart'; + + +class MapFeatureState extends MapFeatureRepresentation { + final _active = Observable(false); + bool get active => _active.value; + set active(bool value) => _active.value = value; + + final _uploadState = Observable?>(null); + Future? get uploadState => _uploadState.value; + set uploadState(Future? value) => _uploadState.value = value; + + final void Function(MapFeatureState)? onTap; + final BoxCollider collider = BoxCollider(60, 60); + + MapFeatureState.fromRepresentation({ + required MapFeatureRepresentation representation, + this.onTap, + }) : super( + id: representation.id, + type: representation.type, + geometry: representation.geometry, + icon: representation.icon, + label: (_, __) => 'TODO', + tags: const {}, + ); + +} -class OsmElementLayer extends StatefulWidget { - final Stream elements; - final MapFeatureRepresentation? selectedElement; - final void Function(MapFeatureRepresentation osmElement)? onOsmElementTap; - final Map uploadQueue; - /// The maximum shift in duration between different markers. +// Problem: +// - Minimized marker overlaps non minimized markers +// only solution is to render them on a totally separate layer OR can sort them? Perhaps I can give a priority property or so +// - instable dadurch, dass ich am rand welche ausblende wird plötzlich platz für andere marker +// - instable durch zoomen, weil plötzlich platz für einen vorhergehenden marker wird wodurch allerdings der darauffolgende keinen mehr hat () +// - "on add" rendert zunächst element, collision detection hat erst einfluss im nächsten frame +// - MapLayerPositioned align will be different (bottom vs center) - final Duration durationOffsetRange; +// Solution: +// - Minimized marker Overlapp: Second layer mit transform target + follower und minimized marker follown dann ihrem marker? - /// The current zoom level. +// sort markers based on distance to one starting marker? - final num currentZoom; - /// The lowest zoom level on which the layer is still visible. +class OsmElementLayer extends StatefulWidget { + + final Stream elements; + + final void Function(MapFeatureRepresentation? element)? onSelect; final int zoomLowerLimit; const OsmElementLayer({ required this.elements, - required this.currentZoom, - required this.uploadQueue, - this.selectedElement, - this.onOsmElementTap, - this.durationOffsetRange = const Duration(milliseconds: 300), + this.onSelect, // TODO: currently changes to this won't update the super cluster this.zoomLowerLimit = 16, - super.key + super.key, }); @override State createState() => _OsmElementLayerState(); } -class _OsmElementLayerState extends State { - StreamSubscription? _streamSubscription; +class _OsmElementLayerState extends AddRemovalAnimationBase { - late final _superCluster = SuperclusterMutable( - getX: (p) => p.geometry.center.longitude, - getY: (p) => p.geometry.center.latitude, - minZoom: widget.zoomLowerLimit, - maxZoom: 20, - radius: 120, - extent: 512, - nodeSize: 64, - extractClusterData: (customMapPoint) => _ClusterLeafs([customMapPoint]) - ); + late StreamSubscription _elementsSub; @override void initState() { super.initState(); - _streamSubscription = widget.elements.listen(_handleElementChange); + _elementsSub = widget.elements.listen(_handleElementChanges); } @override void didUpdateWidget(covariant OsmElementLayer oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.elements != oldWidget.elements) { - _streamSubscription?.cancel(); - _streamSubscription = widget.elements.listen(_handleElementChange); + _elementsSub.cancel(); + _elementsSub = widget.elements.listen(_handleElementChanges); } } - void _handleElementChange(ElementUpdate change) { - setState(() { - if (change.action == ElementUpdateAction.clear) { - _superCluster.load([]); - } - else if (change.action == ElementUpdateAction.update) { - // _superCluster.containsPoint() will not globally check whether a point - // has already been added. - // So if the point position has been modified it may not find it. - // Therefore use _superCluster.points.contains(). - if (_superCluster.points.contains(change.element!)) { - _superCluster.modifyPointData(change.element!, change.element!); - } - else { - _superCluster.add(change.element!); - } - } - else if (change.action == ElementUpdateAction.remove){ - _superCluster.remove(change.element!); - } - }); + void _handleElementChanges(ElementUpdate change) { + if (change.action == ElementUpdateAction.clear) { + } + else if (change.action == ElementUpdateAction.update) { + print("add"); + + // TODO: what happens on update? / duplicates? + add(MapFeatureState.fromRepresentation( + onTap: (element) { + runInAction(() => element.active = !element.active); + widget.onSelect?.call(element.active + ? element + : null, + ); + }, + representation: change.element! + ), duration: Duration(seconds: 1)); + } + else if (change.action == ElementUpdateAction.remove) { + remove(MapFeatureState.fromRepresentation( + representation: change.element! + ), duration: Duration(seconds: 1)); + } } + + AppLocalizations get appLocale => AppLocalizations.of(context)!; + @override - void dispose() { - _streamSubscription?.cancel(); - super.dispose(); + Widget build(BuildContext context) { + return MapLayer( + children: getChildren(context).toList(growable: false), + ); } @override - Widget build(BuildContext context) { - final visibleMarkers = []; - final suppressedMarkers = []; - - if (widget.currentZoom >= widget.zoomLowerLimit) { - final clusters = _superCluster.search(-180, -85, 180, 85, widget.currentZoom.toInt()); - var activeMarkerFound = false; - - for (final cluster in clusters) { - // get elements from cluster - final elements = _elementsFromCluster(cluster).iterator; - - if (widget.selectedElement != null) { - while(!activeMarkerFound && elements.moveNext()) { - if (widget.selectedElement == elements.current) { - visibleMarkers.add( - _createMarker(elements.current) - ); - activeMarkerFound = true; - } - else { - suppressedMarkers.add(_createMinimizedMarker(elements.current)); - } - } - while(elements.moveNext()) { - suppressedMarkers.add(_createMinimizedMarker(elements.current)); - } - } - else { - // loop over elements so that only the first one is a marker and not a placeholder - if (elements.moveNext()) { - visibleMarkers.add( - _createMarker(elements.current), - ); - while(elements.moveNext()) { - suppressedMarkers.add( - _createMinimizedMarker(elements.current) - ); - } - } - } - } - } + Widget itemBuilder(context, item, animation) { + animation = CurvedAnimation( + parent: animation, + curve: Curves.elasticOut, + reverseCurve: Curves.easeOutBack, + ); - // hide layer when zooming out passing the lower zoom limit - return AnimatedSwitcher( - // instantly show the animated marker layer since the markers themselves will be animated in - duration: Duration.zero, - reverseDuration: const Duration(milliseconds: 300), - child: widget.currentZoom >= widget.zoomLowerLimit - ? Stack( - children: [ - AnimatedMarkerLayer( - markers: suppressedMarkers, - ), - AnimatedMarkerLayer( - markers: visibleMarkers, - ), - ], - ) - : null + return MapLayerPositioned( + key: ValueKey(item), + position: item.geometry.center, + align: Alignment.bottomCenter, + collider: item.collider, + child: ValueListenableBuilder( + valueListenable: item.collider, + builder: (context, collision, child) { + return ScaleTransition( + scale: animation, + alignment: Alignment.bottomCenter, + filterQuality: FilterQuality.low, + child: AnimatedSwitcher( + duration: Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + if (child is Container) { + return FadeTransition( + opacity: animation, + child: child, + ); + } + else { + return ScaleTransition( + scale: CurvedAnimation( + parent: animation, + curve: Curves.elasticOut, + reverseCurve: Curves.easeOutBack, + ), + alignment: Alignment.bottomCenter, + filterQuality: FilterQuality.low, + child: child, + ); + } + }, + child: collision + ? Container(color: Colors.blue,width: 5, height: 5,) + : child + ) + ); + }, + child: SizedBox( + width: 260, + height: 60, + child: Observer( + builder: (context) { + return OsmElementMarker( + onTap: () => item.uploadState == null + ? item.onTap?.call(item) + : null, + active: item.active, + icon: item.icon, + label: item.elementLabel(appLocale), + uploadState: item.uploadState, + ); + }, + ), + ), + ), ); } - - Iterable _elementsFromCluster(MutableLayerElement cluster) sync* { - if (cluster is MutableLayerCluster) { - yield* (cluster.clusterData as _ClusterLeafs).elements; - } - else if (cluster is MutableLayerPoint) { - yield cluster.originalPoint; - } + @override + bool isItemInViewport(item) { + // TODO: implement isItemInViewport + return true; } + // Duration _getRandomDelay([int? seed]) { + // if (widget.durationOffsetRange.inMicroseconds == 0) { + // return Duration.zero; + // } + // final randomTimeOffset = Random(seed).nextInt(widget.durationOffsetRange.inMicroseconds); + // return Duration(microseconds: randomTimeOffset); + // } +} - Duration _getRandomDelay([int? seed]) { - if (widget.durationOffsetRange.inMicroseconds == 0) { - return Duration.zero; - } - final randomTimeOffset = Random(seed).nextInt(widget.durationOffsetRange.inMicroseconds); - return Duration(microseconds: randomTimeOffset); - } - AnimatedMarker _createMarker(MapFeatureRepresentation element) { - // supply id as seed so we get the same delay for both marker types - final seed = element.id; - return _OsmElementMarker( - element: element, - animateInDelay: _getRandomDelay(seed), - builder: _markerBuilder - ); - } +// think about delay differently - Widget _markerBuilder(BuildContext context, Animation animation, AnimatedMarker marker) { - final appLocale = AppLocalizations.of(context)!; - marker as _OsmElementMarker; - final isActive = widget.selectedElement == marker.element; - final uploadState = widget.uploadQueue[marker.element]; - - return ScaleTransition( - scale: animation, - alignment: Alignment.bottomCenter, - filterQuality: FilterQuality.low, - child: OsmElementMarker( - onTap: () => uploadState == null - ? widget.onOsmElementTap?.call(marker.element) - : null, - active: isActive, - icon: marker.element.icon, - label: marker.element.elementLabel(appLocale), - uploadState: uploadState, - ), - ); - } +// add a queue to which each new element is added and randomly remove them from the queue and add them to the map +// or directly delay and add to map (only problem is an early removal) - AnimatedMarker _createMinimizedMarker(MapFeatureRepresentation element) { - return AnimatedMarker( - // use geo element as key, because osm element equality changes whenever its tags or version change - // while geo elements only compare the OSM element type and id - key: ValueKey(element), - point: element.geometry.center, - size: const Size.fromRadius(4), - animateInCurve: Curves.easeIn, - animateOutCurve: Curves.easeOut, - animateInDuration: const Duration(milliseconds: 300), - animateOutDuration: const Duration(milliseconds: 300), - // supply id as seed so we get the same delay for both marker types - animateOutDelay: _getRandomDelay(element.id), - builder: _minimizedMarkerBuilder - ); - } +// directly Future.delayed().then(removeFromMapCollection + addToMap) <- store each future inside a map collection in case it got removed before it got added - Widget _minimizedMarkerBuilder(BuildContext context, Animation animation, _) { - return FadeTransition( - opacity: animation, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - border: Border.all( - width: 1, - color: Theme.of(context).colorScheme.shadow.withOpacity(0.26) - ) - ) - ) - ); - } -} +// delay has to be implemented in the add/remove together with curves functions via Interval because I need to update the duration -class _OsmElementMarker extends AnimatedMarker { - final MapFeatureRepresentation element; - - _OsmElementMarker({ - required this.element, - required super.builder, - super.animateInDelay, - }) : super( - // use ElementIdentifier as key - // its equality doesn't change when its tags or version changes - key: ValueKey(element), - point: element.geometry.center, - size: const Size(260, 60), - anchor: Alignment.bottomCenter, - animateInCurve: Curves.elasticOut, - animateOutCurve: Curves.easeOutBack, - animateOutDuration: const Duration(milliseconds: 300), - ); -} +abstract class AddRemovalAnimationBase

extends State

with TickerProviderStateMixin { + Widget itemBuilder(BuildContext context, T item, Animation animation); + + final _items = {}; + + static const _kAlwaysStopped = AlwaysStoppedAnimation(1); + + Iterable getChildren(BuildContext context) { + return _items.entries.map((item) => itemBuilder( + context, + item.key, + item.value ?? _kAlwaysStopped, + ), + ); + } + /// doc + /// + /// + /// + void add(T item, { + Duration duration = Duration.zero, + }) { + if (isItemInViewport(item)) { + final AnimationController? controller; + if (duration != Duration.zero) { + controller = AnimationController( + duration: duration, + vsync: this, + ); + controller.forward().then((_) { + controller!.dispose(); + _items[item] = null; + }); + } + else { + controller = null; + } + setState(() { + _items[item] = controller; + }); + } + else { + _items[item] = null; + } + } -class _ClusterLeafs extends ClusterDataBase { - final List elements; + /// doc + /// + /// + /// + void remove(T item, { + Duration duration = Duration.zero, + }) { + if (isItemInViewport(item)) { + if (duration != Duration.zero) { + final controller = _items[item] ?? AnimationController( + duration: duration, + value: 1.0, + vsync: this, + ); + controller.reverse().then((_) { + controller.dispose(); + setState(() { + _items.remove(item); + }); + }); + setState(() { + _items[item] = controller; + }); + } + else { + setState(() { + _items.remove(item); + }); + } + } + else { + _items.remove(item); + } + } - _ClusterLeafs(this.elements); + bool isItemInViewport(T item); @override - _ClusterLeafs combine(_ClusterLeafs other) { - return _ClusterLeafs(elements + other.elements); + void dispose() { + for (final controller in _items.values) { + controller?.dispose(); + } + super.dispose(); } } + +typedef AnimatedMarkerLayerItemBuilder = Widget Function(BuildContext context, T item, Animation animation);