614 lines
19 KiB
Dart
614 lines
19 KiB
Dart
// Copyright 2018 The Chromium Authors. All rights reserved.
|
||
// Use of this source code is governed by a BSD-style license that can be
|
||
// found in the LICENSE file.
|
||
import 'dart:math' as Math;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:maplibre/maplibre.dart';
|
||
import 'package:maplibre_gl/maplibre_gl.dart' as gl;
|
||
import '../ecs/system.dart';
|
||
|
||
abstract class ExamplePage extends StatelessWidget {
|
||
const ExamplePage(
|
||
this.leading,
|
||
this.title, {
|
||
this.needsLocationPermission = true,
|
||
super.key,
|
||
});
|
||
|
||
final Widget leading;
|
||
final String title;
|
||
final bool needsLocationPermission;
|
||
}
|
||
|
||
|
||
class MapUiPage extends ExamplePage {
|
||
const MapUiPage({super.key}) : super(const Icon(Icons.map), '态势');
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return const MapUiBody();
|
||
}
|
||
}
|
||
|
||
class MapUiBody extends StatefulWidget {
|
||
const MapUiBody({super.key});
|
||
|
||
@override
|
||
State<MapUiBody> createState() => MapUiBodyState();
|
||
|
||
/// 静态方法,用于通过 GlobalKey 获取 MapUiBodyState 实例
|
||
static MapUiBodyState? of(BuildContext context) {
|
||
final mapUiBody = context.findAncestorWidgetOfExactType<MapUiBody>();
|
||
if (mapUiBody?.key is GlobalKey<MapUiBodyState>) {
|
||
final key = mapUiBody?.key as GlobalKey<MapUiBodyState>;
|
||
return key.currentState;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
class MapUiBodyState extends State<MapUiBody> with TickerProviderStateMixin {
|
||
late final MapController _controller;
|
||
final GlobalKey _mapKey = GlobalKey();
|
||
|
||
// MapSystem 实例,用于通过系统调用地图接口
|
||
late final MapSystem mapSystem;
|
||
|
||
final _markerPositions = [
|
||
const Geographic(lon: -10, lat: 0),
|
||
const Geographic(lon: -5, lat: 0),
|
||
const Geographic(lon: 0, lat: 0),
|
||
const Geographic(lon: 5, lat: 0),
|
||
];
|
||
|
||
// 自定义图片标记数据
|
||
final _customMarkers = <Feature<Point>>[];
|
||
bool _customMarkerImageLoaded = false;
|
||
|
||
// 圆形数据
|
||
final _circles = <Feature<Point>>[];
|
||
|
||
// 多边形数据(包括扇形和长方形)
|
||
final _polygons = <Feature<Polygon>>[];
|
||
|
||
Geographic? _originalPosition;
|
||
MapGestures _mapGestures = const MapGestures.all();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// 初始化 MapSystem,并将 this 传递给它
|
||
mapSystem = MapSystem(mapUiBodyState: this);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
//appBar: AppBar(title: const Text('Interactive Widget Layer')),
|
||
body: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Padding(
|
||
padding: EdgeInsets.only(left: 16, bottom: 8),
|
||
|
||
),
|
||
Expanded(
|
||
child: MapLibreMap(
|
||
key: _mapKey,
|
||
options: MapOptions(
|
||
initZoom: 3,
|
||
initCenter: const Geographic(lon: 0, lat: 0),
|
||
initStyle: "assets/osm_style.json",
|
||
gestures: _mapGestures,
|
||
),
|
||
onMapCreated: (controller) => _controller = controller,
|
||
onEvent: (event) async {
|
||
if (event is MapEventStyleLoaded) {
|
||
// 加载自定义标记图片
|
||
await event.style.addImageFromIconData(
|
||
id: 'custom-marker',
|
||
iconData: Icons.location_on,
|
||
color: Colors.blue,
|
||
);
|
||
setState(() {
|
||
_customMarkerImageLoaded = true;
|
||
});
|
||
} else if (event is MapEventLongClick) {
|
||
final position = event.point;
|
||
_markerPositions.add(position);
|
||
|
||
setState(() {});
|
||
}
|
||
},
|
||
layers: [
|
||
// 自定义图片标记层
|
||
MarkerLayer(
|
||
points: _customMarkers.where((marker) =>
|
||
marker.properties['type'] != 'fixed_circle'
|
||
).toList(), // 过滤掉固定圆形
|
||
iconImage: _customMarkerImageLoaded ? 'custom-marker' : null,
|
||
iconSize: 0.15,
|
||
iconAnchor: IconAnchor.bottom,
|
||
),
|
||
// 圆形层
|
||
CircleLayer(
|
||
points: _circles,
|
||
color: Colors.orange.withValues(alpha: 0.5),
|
||
radius: 20,
|
||
strokeColor: Colors.red,
|
||
strokeWidth: 2,
|
||
),
|
||
// 多边形层(扇形、长方形和圆形)
|
||
PolygonLayer(
|
||
polygons: _polygons,
|
||
color: Colors.lightBlueAccent.withValues(alpha: 0.6),
|
||
outlineColor: Colors.blue,
|
||
),
|
||
],
|
||
children: [
|
||
WidgetLayer(
|
||
allowInteraction: true,
|
||
markers: List.generate(
|
||
_markerPositions.length,
|
||
(index) => Marker(
|
||
size: const Size.square(50),
|
||
point: _markerPositions[index],
|
||
child: GestureDetector(
|
||
onTap: () => _onTap(index),
|
||
onLongPressStart: (details) =>
|
||
_onLongPress(index, details),
|
||
onPanStart: (details) => _onLongPanStart(details, index),
|
||
onPanUpdate: (details) async =>
|
||
_onPanUpdate(details, index),
|
||
onPanEnd: (details) async => _onPanEnd(details, index),
|
||
child: const Icon(
|
||
Icons.location_on,
|
||
color: Colors.red,
|
||
size: 50,
|
||
),
|
||
),
|
||
alignment: Alignment.bottomCenter,
|
||
),
|
||
),
|
||
),
|
||
// 添加固定圆形的Widget
|
||
WidgetLayer(
|
||
allowInteraction: false, // 固定圆形不需要交互
|
||
markers: _customMarkers
|
||
.where((marker) => marker.properties['type'] == 'fixed_circle')
|
||
.map((marker) {
|
||
final pointGeometry = marker.geometry as Point;
|
||
// 使用Point的position属性获取坐标
|
||
final geographic = Geographic(
|
||
lon: pointGeometry.position.x,
|
||
lat: pointGeometry.position.y,
|
||
);
|
||
return Marker(
|
||
size: Size.fromRadius(marker.properties['radius']?.toDouble() ?? 20.0),
|
||
point: geographic,
|
||
child: IgnorePointer( // 忽略指针事件,确保不会干扰地图手势
|
||
child: Container(
|
||
width: (marker.properties['radius']?.toDouble() ?? 20.0) * 2,
|
||
height: (marker.properties['radius']?.toDouble() ?? 20.0) * 2,
|
||
decoration: BoxDecoration(
|
||
color: marker.properties['color'] ?? Colors.orange,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: marker.properties['strokeColor'] ?? Colors.red,
|
||
width: marker.properties['strokeWidth']?.toDouble() ?? 2.0,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
alignment: Alignment.center,
|
||
);
|
||
})
|
||
.toList(),
|
||
),
|
||
// display the UI widgets above the widget markers.
|
||
const MapScalebar(),
|
||
const SourceAttribution(),
|
||
const MapControlButtons(),
|
||
const MapCompass(),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<Geographic> _toLngLat(Offset eventOffset) async {
|
||
final mapRenderBox =
|
||
_mapKey.currentContext?.findRenderObject() as RenderBox?;
|
||
|
||
assert(mapRenderBox != null, 'RenderBox of Map should never be null');
|
||
|
||
final mapOffset = mapRenderBox!.localToGlobal(Offset.zero);
|
||
|
||
final offset = Offset(
|
||
eventOffset.dx - mapOffset.dx,
|
||
eventOffset.dy - mapOffset.dy,
|
||
);
|
||
|
||
return _controller.toLngLat(offset);
|
||
}
|
||
|
||
void _onLongPress(int index, LongPressStartDetails details) {
|
||
final offset = details.globalPosition;
|
||
|
||
showMenu(
|
||
context: context,
|
||
position: RelativeRect.fromLTRB(
|
||
offset.dx,
|
||
offset.dy,
|
||
MediaQuery.of(context).size.width - offset.dx,
|
||
MediaQuery.of(context).size.height - offset.dy,
|
||
),
|
||
items: [
|
||
const PopupMenuItem<void>(child: Text('Edit')),
|
||
PopupMenuItem<void>(
|
||
onTap: () async {
|
||
final isConfirmed = await _showConfirmationDialogDelete(index);
|
||
|
||
if (isConfirmed) {
|
||
_markerPositions.removeAt(index);
|
||
|
||
setState(() {});
|
||
}
|
||
},
|
||
child: const Text('Delete'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _onPanEnd(DragEndDetails details, int index) async {
|
||
final isAccepted = await _showConfirmationDialogMove();
|
||
|
||
if (!isAccepted) {
|
||
_markerPositions[index] = _originalPosition!;
|
||
} else {
|
||
final newPosition = await _toLngLat(details.globalPosition);
|
||
_markerPositions[index] = newPosition;
|
||
}
|
||
|
||
_originalPosition = null;
|
||
|
||
setState(() {
|
||
_mapGestures = const MapGestures.all();
|
||
});
|
||
}
|
||
|
||
void _onLongPanStart(DragStartDetails details, int index) {
|
||
// Keep original position in case of discarded move
|
||
_originalPosition = Geographic.from(_markerPositions[index]);
|
||
|
||
setState(() {
|
||
// Disable camera panning while a marker gets moved.
|
||
_mapGestures = const MapGestures.all(pan: false);
|
||
});
|
||
}
|
||
|
||
Future<void> _onPanUpdate(DragUpdateDetails details, int index) async {
|
||
final newPosition = await _toLngLat(details.globalPosition);
|
||
_markerPositions[index] = newPosition;
|
||
|
||
setState(() {});
|
||
}
|
||
|
||
void _onTap(int index) {
|
||
_showMarkerDetails(index);
|
||
}
|
||
|
||
Future<bool> _showConfirmationDialogDelete(int index) async {
|
||
final isConfirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text('Delete marker [$index]?'),
|
||
actions: <Widget>[
|
||
TextButton(
|
||
style: TextButton.styleFrom(
|
||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||
),
|
||
child: const Text('Cancel'),
|
||
onPressed: () {
|
||
Navigator.of(context).pop(false);
|
||
},
|
||
),
|
||
TextButton(
|
||
style: TextButton.styleFrom(
|
||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||
),
|
||
child: const Text('Delete'),
|
||
onPressed: () {
|
||
Navigator.of(context).pop(true);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
return isConfirmed ?? false;
|
||
}
|
||
|
||
Future<bool> _showConfirmationDialogMove() async {
|
||
final isConfirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Accept new position?'),
|
||
actions: <Widget>[
|
||
TextButton(
|
||
style: TextButton.styleFrom(
|
||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||
),
|
||
child: const Text('Discard'),
|
||
onPressed: () {
|
||
Navigator.of(context).pop(false);
|
||
},
|
||
),
|
||
TextButton(
|
||
style: TextButton.styleFrom(
|
||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||
),
|
||
child: const Text('Accept'),
|
||
onPressed: () {
|
||
Navigator.of(context).pop(true);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
return isConfirmed ?? false;
|
||
}
|
||
|
||
Future<void> _showMarkerDetails(int index) async {
|
||
await showDialog<void>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text('Details marker with index: $index'),
|
||
content: Text('Show here the details of Marker with index $index'),
|
||
actions: <Widget>[
|
||
TextButton(
|
||
style: TextButton.styleFrom(
|
||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||
),
|
||
child: const Text('Cancel'),
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
return;
|
||
}
|
||
|
||
// ==================== 公共接口方法 ====================
|
||
|
||
/// 添加自定义图片标记
|
||
///
|
||
/// [position] 标记的地理位置
|
||
/// [properties] 可选的属性数据
|
||
void addCustomMarker(Geographic position, {Map<String, dynamic>? properties}) {
|
||
setState(() {
|
||
_customMarkers.add(Feature(
|
||
geometry: Point(position),
|
||
properties: properties,
|
||
));
|
||
});
|
||
}
|
||
|
||
/// 添加圆形
|
||
///
|
||
/// [position] 圆心的地理位置
|
||
/// [radius] 圆的半径(单位:像素)
|
||
/// [color] 填充颜色
|
||
/// [strokeColor] 边框颜色
|
||
/// [strokeWidth] 边框宽度
|
||
void addCircle(
|
||
Geographic position, {
|
||
double radius = 20,
|
||
Color color = Colors.orange,
|
||
Color strokeColor = Colors.red,
|
||
double strokeWidth = 2,
|
||
}) {
|
||
setState(() {
|
||
_circles.add(Feature(
|
||
geometry: Point(position),
|
||
properties: {
|
||
'radius': radius,
|
||
'color': color,
|
||
'strokeColor': strokeColor,
|
||
'strokeWidth': strokeWidth,
|
||
},
|
||
));
|
||
});
|
||
}
|
||
|
||
/// 添加固定大小的圆形(不随地图缩放)
|
||
///
|
||
/// [center] 圆心的地理位置
|
||
/// [radius] 圆的半径(单位:米)
|
||
/// [color] 填充颜色
|
||
/// [outlineColor] 边框颜色
|
||
void addFixedCircle(
|
||
Geographic center, {
|
||
required double radius,
|
||
Color color = Colors.orange,
|
||
Color outlineColor = Colors.blue,
|
||
}) {
|
||
// 将半径从米转换为经纬度(度)
|
||
final radiusInDegrees = _metersToDegrees(center, radius);
|
||
|
||
final points = <Geographic>[];
|
||
|
||
// 计算圆周上的点(360度)
|
||
for (int i = 0; i <= 36; i++) { // 使用36个点来构成圆形
|
||
final angle = (360 * i / 36) * 3.14159265359 / 180; // 角度转弧度
|
||
|
||
// 将角度转换为经纬度偏移
|
||
final latOffset = radiusInDegrees * Math.sin(angle);
|
||
final lonOffset = radiusInDegrees * Math.cos(angle);
|
||
|
||
points.add(Geographic(
|
||
lon: center.lon + lonOffset,
|
||
lat: center.lat + latOffset,
|
||
));
|
||
}
|
||
|
||
setState(() {
|
||
_polygons.add(Feature(
|
||
geometry: Polygon.from([points]),
|
||
properties: {
|
||
'color': color,
|
||
'outlineColor': outlineColor,
|
||
'type': 'fixed_circle_geo', // 标记为地理圆形
|
||
},
|
||
));
|
||
});
|
||
}
|
||
|
||
/// 添加扇形
|
||
///
|
||
/// [center] 扇形中心的地理位置
|
||
/// [radius] 扇形的半径(单位:米)
|
||
/// [startAngle] 起始角度(单位:度,0度表示正东方向)
|
||
/// [endAngle] 结束角度(单位:度)
|
||
/// [segments] 扇形的分段数,值越大越平滑
|
||
/// [color] 填充颜色
|
||
/// [outlineColor] 边框颜色
|
||
void addSector(
|
||
Geographic center, {
|
||
required double radius,
|
||
required double startAngle,
|
||
required double endAngle,
|
||
int segments = 36,
|
||
Color color = Colors.lightBlueAccent,
|
||
Color outlineColor = Colors.blue,
|
||
}) {
|
||
// 将半径从米转换为经纬度(度)
|
||
final radiusInDegrees = _metersToDegrees(center, radius);
|
||
|
||
final points = <Geographic>[];
|
||
|
||
// 添加中心点
|
||
points.add(center);
|
||
|
||
// 计算扇形的弧线上的点
|
||
for (int i = 0; i <= segments; i++) {
|
||
final angle = startAngle + (endAngle - startAngle) * i / segments;
|
||
final angleRad = angle * 3.14159265359 / 180;
|
||
|
||
// 将角度转换为经纬度偏移
|
||
final latOffset = radiusInDegrees * Math.sin(angleRad);
|
||
final lonOffset = radiusInDegrees * Math.cos(angleRad);
|
||
|
||
points.add(Geographic(
|
||
lon: center.lon + lonOffset,
|
||
lat: center.lat + latOffset,
|
||
));
|
||
}
|
||
|
||
setState(() {
|
||
_polygons.add(Feature(
|
||
geometry: Polygon.from([points]),
|
||
properties: {
|
||
'color': color,
|
||
'outlineColor': outlineColor,
|
||
},
|
||
));
|
||
});
|
||
}
|
||
|
||
/// 添加长方形
|
||
///
|
||
/// [center] 长方形中心的地理位置
|
||
/// [width] 长方形的宽度(单位:米)
|
||
/// [height] 长方形的高度(单位:米)
|
||
/// [color] 填充颜色
|
||
/// [outlineColor] 边框颜色
|
||
void addRectangle(
|
||
Geographic center, {
|
||
required double width,
|
||
required double height,
|
||
Color color = Colors.lightBlueAccent,
|
||
Color outlineColor = Colors.blue,
|
||
}) {
|
||
// 将宽高从米转换为经纬度(度)
|
||
final widthInDegrees = _metersToDegrees(center, width / 2);
|
||
final heightInDegrees = _metersToDegrees(center, height / 2);
|
||
|
||
final points = [
|
||
Geographic(lon: center.lon - widthInDegrees, lat: center.lat - heightInDegrees),
|
||
Geographic(lon: center.lon + widthInDegrees, lat: center.lat - heightInDegrees),
|
||
Geographic(lon: center.lon + widthInDegrees, lat: center.lat + heightInDegrees),
|
||
Geographic(lon: center.lon - widthInDegrees, lat: center.lat + heightInDegrees),
|
||
Geographic(lon: center.lon - widthInDegrees, lat: center.lat - heightInDegrees), // 闭合多边形
|
||
];
|
||
|
||
setState(() {
|
||
_polygons.add(Feature(
|
||
geometry: Polygon.from([points]),
|
||
properties: {
|
||
'color': color,
|
||
'outlineColor': outlineColor,
|
||
},
|
||
));
|
||
});
|
||
}
|
||
|
||
/// 将距离(米)转换为经纬度(度)
|
||
/// 此方法使用近似计算,适用于小范围区域
|
||
double _metersToDegrees(Geographic center, double meters) {
|
||
// 地球半径,约为6378137米
|
||
const earthRadius = 6378137.0;
|
||
|
||
// 将米转换为度
|
||
// 1度大约等于111320米(在赤道附近)
|
||
// 但需要考虑纬度的影响,因为经度的距离会随纬度变化
|
||
final latCircumference = 2 * Math.pi * earthRadius; // 纬线周长
|
||
final latFactor = meters / latCircumference * 360; // 纬度方向转换因子
|
||
|
||
// 经度方向需要考虑纬度的影响
|
||
final lonCircumference = 2 * Math.pi * earthRadius * Math.cos(center.lat * Math.pi / 180); // 经线周长
|
||
final lonFactor = meters / lonCircumference * 360; // 经度方向转换因子
|
||
|
||
// 返回平均值,对于小范围区域,这个近似是足够的
|
||
return (latFactor + lonFactor) / 2;
|
||
}
|
||
|
||
/// 清除所有自定义标记
|
||
void clearCustomMarkers() {
|
||
setState(() {
|
||
_customMarkers.clear();
|
||
});
|
||
}
|
||
|
||
/// 清除所有圆形
|
||
void clearCircles() {
|
||
setState(() {
|
||
_circles.clear();
|
||
});
|
||
}
|
||
|
||
/// 清除所有多边形(包括扇形和长方形)
|
||
void clearPolygons() {
|
||
setState(() {
|
||
_polygons.clear();
|
||
});
|
||
}
|
||
|
||
/// 清除所有自定义图层
|
||
void clearAllCustomLayers() {
|
||
setState(() {
|
||
_customMarkers.clear();
|
||
_circles.clear();
|
||
_polygons.clear();
|
||
});
|
||
}
|
||
}
|
||
|