高清地图+无人机实时信息显示异常问题修复
This commit is contained in:
@@ -46,19 +46,19 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
}) async {
|
||||
if (_isDisposed) return;
|
||||
|
||||
// 同一设备(SN 相同):只增加引用计数,共享订阅
|
||||
// 🔥 同一设备(SN 相同):只增加引用计数,不重新订阅
|
||||
if (_listenerCount > 0 &&
|
||||
_deviceSn == deviceSn &&
|
||||
_gatewaySn == gatewaySn &&
|
||||
_subscription != null) {
|
||||
_gatewaySn == gatewaySn) {
|
||||
_listenerCount++;
|
||||
debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount');
|
||||
debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount (设备相同,不重新订阅)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换到不同设备:强制清理旧订阅
|
||||
// 不管引用计数多少,SN 变了就必须先取消旧订阅再订阅新的
|
||||
if (_deviceSn != null || _gatewaySn != null) {
|
||||
// 🔥 切换到不同设备或首次订阅时才清理旧订阅
|
||||
// 重要:只有在设备不同的情况下才调用 stopListening,避免误取消当前订阅
|
||||
final needCleanup = _deviceSn != null || _gatewaySn != null;
|
||||
if (needCleanup) {
|
||||
// 重置引用计数为 1,让 stopListening 能真正取消 MQTT 订阅
|
||||
_listenerCount = 1;
|
||||
await stopListening();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// GeoServer 基地址(公司端)
|
||||
const String kGeoserverBase = 'http://1.95.137.212:7900/geoserver';
|
||||
|
||||
/// 首期苏州区域图层标识
|
||||
const String kSuzhouLayerName = 'workSpace:suzhou';
|
||||
|
||||
/// 苏州图层实际覆盖范围(取自 GetCapabilities,约 250m × 190m 的小地块)
|
||||
final LatLngBounds kSuzhouBounds = LatLngBounds(
|
||||
const LatLng(31.082450793459877, 120.83906484264836),
|
||||
const LatLng(31.084141983135456, 120.84164596446476),
|
||||
);
|
||||
|
||||
/// 场站中心点(真实地块中心)
|
||||
const LatLng kSuzhouCenter = LatLng(31.083296, 120.840355);
|
||||
|
||||
/// 叠加层取瓦片方式开关:
|
||||
/// - false(当前):WMS GetMap 兜底,不依赖 GWC 缓存
|
||||
/// - true:WMTS GetTile(公司补配 GWC EPSG:3857 gridset 并 Seed 后切换)
|
||||
const bool kUseWmtsTile = false;
|
||||
|
||||
/// WMS 取图提供器:
|
||||
/// 实测 GeoServer 对 workSpace:suzhou 只有原生 EPSG:4326 能出图,
|
||||
/// 请求 EPSG:3857(WMS 1.1.1 / 1.3.0 均验证过)返回纯白图。
|
||||
/// 因此这里按瓦片 XYZ 反算经纬度 bbox,以 EPSG:4326 请求 WMS。
|
||||
class GeoWmsProvider extends TileProvider {
|
||||
GeoWmsProvider({this.maxLevel = 21});
|
||||
|
||||
final int maxLevel;
|
||||
|
||||
/// Web 墨卡托地球周长(米)
|
||||
static const double _full = 40075016.685578488;
|
||||
|
||||
/// Web 墨卡托 Y(米)→ 纬度(度)
|
||||
static double _mercatorYToLat(double y) {
|
||||
final t = y / 6378137.0;
|
||||
final sinh = (math.exp(t) - math.exp(-t)) / 2; // dart:math 无 sinh,手动实现
|
||||
return math.atan(sinh) * 180 / math.pi;
|
||||
}
|
||||
|
||||
@override
|
||||
ImageProvider<Object> getImage(
|
||||
TileCoordinates coordinates,
|
||||
TileLayer options,
|
||||
) {
|
||||
if (coordinates.z > maxLevel) {
|
||||
return MemoryImage(TileProvider.transparentImage);
|
||||
}
|
||||
|
||||
final span = _full / math.pow(2, coordinates.z);
|
||||
final westM = -_full / 2 + coordinates.x * span;
|
||||
final eastM = westM + span;
|
||||
final northM = _full / 2 - coordinates.y * span;
|
||||
final southM = northM - span;
|
||||
|
||||
final westLng = westM / _full * 360;
|
||||
final eastLng = eastM / _full * 360;
|
||||
final southLat = _mercatorYToLat(southM);
|
||||
final northLat = _mercatorYToLat(northM);
|
||||
|
||||
// 与图层实际范围不相交的瓦片直接返回透明图,不发无谓请求
|
||||
final intersects = westLng < kSuzhouBounds.east &&
|
||||
eastLng > kSuzhouBounds.west &&
|
||||
southLat < kSuzhouBounds.north &&
|
||||
northLat > kSuzhouBounds.south;
|
||||
if (!intersects) {
|
||||
return MemoryImage(TileProvider.transparentImage);
|
||||
}
|
||||
|
||||
final url = Uri.parse('$kGeoserverBase/wms').replace(
|
||||
queryParameters: {
|
||||
'SERVICE': 'WMS',
|
||||
'REQUEST': 'GetMap',
|
||||
'VERSION': '1.1.1',
|
||||
'LAYERS': kSuzhouLayerName,
|
||||
'BBOX': '${westLng.toStringAsFixed(6)},${southLat.toStringAsFixed(6)},'
|
||||
'${eastLng.toStringAsFixed(6)},${northLat.toStringAsFixed(6)}',
|
||||
'WIDTH': '256',
|
||||
'HEIGHT': '256',
|
||||
'FORMAT': 'image/png',
|
||||
'TRANSPARENT': 'true',
|
||||
'SRS': 'EPSG:4326',
|
||||
'STYLES': '',
|
||||
},
|
||||
);
|
||||
return NetworkImage(url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// GeoServer 苏州无人机高清影像叠加层(WGS84 基准,覆盖在 ESRI 底图上)
|
||||
///
|
||||
/// 地块很小(约 250m),zoom 16 以上才有意义,默认 minZoom = 16
|
||||
class GeoSuzhouOverlay extends StatelessWidget {
|
||||
const GeoSuzhouOverlay({super.key, this.minZoom = 16, this.maxZoom = 21});
|
||||
|
||||
/// 小于该级别不请求瓦片(地块太小,低层级看不见)
|
||||
final double minZoom;
|
||||
|
||||
/// 大于该级别不请求瓦片
|
||||
final double maxZoom;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kUseWmtsTile) {
|
||||
// === WMTS 版:公司把 EPSG:3857 gridset 加入 GWC 并 Seed 后生效 ===
|
||||
return TileLayer(
|
||||
urlTemplate: '$kGeoserverBase/gwc/service/wmts'
|
||||
'?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0'
|
||||
'&LAYER=$kSuzhouLayerName&TILEMATRIXSET=EPSG:3857'
|
||||
'&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png',
|
||||
userAgentPackageName: 'com.example.app',
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
maxNativeZoom: maxZoom.toInt(),
|
||||
tileBounds: kSuzhouBounds,
|
||||
);
|
||||
}
|
||||
// === WMS 版(当前兜底):按原生 EPSG:4326 请求,GeoServer 实时渲染 ===
|
||||
return TileLayer(
|
||||
tileProvider: GeoWmsProvider(maxLevel: maxZoom.toInt()),
|
||||
userAgentPackageName: 'com.example.app',
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
maxNativeZoom: maxZoom.toInt(),
|
||||
tileBounds: kSuzhouBounds,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -156,11 +156,13 @@ class _AmapFlutterMapPageState extends State<AmapFlutterMapPage> {
|
||||
interactiveFlags: InteractiveFlag.all,
|
||||
),
|
||||
children: [
|
||||
// ESRI World Imagery(WGS84 坐标系,全球高清卫星图)
|
||||
// 替代高德地图(GCJ02),统一坐标系,无需坐标转换
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
"https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}&key=$amapKey",
|
||||
subdomains: const ['1', '2', '3', '4'],
|
||||
tileProvider: NetworkTileProvider(),
|
||||
urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
userAgentPackageName: 'com.example.app',
|
||||
minZoom: 0,
|
||||
maxZoom: 19,
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -48,6 +49,7 @@ import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/trace
|
||||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/CenterLocation.dart';
|
||||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/HeadingPointer.dart';
|
||||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/dji_tile_layer.dart';
|
||||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart';
|
||||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/obsToast.dart';
|
||||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/startpoint_area.dart';
|
||||
import 'package:image/image.dart' as _image; // 注意命名空间冲突,使用 as img
|
||||
@@ -490,18 +492,15 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
// 2. 关键:同步机器人模式坐标(保存时边框不丢失)
|
||||
// ⚠️ 地图已切换为 WGS84,不再需要坐标转换
|
||||
if (_currentRobotMode == RobotMode.point && _robotModeWgsPoints.isEmpty) {
|
||||
_robotModeWgsPoints = _markedPoints.map((p) {
|
||||
return gcj02ToWgs84(p.latitude, p.longitude);
|
||||
}).toList();
|
||||
_robotModeWgsPoints = List.from(_markedPoints); // 直接使用 WGS84 坐标
|
||||
}
|
||||
// 兜底:Robot模式下_robotModeWgsPoints缓存未写入时,从_markedPoints恢复
|
||||
if (_currentRobotMode == RobotMode.robot &&
|
||||
_robotModeWgsPoints.isEmpty &&
|
||||
_markedPoints.isNotEmpty) {
|
||||
_robotModeWgsPoints = _markedPoints.map((p) {
|
||||
return gcj02ToWgs84(p.latitude, p.longitude);
|
||||
}).toList();
|
||||
_robotModeWgsPoints = List.from(_markedPoints); // 直接使用 WGS84 坐标
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
@@ -931,7 +930,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
.toList();
|
||||
}
|
||||
return _markedPoints.map((point) {
|
||||
final wgs84 = gcj02ToWgs84(point.latitude, point.longitude);
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
final wgs84 = LatLng(point.latitude, point.longitude);
|
||||
return {'lng': wgs84.longitude, 'lat': wgs84.latitude};
|
||||
}).toList();
|
||||
}
|
||||
@@ -944,14 +944,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
'outer': _currentWorkMode == WorkMode.custom
|
||||
? [] // 自定义模式 outer 为空
|
||||
: _markedPointsToWgs(), // 弓字模式 outer 为打点坐标
|
||||
// 🔥 对齐 Web 端:弓字模式 path=API返回,自定义模式 path=打点
|
||||
// 旧逻辑:自定义模式 path 为空数组
|
||||
'path': _currentWorkMode == WorkMode.custom
|
||||
? _markedPointsToWgs() // 自定义模式 path 为打点坐标
|
||||
: typedPathList // 弓字模式 path 为 API 返回的路径
|
||||
.map((point) => {'lng': point.longitude, 'lat': point.latitude})
|
||||
.toList(),
|
||||
|
||||
// 🔥 统一使用用户手动打点的边界点,不再上传算法清扫轨迹点
|
||||
'path': _markedPointsToWgs(),
|
||||
'planModel': _currentWorkMode == WorkMode.bow ? 0 : 2, // 作业模式值
|
||||
};
|
||||
|
||||
@@ -1425,10 +1419,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
// 保存当前障碍物组(Robot模式下直接用WGS84坐标)
|
||||
if (_currentRobotMode == RobotMode.robot &&
|
||||
_robotModeObsWgsPoints.isNotEmpty) {
|
||||
final gcjPoints = _robotModeObsWgsPoints
|
||||
.map((wgs) => wgs84ToGcj02(wgs.latitude, wgs.longitude))
|
||||
.toList();
|
||||
_obstacleHoles.add(gcjPoints);
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
_obstacleHoles.add(List.from(_robotModeObsWgsPoints));
|
||||
_obstacleWgsHoles.add(List.from(_robotModeObsWgsPoints));
|
||||
} else {
|
||||
_obstacleHoles.add(List.from(_currentObstaclePoints));
|
||||
@@ -1751,14 +1743,16 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
double? lat = safeToDouble(item['lat']);
|
||||
double? lon = safeToDouble(item['lng'] ?? item['lon']);
|
||||
if (lat != null && lon != null) {
|
||||
newPathPoints.add(convertWGS84ToGCJ02(lat, lon));
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
newPathPoints.add(LatLng(lat, lon));
|
||||
}
|
||||
}
|
||||
for (var item in outerList) {
|
||||
double? lng = safeToDouble(item['lng'] ?? item['lon']);
|
||||
double? lat = safeToDouble(item['lat']);
|
||||
if (lng != null && lat != null) {
|
||||
newOuterPoints.add(convertWGS84ToGCJ02(lat, lng));
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
newOuterPoints.add(LatLng(lat, lng));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1871,26 +1865,28 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
) {
|
||||
_onDeviceData(lng, lat, obfFlag, headingStatus, controlMode);
|
||||
|
||||
final gcjPoint = wgs84ToGcj02(lat, lng);
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
final currentPoint = LatLng(lat, lng);
|
||||
|
||||
//final initCenter = bd09ToGcj02(
|
||||
// '120.81992468426961', // 经度
|
||||
// '32.04532826114155',
|
||||
//);
|
||||
|
||||
// 🔥 修复1:初始化坐标改为直接使用有效GCJ02坐标,避免BD09转换风险
|
||||
// 🔥 修复1:初始化坐标改为直接使用有效WGS84坐标
|
||||
final initCenter = _getDefaultValidCenter();
|
||||
_currentWgsLatLng = LatLng(lat, lng);
|
||||
|
||||
_traceManager.upsert(_currentWgsLatLng as PlotPoint, TPAction.UPDATE);
|
||||
tracePoint = _traceManager.getTracePoint();
|
||||
gctracePoint = batchWgs84ToGcj02(tracePoint!);
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始轨迹点
|
||||
gctracePoint = List.from(tracePoint!);
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
|
||||
// _logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
|
||||
|
||||
//_currentLatLng = gcjPoint; // ✅ 状态变量在setState内更新
|
||||
if (_isValidLatLng(gcjPoint.latitude, gcjPoint.longitude)) {
|
||||
_currentLatLng = gcjPoint;
|
||||
//_currentLatLng = currentPoint; // ✅ 状态变量在setState内更新
|
||||
if (_isValidLatLng(currentPoint.latitude, currentPoint.longitude)) {
|
||||
_currentLatLng = currentPoint;
|
||||
} else {
|
||||
_currentLatLng = null;
|
||||
}
|
||||
@@ -1909,24 +1905,20 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
onTap: (_, __) {}, // 空实现,禁用地图点击响应
|
||||
),
|
||||
children: [
|
||||
/// 高德瓦片(GCJ-02)
|
||||
/// ESRI World Imagery(WGS84 坐标系,全球高清卫星图)
|
||||
/// 替代高德地图(GCJ02),统一坐标系,无需坐标转换
|
||||
TileLayer(
|
||||
//urlTemplate:
|
||||
// "https://webst02.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=2&style=7&key=bbb1f0f20eed6bf679eddf2625630aba",
|
||||
urlTemplate:
|
||||
'https://webrd02.is.autonavi.com/appmaptile'
|
||||
'?style=8&x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1'
|
||||
'&key=bbb1f0f20eed6bf679eddf2625630aba',
|
||||
// 🔥 修复:适配高DPI(替代pixelRatio)
|
||||
// tileSize: 512, // 高清瓦片尺寸(默认256,改为512适配scale=2)
|
||||
tileProvider: NetworkTileProvider(
|
||||
// 自定义瓦片请求头(可选,提升兼容性)
|
||||
headers: {
|
||||
'Referer': 'https://www.amap.com', // 高德要求的Referer
|
||||
},
|
||||
),
|
||||
maxZoom: 18,
|
||||
urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
userAgentPackageName: 'com.example.app',
|
||||
minZoom: 0,
|
||||
maxZoom: 19,
|
||||
),
|
||||
|
||||
/// GeoServer 苏州无人机高清影像叠加层(WGS84 基准,无偏移)
|
||||
/// 实际地块很小(约 250m,中心 31.0833/120.8404),需 zoom >= 16 才能看到
|
||||
/// 当前 WMS 兜底(原生 EPSG:4326);公司补配 GWC EPSG:3857 后切 WMTS
|
||||
/// 切换位置:geoserver_suzhou_layer.dart 的 kUseWmtsTile 开关
|
||||
GeoSuzhouOverlay(),
|
||||
//if (isDjMapShow) DJITileLayer(stationName: _currentStation.name, mapController: _mapController),
|
||||
|
||||
// 绘制path折线(Line模式)
|
||||
@@ -2512,8 +2504,87 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 步骤3: 任务确认成功,更新UI状态
|
||||
debugPrint('🎯 [开始作业] 步骤3: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}');
|
||||
// 🔥 新增:步骤2.5 - 调用 getTaskPath 接口获取完整路径数据
|
||||
debugPrint('📍 [开始作业] 步骤2.5: 调用 getTaskPath 接口获取路径数据');
|
||||
final currentTaskId = taskCubit.state.currentTaskId;
|
||||
bool pathFetchSuccess = false;
|
||||
List<LatLng>? fetchedPathPoints;
|
||||
|
||||
try {
|
||||
final dio = sl<Dio>();
|
||||
final response = await dio.get(
|
||||
'http://1.95.137.212:8081/iot/taskPath/getTaskPath',
|
||||
queryParameters: {
|
||||
'taskId': currentTaskId,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final code = data['code'];
|
||||
final codeValue = code is int ? code : int.tryParse(code?.toString() ?? '');
|
||||
|
||||
if (codeValue == 200) {
|
||||
final pathData = data['data'];
|
||||
debugPrint('✅ [开始作业] getTaskPath 接口调用成功, data类型: ${pathData.runtimeType}');
|
||||
|
||||
if (pathData != null && pathData is Map<String, dynamic>) {
|
||||
final pathList = pathData['path'];
|
||||
if (pathList != null && pathList is List && pathList.isNotEmpty) {
|
||||
debugPrint('✅ [开始作业] 获取到路径点数: ${pathList.length}');
|
||||
|
||||
// 将路径点渲染到地图上
|
||||
fetchedPathPoints = pathList.map((point) {
|
||||
final lat = (point['lat'] as num?)?.toDouble() ?? 0.0;
|
||||
final lng = (point['lng'] ?? point['lon'] as num?)?.toDouble() ?? 0.0;
|
||||
return LatLng(lat, lng);
|
||||
}).toList();
|
||||
|
||||
// 渲染路径到地图
|
||||
setState(() {
|
||||
gcjPathPoints = fetchedPathPoints!;
|
||||
typedPathList = fetchedPathPoints!.map((latLng) {
|
||||
return work_area_model.DeviceAddPathPointModel(
|
||||
latitude: latLng.latitude,
|
||||
longitude: latLng.longitude,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
|
||||
pathFetchSuccess = true;
|
||||
debugPrint('✅ [开始作业] 路径已渲染到地图');
|
||||
} else {
|
||||
debugPrint('⚠️ [开始作业] 路径数据为空');
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [开始作业] 路径数据结构格式错误');
|
||||
}
|
||||
} else {
|
||||
debugPrint('❌ [开始作业] getTaskPath 接口返回业务失败: $code');
|
||||
}
|
||||
} else {
|
||||
debugPrint('❌ [开始作业] getTaskPath 接口HTTP失败: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [开始作业] getTaskPath 接口调用异常: $e');
|
||||
}
|
||||
|
||||
// 🔥 判断路径获取结果
|
||||
if (!pathFetchSuccess) {
|
||||
debugPrint('❌ [开始作业] 路径数据获取失败或为空,关闭loading,弹出提示,终止作业启动');
|
||||
setState(() {
|
||||
isStopWork = false;
|
||||
isStartWork = false;
|
||||
_workStatus = WorkStatus.idle;
|
||||
});
|
||||
_showPageToast(
|
||||
message: '获取路径数据失败,无法启动作业',
|
||||
type: ToastType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🎯 [开始作业] 步骤4: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}');
|
||||
setState(() {
|
||||
isStopWork = false;
|
||||
isStartWork = true;
|
||||
@@ -2521,8 +2592,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
});
|
||||
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
|
||||
|
||||
// 🔥 步骤4: 刷新任务池(保护 taskId 不被覆盖)
|
||||
debugPrint('🔄 [开始作业] 步骤4: 刷新任务池');
|
||||
// 🔥 步骤5: 刷新任务池(保护 taskId 不被覆盖)
|
||||
debugPrint('🔄 [开始作业] 步骤5: 刷新任务池');
|
||||
final _confirmedTaskId = taskCubit.state.currentTaskId;
|
||||
await taskCubit.fetchAndFilterTask(deviceId);
|
||||
if (_confirmedTaskId != null && taskCubit.state.currentTaskId == null) {
|
||||
@@ -2530,8 +2601,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
debugPrint('🛡️ [开始作业] taskId 被刷新覆盖,已还原: $_confirmedTaskId');
|
||||
}
|
||||
|
||||
// 🔥 步骤5: 启动MQTT到达点监听
|
||||
debugPrint('📡 [开始作业] 步骤5: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId');
|
||||
// 🔥 步骤6: 启动MQTT到达点监听
|
||||
debugPrint('📡 [开始作业] 步骤6: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId');
|
||||
try {
|
||||
await context.read<DevicesCubit>().startListeningMqttArrive(deviceId: deviceId, taskId: _confirmedTaskId!);
|
||||
debugPrint('✅ [开始作业] MQTT到达点监听已启动');
|
||||
@@ -2539,8 +2610,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
debugPrint('⚠️ [开始作业] MQTT监听启动失败: $e (非致命,继续)');
|
||||
}
|
||||
|
||||
// 🔥 步骤6: 持久化状态
|
||||
debugPrint('💾 [开始作业] 步骤6: 持久化状态, taskId=${taskCubit.state.currentTaskId}');
|
||||
// 🔥 步骤7: 持久化状态
|
||||
debugPrint('💾 [开始作业] 步骤7: 持久化状态, taskId=${taskCubit.state.currentTaskId}');
|
||||
_showPageToast(message: "作业已开始", type: ToastType.success);
|
||||
_saveDataToLocal();
|
||||
|
||||
@@ -3511,18 +3582,12 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
firstPointWgs = _robotModeWgsPoints.first;
|
||||
} else if (_currentRobotMode == RobotMode.point &&
|
||||
_markedPoints.isNotEmpty) {
|
||||
LatLng firstPointGcj = _markedPoints.first;
|
||||
firstPointWgs = gcj02ToWgs84(
|
||||
firstPointGcj.latitude,
|
||||
firstPointGcj.longitude,
|
||||
);
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
firstPointWgs = _markedPoints.first;
|
||||
} else if (_markedPoints.isNotEmpty) {
|
||||
// 兜底:_robotModeWgsPoints 为空但_markedPoints有数据(新缓存key未写入等场景)
|
||||
LatLng firstPointGcj = _markedPoints.first;
|
||||
firstPointWgs = gcj02ToWgs84(
|
||||
firstPointGcj.latitude,
|
||||
firstPointGcj.longitude,
|
||||
);
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
firstPointWgs = _markedPoints.first;
|
||||
} else {
|
||||
if (showTips) {
|
||||
_showPageToast(message: '参考点坐标为空,请先添加作业区域打点!', type: ToastType.error);
|
||||
@@ -3546,20 +3611,19 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
);
|
||||
}).toList();
|
||||
} else if (_markedPoints.isNotEmpty) {
|
||||
// robot模式但_robotModeWgsPoints为空时,兜底用_markedPoints转换
|
||||
// robot模式但_robotModeWgsPoints为空时,用_markedPoints(已是WGS84)
|
||||
outerPositions = _markedPoints.map((latLng) {
|
||||
final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude);
|
||||
return work_area_model.Position(
|
||||
lat: wgs84Point.latitude,
|
||||
lon: wgs84Point.longitude,
|
||||
lat: latLng.latitude,
|
||||
lon: latLng.longitude,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
gcjOuterPoints = outerPositions.map((point) {
|
||||
double wgs84Lat = point.lat;
|
||||
double wgs84Lon = point.lon;
|
||||
final gcjPoint = wgs84ToGcj02(wgs84Lat, wgs84Lon);
|
||||
return LatLng(gcjPoint.latitude, gcjPoint.longitude);
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
return LatLng(wgs84Lat, wgs84Lon);
|
||||
}).toList();
|
||||
|
||||
final outerBoundary = work_area_model.OuterBoundary(
|
||||
@@ -3588,13 +3652,10 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
for (var holePoints in _obstacleHoles) {
|
||||
List<work_area_model.Position> innerPositionList = [];
|
||||
innerPositionList = holePoints.map((latLng) {
|
||||
final wgs84Point = gcj02ToWgs84(
|
||||
latLng.latitude,
|
||||
latLng.longitude,
|
||||
);
|
||||
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
|
||||
return work_area_model.Position(
|
||||
lat: wgs84Point.latitude,
|
||||
lon: wgs84Point.longitude,
|
||||
lat: latLng.latitude,
|
||||
lon: latLng.longitude,
|
||||
);
|
||||
}).toList();
|
||||
positionList.add(innerPositionList);
|
||||
@@ -3625,6 +3686,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 已废弃:不再调用旧的路径生成API,path只传用户手动打点的边界点
|
||||
/*
|
||||
await context.read<DevicesCubit>().generatePath(
|
||||
reference: referencePoint,
|
||||
heading: heading,
|
||||
@@ -3671,6 +3734,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
moveMapToPointsCenter(gcjPathPoints);
|
||||
}
|
||||
}
|
||||
*/
|
||||
} catch (e) {
|
||||
debugPrint('路径生成异常:$e');
|
||||
if (showTips) {
|
||||
@@ -3950,6 +4014,39 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
),
|
||||
),
|
||||
|
||||
/// 悬浮"回到苏州"按钮:一键跳转到 GeoServer 苏州地块(zoom 18 才能看清 250m 地块)
|
||||
Positioned(
|
||||
top: 60,
|
||||
left: 10,
|
||||
child: GestureDetector(
|
||||
onTap: () => _mapController.move(kSuzhouCenter, 18),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'苏',
|
||||
style: TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
/// 核心:地图正中心固定定位标
|
||||
if (_currentRobotMode == RobotMode.point)
|
||||
Positioned(
|
||||
@@ -4428,12 +4525,14 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换单个WGS84坐标到GCJ02
|
||||
LatLng convertWGS84ToGCJ02(double lat, double lon) {
|
||||
return wgs84ToGcj02(lat, lon);
|
||||
}
|
||||
// ⚠️ 以下函数已注释 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换
|
||||
// 后续确认无误后可删除
|
||||
// ⚠️ convertWGS84ToGCJ02 函数已删除 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换
|
||||
// 以下坐标转换函数保留但注释,后续确认无误后可删除
|
||||
|
||||
/// =======================================================
|
||||
// ⚠️ 坐标转换函数已注释 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换
|
||||
// 以下函数保留但注释,后续确认无误后可删除
|
||||
/*
|
||||
/// 坐标转换:WGS84 -> GCJ02(国内高德/腾讯通用)
|
||||
/// =======================================================
|
||||
const double _pi = 3.1415926535897932384626;
|
||||
@@ -4560,8 +4659,9 @@ double _transformLon(double x, double y) {
|
||||
(150.0 * sin(x / 12.0 * _pi) + 300.0 * sin(x / 30.0 * _pi)) * 2.0 / 3.0;
|
||||
return ret;
|
||||
}
|
||||
*/
|
||||
|
||||
/// 计算两个经纬度点之间的地面距离(单位:米,GCJ02/WGS84通用)
|
||||
/// 计算两个经纬度点之间的地面距离(单位:米,GCJ02/WGS84 通用)
|
||||
String _calculateLatLngDistance(LatLng point1, LatLng point2) {
|
||||
const double earthRadius = 6371000; // 地球半径(米)
|
||||
|
||||
|
||||
@@ -1501,12 +1501,13 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
},
|
||||
),
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
// ESRI World Imagery(WGS84 坐标系,全球高清卫星图)
|
||||
// 替代高德地图(GCJ02),统一坐标系,无需坐标转换
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
userAgentPackageName: 'com.example.app',
|
||||
minZoom: 0,
|
||||
maxZoom: 19,
|
||||
),
|
||||
|
||||
// 轨迹线(中间层)
|
||||
|
||||
@@ -36,6 +36,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
|
||||
late DroneOsdDataSource _osdDataSource;
|
||||
StreamSubscription<DroneOsdEntity>? _stationOsdSubscription;
|
||||
StreamSubscription<Object?>? _blocStateSubscription;
|
||||
|
||||
/// 静态缓存:按 gatewaySn 分区,跨页面实例保留每个机场上次的 host 数据
|
||||
/// 避免进入新机场时显示上一个机场的缓存数据
|
||||
@@ -62,23 +63,34 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
|
||||
_osdDataSource = sl<DroneOsdDataSource>();
|
||||
_startStationOsdListening();
|
||||
|
||||
_hasInitialized = true;
|
||||
}
|
||||
|
||||
void _startStationOsdListening() {
|
||||
// 使用 station 自带的 deviceSn + gatewaySn,与列表页预订阅参数一致
|
||||
// 避免因参数不同导致 DataSource 重新订阅、打断数据流
|
||||
_osdDataSource.startListening(
|
||||
deviceSn: widget.station.deviceSn,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
);
|
||||
|
||||
// 🔥 立即建立 stationOsdStream 的监听(关键!)
|
||||
// 否则 _parseAndUpdateHostData 永远不会被调用,机场状态卡片一直显示"未知"
|
||||
_stationOsdSubscription = _osdDataSource.stationOsdStream.listen((osd) {
|
||||
if (!mounted) return;
|
||||
_parseAndUpdateHostData(osd);
|
||||
});
|
||||
|
||||
// 🔥 监听 Bloc 状态变化,等数据加载完成后再确保 MQTT 订阅建立
|
||||
_blocStateSubscription = _bloc.stream.listen((state) {
|
||||
if (state is UAVDetailLoaded && mounted) {
|
||||
debugPrint('✅ [DroneStationDetailPage] Bloc 数据加载完成,确保 MQTT 订阅');
|
||||
debugPrint(' deviceSn: ${state.detail.deviceSn}');
|
||||
debugPrint(' gatewaySn: ${widget.station.gatewaySn}');
|
||||
|
||||
// 确保 MQTT 订阅建立
|
||||
_osdDataSource.startListening(
|
||||
deviceSn: state.detail.deviceSn,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
);
|
||||
_droneSn = state.detail.deviceSn;
|
||||
|
||||
// 取消监听(只执行一次)
|
||||
_blocStateSubscription?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
_hasInitialized = true;
|
||||
}
|
||||
|
||||
void _parseAndUpdateHostData(DroneOsdEntity osd) {
|
||||
@@ -95,6 +107,18 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
final hostData = data['data'] is Map ? (data['data'] as Map)['host'] : null;
|
||||
if (hostData == null || hostData is! Map) return;
|
||||
|
||||
// 🔥 关键检查:确认是否包含机场状态的关键字段
|
||||
// 避免将其他类型的OSD消息(如设备状态、电池信息等)覆盖机场状态数据
|
||||
final hasHostFields = hostData.containsKey('cover_state') ||
|
||||
hostData.containsKey('drone_in_dock') ||
|
||||
hostData.containsKey('temperature') ||
|
||||
hostData.containsKey('humidity');
|
||||
if (!hasHostFields) {
|
||||
debugPrint('⚠️ [DroneStationDetailPage] ⚠️ 此消息不包含机场状态字段,跳过更新!');
|
||||
debugPrint(' hostData keys: ${hostData.keys.toList()}');
|
||||
return; // 不是机场状态数据,直接返回
|
||||
}
|
||||
|
||||
// 🔥 基于已有数据合并,只更新非 null 字段,避免 Type 2 消息覆盖已有值
|
||||
final Map<String, dynamic> merged = Map<String, dynamic>.from(
|
||||
_stationHostData.value,
|
||||
@@ -111,10 +135,13 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
_put('humidity', (hostData['humidity'] as num?)?.toDouble());
|
||||
_put('wind_speed', (hostData['wind_speed'] as num?)?.toDouble());
|
||||
_put('rainfall', hostData['rainfall']?.toString());
|
||||
_put('cover_state', hostData['cover_state']?.toString());
|
||||
_put('drone_in_dock', hostData['drone_in_dock']?.toString());
|
||||
|
||||
// 🔥 不再做严格校验,直接存储原始值,让UI层自己去解析
|
||||
_put('cover_state', hostData['cover_state']);
|
||||
_put('drone_in_dock', hostData['drone_in_dock']);
|
||||
_put('temperature', (hostData['temperature'] as num?)?.toDouble());
|
||||
_put('putter_state', hostData['putter_state']?.toString());
|
||||
_put('putter_state', hostData['putter_state']);
|
||||
|
||||
_put(
|
||||
'supplement_light_state',
|
||||
hostData['supplement_light_state']?.toString(),
|
||||
@@ -144,25 +171,17 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
final acData = hostData['air_conditioner'];
|
||||
if (acData is Map) {
|
||||
_put('air_conditioner', acData);
|
||||
|
||||
// 解析空调开关状态
|
||||
final acStateVal = int.tryParse(acData['air_conditioner_state']?.toString() ?? '') ?? -1;
|
||||
if (acStateVal == 0 || acStateVal == 1) {
|
||||
_put('air_conditioner_state', acStateVal.toString());
|
||||
}
|
||||
|
||||
// 解析空调运行时间
|
||||
final switchTime = acData['switch_time'];
|
||||
if (switchTime != null) {
|
||||
_put('air_conditioner_switch_time', '$switchTime');
|
||||
}
|
||||
// 不单独提取air_conditioner_state,让UI层自己解析
|
||||
}
|
||||
|
||||
// 🔥 存储到ValueNotifier触发UI更新
|
||||
_stationHostData.value = merged;
|
||||
// 同步写入按 gatewaySn 分区的静态缓存
|
||||
// 同步写入按gatewaySn分区的静态缓存
|
||||
_cachedHostDataBySn[widget.station.gatewaySn] = Map<String, dynamic>.from(
|
||||
merged,
|
||||
);
|
||||
|
||||
debugPrint('[DroneStationDetailPage] 收到机场状态数据');
|
||||
}
|
||||
|
||||
/// 🔥 页面重新激活时调用(从其他页面返回时)
|
||||
@@ -198,6 +217,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
_stationOsdSubscription?.cancel();
|
||||
_blocStateSubscription?.cancel();
|
||||
// 只调用 stopListening 减少引用计数,不调用 dispose()
|
||||
// 因为 DroneOsdDataSource 是 LazySingleton,被列表页和详情页共享
|
||||
// 详情页退出后列表页可能仍需要该订阅
|
||||
@@ -331,17 +351,6 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
|
||||
Widget _buildContent(UAVDetailEntity detail) {
|
||||
_detail = detail;
|
||||
final newDroneSn = detail.deviceSn;
|
||||
|
||||
// 只在 droneSn 真正变化时才重新订阅,避免 Bloc 刷新时重复 startListening
|
||||
// 导致引用计数虚增、dispose 时无法真正取消订阅
|
||||
if (newDroneSn != null && newDroneSn.isNotEmpty && newDroneSn != _droneSn) {
|
||||
_droneSn = newDroneSn;
|
||||
_osdDataSource.startListening(
|
||||
deviceSn: newDroneSn,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
@@ -484,8 +493,11 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
final rainfall = hostData['rainfall'];
|
||||
final coverState = hostData['cover_state'];
|
||||
final droneInDock = hostData['drone_in_dock'];
|
||||
final acState = hostData['air_conditioner_state'];
|
||||
final alarmState = hostData['alarm_state'];
|
||||
|
||||
// 🔥 从 air_conditioner Map 中解析空调状态
|
||||
final acDataMap = hostData['air_conditioner'];
|
||||
final acState = acDataMap is Map ? acDataMap['air_conditioner_state'] : null;
|
||||
|
||||
String envTempStr = '未知';
|
||||
if (envTemp != null) {
|
||||
@@ -516,7 +528,8 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
String coverStr = '未知';
|
||||
Color coverColor = const Color(0xFF86909C);
|
||||
if (coverState != null) {
|
||||
final state = int.tryParse(coverState) ?? -1;
|
||||
// 🔥 像 DroneStationOsdCard 一样解析,支持多种数据格式
|
||||
final state = coverState is int ? coverState : int.tryParse(coverState.toString()) ?? -1;
|
||||
if (state == 1) {
|
||||
coverStr = '开启';
|
||||
coverColor = const Color(0xFFFF7D00);
|
||||
@@ -529,7 +542,8 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
String droneDockStr = '未知';
|
||||
Color droneDockColor = const Color(0xFF86909C);
|
||||
if (droneInDock != null) {
|
||||
final state = int.tryParse(droneInDock) ?? -1;
|
||||
// 🔥 像 DroneStationOsdCard 一样解析
|
||||
final state = droneInDock is int ? droneInDock : int.tryParse(droneInDock.toString()) ?? -1;
|
||||
if (state == 1) {
|
||||
droneDockStr = '在库内';
|
||||
droneDockColor = const Color(0xFF00B42A);
|
||||
@@ -542,7 +556,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
String acStr = '未知';
|
||||
Color acColor = const Color(0xFF86909C);
|
||||
if (acState != null) {
|
||||
// 🔥 增强兼容性:尝试多种数据格式(int、string等)
|
||||
// 🔥 增强兼容性:支持多种数据格式和更多状态值
|
||||
final acValue = acState is int ? acState : int.tryParse(acState.toString()) ?? -1;
|
||||
if (acValue == 1) {
|
||||
acStr = '开启';
|
||||
@@ -551,7 +565,6 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
acStr = '关闭';
|
||||
acColor = const Color(0xFF86909C);
|
||||
} else if (acValue == 2) {
|
||||
// 部分设备使用 2 表示待机或其他状态
|
||||
acStr = '待机';
|
||||
acColor = const Color(0xFFFF7D00);
|
||||
}
|
||||
|
||||
@@ -1290,12 +1290,13 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
),
|
||||
),
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
// ESRI World Imagery(WGS84 坐标系,全球高清卫星图)
|
||||
// 替代高德地图(GCJ02),统一坐标系,无需坐标转换
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
userAgentPackageName: 'com.example.app',
|
||||
minZoom: 0,
|
||||
maxZoom: 19,
|
||||
),
|
||||
|
||||
// 🔥 轨迹线(中间层)
|
||||
@@ -1563,12 +1564,13 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
),
|
||||
),
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
// ESRI World Imagery(WGS84 坐标系,全球高清卫星图)
|
||||
// 替代高德地图(GCJ02),统一坐标系,无需坐标转换
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
userAgentPackageName: 'com.example.app',
|
||||
minZoom: 0,
|
||||
maxZoom: 19,
|
||||
),
|
||||
|
||||
// 🔥 轨迹线(中间层)
|
||||
|
||||
Reference in New Issue
Block a user