596 lines
22 KiB
Dart
596 lines
22 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
import 'dart:math' as math;
|
||
import 'dart:ui' as ui;
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/widgets.dart';
|
||
import 'package:flutter_map/flutter_map.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:latlong2/latlong.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:xml/xml.dart';
|
||
|
||
/// GeoServer 基地址(公司端)
|
||
const String kGeoserverBase = 'http://1.95.137.212:7900/geoserver';
|
||
|
||
/// GeoServer 工作区名(后端 siteMapName 形如 workSpace:suzhou)
|
||
const String kGeoWorkspace = 'workSpace';
|
||
|
||
/// WMS 瓦片网络超时。大图层(如 neimeng/zhongke)一屏十几片、GeoServer 实时渲染排队,
|
||
/// 单片实际等待可能远超 30s;浏览器预览肯等所以最终能出图,App 若超时太短会集体放弃、
|
||
/// 返回透明图导致“一直出不来”。故放宽到 90s,并配合下方并发闸门分批取图。
|
||
const Duration kWmsTileTimeout = Duration(seconds: 90);
|
||
|
||
/// 各场站高清图层的真实范围(取自 GeoServer GetCapabilities,原生 EPSG:4326)
|
||
/// key = 后端下发的 siteMapName(形如 workSpace:suzhou)
|
||
final Map<String, LatLngBounds> kStationLayerBounds = {
|
||
'workSpace:liaoding': LatLngBounds(
|
||
const LatLng(41.19998369457442, 120.59074514010273),
|
||
const LatLng(41.202002066385475, 120.59343722827124),
|
||
),
|
||
'workSpace:loudi': LatLngBounds(
|
||
const LatLng(27.846682995656035, 111.52814279952669),
|
||
const LatLng(27.847939704917945, 111.52928081352842),
|
||
),
|
||
'workSpace:neimeng': LatLngBounds(
|
||
const LatLng(41.321581124009896, 112.62468603332228),
|
||
const LatLng(41.325573311689986, 112.63034797059984),
|
||
),
|
||
'workSpace:result': LatLngBounds(
|
||
const LatLng(27.36490549568922, 116.84490756003203),
|
||
const LatLng(27.36673908366302, 116.84661654295478),
|
||
),
|
||
'workSpace:sanxiamen': LatLngBounds(
|
||
const LatLng(34.732748259670494, 111.43051478686061),
|
||
const LatLng(34.734205267863345, 111.43201022647355),
|
||
),
|
||
'workSpace:suzhou': LatLngBounds(
|
||
const LatLng(31.082450793459877, 120.83906484264836),
|
||
const LatLng(31.084141983135456, 120.84164596446476),
|
||
),
|
||
'workSpace:zhongke': LatLngBounds(
|
||
const LatLng(32.0406442971753, 120.8057183175354),
|
||
const LatLng(32.045144217672636, 120.81100452381844),
|
||
),
|
||
};
|
||
|
||
/// 取某场站图层的范围中心点(用作进场默认定位);未知/为空返回 null
|
||
LatLng? stationLayerCenter(String? siteMapName) {
|
||
if (siteMapName == null) return null;
|
||
// 优先用动态拉取的范围(StationLayerRepo),其内部已含硬编码兜底
|
||
final b = StationLayerRepo.instance.bounds[siteMapName];
|
||
if (b == null) return null;
|
||
return LatLng((b.south + b.north) / 2, (b.west + b.east) / 2);
|
||
}
|
||
|
||
/// 叠加层取瓦片方式开关:
|
||
/// - 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({
|
||
required this.layerName,
|
||
required this.bounds,
|
||
this.maxLevel = 21,
|
||
});
|
||
|
||
final String layerName;
|
||
final LatLngBounds bounds;
|
||
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 < bounds.east &&
|
||
eastLng > bounds.west &&
|
||
southLat < bounds.north &&
|
||
northLat > bounds.south;
|
||
if (!intersects) {
|
||
return MemoryImage(TileProvider.transparentImage);
|
||
}
|
||
|
||
final url = Uri.parse('$kGeoserverBase/wms').replace(
|
||
queryParameters: {
|
||
'SERVICE': 'WMS',
|
||
'REQUEST': 'GetMap',
|
||
'VERSION': '1.1.1',
|
||
'LAYERS': layerName,
|
||
'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 CachedWmsImage(url.toString());
|
||
}
|
||
}
|
||
|
||
/// GeoServer 场站无人机高清影像叠加层(WGS84 基准,覆盖在 ESRI 底图上)
|
||
/// 按当前选中场站的 siteMapName 动态加载对应图层;地块很小,默认 minZoom = 16
|
||
class GeoSuzhouOverlay extends StatelessWidget {
|
||
const GeoSuzhouOverlay({
|
||
super.key,
|
||
this.siteMapName,
|
||
this.minZoom = 16,
|
||
this.maxZoom = 21,
|
||
});
|
||
|
||
/// 当前选中场站对应的 GeoServer 图层名(形如 workSpace:suzhou)
|
||
final String? siteMapName;
|
||
|
||
/// 小于该级别不请求瓦片(地块太小,低层级看不见)
|
||
final double minZoom;
|
||
|
||
/// 大于该级别不请求瓦片
|
||
final double maxZoom;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final layer = siteMapName;
|
||
final bounds = layer == null ? null : kStationLayerBounds[layer];
|
||
// 未选中场站或图层未知 → 不渲染叠加层
|
||
if (layer == null || bounds == null) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
if (kUseWmtsTile) {
|
||
// === WMTS 版:公司把 EPSG:3857 gridset 加入 GWC 并 Seed 后生效 ===
|
||
return TileLayer(
|
||
urlTemplate: '$kGeoserverBase/gwc/service/wmts'
|
||
'?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0'
|
||
'&LAYER=$layer&TILEMATRIXSET=EPSG:3857'
|
||
'&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png',
|
||
userAgentPackageName: 'com.example.app',
|
||
minZoom: minZoom,
|
||
maxZoom: maxZoom,
|
||
maxNativeZoom: maxZoom.toInt(),
|
||
tileBounds: bounds,
|
||
);
|
||
}
|
||
// === WMS 版(当前兜底):按原生 EPSG:4326 请求,GeoServer 实时渲染 ===
|
||
return TileLayer(
|
||
tileProvider: GeoWmsProvider(
|
||
layerName: layer,
|
||
bounds: bounds,
|
||
maxLevel: maxZoom.toInt(),
|
||
),
|
||
userAgentPackageName: 'com.example.app',
|
||
minZoom: minZoom,
|
||
maxZoom: maxZoom,
|
||
maxNativeZoom: maxZoom.toInt(),
|
||
tileBounds: bounds,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 全部场站高清叠加图层(一次性构建、复用实例,避免每次重建重复请求瓦片)。
|
||
/// 用法:在 FlutterMap 的 children 里 spread 进去 —— ...kAllStationTileLayers
|
||
/// 效果:滑到任意场站区域(zoom>=16)自动叠加对应高清图;每层各自 tileBounds,框外不请求。
|
||
final List<TileLayer> kAllStationTileLayers = kStationLayerBounds.entries
|
||
.map(
|
||
(entry) => TileLayer(
|
||
tileProvider: GeoWmsProvider(
|
||
layerName: entry.key,
|
||
bounds: entry.value,
|
||
maxLevel: 21,
|
||
),
|
||
userAgentPackageName: 'com.example.app',
|
||
minZoom: 16,
|
||
maxZoom: 21,
|
||
maxNativeZoom: 21,
|
||
tileBounds: entry.value,
|
||
),
|
||
)
|
||
.toList();
|
||
|
||
/// 带磁盘缓存 + 超时的 WMS 瓦片图:
|
||
/// 命中本地缓存直接解码(秒开);未命中则联网拉取(带超时),成功后写入缓存;
|
||
/// 失败/超时返回 1×1 透明图,避免异常刷屏。解决 WMS 实时渲染“每次都慢”的问题。
|
||
class CachedWmsImage extends ImageProvider<CachedWmsImage> {
|
||
CachedWmsImage(this.url) : _gen = _generation;
|
||
|
||
final String url;
|
||
|
||
/// 构造时捕获的缓存代次,纳入 key 相等性与磁盘文件名。
|
||
/// 手动刷新时 _generation++,旧瓦片(内存 key + 磁盘文件)立即全部失效,
|
||
/// 只影响 WMS 瓦片、不动 ESRI 底图缓存。
|
||
final int _gen;
|
||
static int _generation = 0;
|
||
|
||
static Future<Directory>? _cacheDirFuture;
|
||
|
||
static Future<Directory> _cacheDir() {
|
||
return _cacheDirFuture ??= () async {
|
||
try {
|
||
final base = await getTemporaryDirectory();
|
||
final dir = Directory('${base.path}${Platform.pathSeparator}wms_tiles');
|
||
if (!await dir.exists()) {
|
||
await dir.create(recursive: true);
|
||
}
|
||
return dir;
|
||
} catch (e) {
|
||
// 初始化失败不要把“坏 Future”永久缓存下来(否则后续所有瓦片都卡在这一步集体失败、
|
||
// 表现为图层全不出来),置空以便下次调用重试
|
||
_cacheDirFuture = null;
|
||
rethrow;
|
||
}
|
||
}();
|
||
}
|
||
|
||
/// URL + 代次 → 稳定短文件名(FNV-1a 32 位 + 长度,降低碰撞;代次前缀用于失效旧缓存)
|
||
static String _fileName(String url, int gen) {
|
||
var h = 0x811c9dc5;
|
||
for (final c in url.codeUnits) {
|
||
h ^= c;
|
||
h = (h * 0x01000193) & 0xFFFFFFFF;
|
||
}
|
||
return 'g${gen}_${h.toRadixString(16).padLeft(8, '0')}_${url.length}.png';
|
||
}
|
||
|
||
/// 清空全部 WMS 瓦片缓存(磁盘文件 + 递增代次使内存 key 失效)。
|
||
/// 后端更新了同名图层的影像内容后,调用它强制丢弃旧缓存、重新联网拉取最新图。
|
||
static Future<void> clearTileCache() async {
|
||
_generation++;
|
||
try {
|
||
final dir = await _cacheDir();
|
||
if (await dir.exists()) {
|
||
await for (final e in dir.list()) {
|
||
try {
|
||
await e.delete();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
debugPrint('🧹 [WMS] 已清空瓦片磁盘缓存,代次 → $_generation');
|
||
} catch (e) {
|
||
debugPrint('⚠️ [WMS] 清缓存失败: $e');
|
||
}
|
||
}
|
||
|
||
// ===== WMS 并发闸门 =====
|
||
// 大图层(如 neimeng/zhongke)一屏可达十几片瓦片,若同时发起会把 GeoServer 压到排队渲染,
|
||
// 每片等待时间暴涨、集体超时 → 全部返回透明图,表现为“一直出不来”。限制并发分批取,
|
||
// 单片能更快拿到渲染资源、在超时内完成(浏览器预览能出图,正是因为它肯等且不这么挤)。
|
||
static const int _maxConcurrentWms = 5;
|
||
static int _activeWms = 0;
|
||
static final List<Completer<void>> _wmsWaiters = [];
|
||
|
||
static Future<void> _acquireWmsSlot() async {
|
||
if (_activeWms < _maxConcurrentWms) {
|
||
_activeWms++;
|
||
return;
|
||
}
|
||
final c = Completer<void>();
|
||
_wmsWaiters.add(c);
|
||
await c.future; // 被唤醒即表示 slot 已由 _releaseWmsSlot 直接转交,_activeWms 不变
|
||
}
|
||
|
||
static void _releaseWmsSlot() {
|
||
if (_wmsWaiters.isNotEmpty) {
|
||
_wmsWaiters.removeAt(0).complete(); // 有等待者:slot 直接转交队首,_activeWms 保持
|
||
} else {
|
||
_activeWms--;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<CachedWmsImage> obtainKey(ImageConfiguration configuration) =>
|
||
SynchronousFuture<CachedWmsImage>(this);
|
||
|
||
@override
|
||
ImageStreamCompleter loadImage(
|
||
CachedWmsImage key,
|
||
ImageDecoderCallback decode,
|
||
) {
|
||
return MultiFrameImageStreamCompleter(
|
||
codec: _loadAsync(decode),
|
||
scale: 1.0,
|
||
debugLabel: 'CachedWmsImage',
|
||
);
|
||
}
|
||
|
||
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async {
|
||
final sw = Stopwatch()..start();
|
||
try {
|
||
final dir = await _cacheDir();
|
||
final file =
|
||
File('${dir.path}${Platform.pathSeparator}${_fileName(url, _gen)}');
|
||
|
||
// 1) 命中磁盘缓存 → 秒开
|
||
if (await file.exists()) {
|
||
final cached = await file.readAsBytes();
|
||
if (cached.isNotEmpty) {
|
||
return decode(await ui.ImmutableBuffer.fromUint8List(cached));
|
||
}
|
||
}
|
||
|
||
// 2) 未命中 → 联网拉取。先过并发闸门,避免大图层一次性压垮 GeoServer 导致集体超时
|
||
debugPrint('🌐 [WMS] MISS 联网取瓦片: ${_shortLayerTag(url)}');
|
||
await _acquireWmsSlot();
|
||
try {
|
||
final resp = await http.get(Uri.parse(url)).timeout(kWmsTileTimeout);
|
||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||
try {
|
||
await file.writeAsBytes(resp.bodyBytes, flush: false);
|
||
} catch (_) {}
|
||
debugPrint(
|
||
'✅ [WMS] 已缓存(${sw.elapsedMilliseconds}ms, ${resp.bodyBytes.length}B): ${_shortLayerTag(url)}',
|
||
);
|
||
return decode(await ui.ImmutableBuffer.fromUint8List(resp.bodyBytes));
|
||
}
|
||
debugPrint(
|
||
'⚠️ [WMS] 响应异常 status=${resp.statusCode}(${sw.elapsedMilliseconds}ms): ${_shortLayerTag(url)}',
|
||
);
|
||
} finally {
|
||
_releaseWmsSlot();
|
||
}
|
||
} catch (e) {
|
||
// 超时 / 网络 / 解码异常 → 透明兜底
|
||
debugPrint('❌ [WMS] 瓦片失败(${sw.elapsedMilliseconds}ms): $e');
|
||
}
|
||
return decode(
|
||
await ui.ImmutableBuffer.fromUint8List(TileProvider.transparentImage),
|
||
);
|
||
}
|
||
|
||
/// 从 URL 提取 LAYERS 参数做简短日志标签(避免整条 URL 刷屏)
|
||
static String _shortLayerTag(String url) {
|
||
final m = RegExp(r'LAYERS=([^&]+)').firstMatch(url);
|
||
return m?.group(1) ?? 'unknown';
|
||
}
|
||
|
||
@override
|
||
bool operator ==(Object other) =>
|
||
other is CachedWmsImage && other.url == url && other._gen == _gen;
|
||
|
||
@override
|
||
int get hashCode => Object.hash(url, _gen);
|
||
}
|
||
|
||
/// 场站图层范围仓库:
|
||
/// - 优先从 GeoServer WMS GetCapabilities 动态拉取各图层真实范围
|
||
/// (后台增/改/删图层,App 不发版即可跟上)
|
||
/// - 拉取或解析失败时,回退到内置 kStationLayerBounds(离线/异常仍可用)
|
||
class StationLayerRepo {
|
||
StationLayerRepo._();
|
||
|
||
static final StationLayerRepo instance = StationLayerRepo._();
|
||
|
||
Map<String, LatLngBounds> _bounds =
|
||
Map<String, LatLngBounds>.of(kStationLayerBounds);
|
||
bool _dynamicLoaded = false;
|
||
Future<void>? _inflight;
|
||
final List<void Function()> _listeners = [];
|
||
|
||
/// 当前生效的范围表(动态成功后为最新;否则为内置兜底)
|
||
Map<String, LatLngBounds> get bounds => _bounds;
|
||
|
||
/// 范围表是否来自动态拉取
|
||
bool get isDynamic => _dynamicLoaded;
|
||
|
||
void addListener(void Function() cb) {
|
||
if (!_listeners.contains(cb)) _listeners.add(cb);
|
||
}
|
||
|
||
void removeListener(void Function() cb) => _listeners.remove(cb);
|
||
|
||
void _notify() {
|
||
for (final cb in List<void Function()>.of(_listeners)) {
|
||
cb();
|
||
}
|
||
}
|
||
|
||
/// 拉取 GetCapabilities 刷新范围表;并发调用自动合并;失败保持兜底不变
|
||
Future<void> refresh() {
|
||
return _inflight ??= _doRefresh().whenComplete(() => _inflight = null);
|
||
}
|
||
|
||
Future<void> _doRefresh() async {
|
||
final sw = Stopwatch()..start();
|
||
try {
|
||
// 用工作区专用端点:只返回 workSpace 下图层,XML 小、快、稳(全局端点图层多易超时)
|
||
final url = Uri.parse('$kGeoserverBase/$kGeoWorkspace/wms').replace(
|
||
queryParameters: {
|
||
'SERVICE': 'WMS',
|
||
'VERSION': '1.1.1',
|
||
'REQUEST': 'GetCapabilities',
|
||
},
|
||
);
|
||
debugPrint('🌐 [StationLayerRepo] 拉取 GetCapabilities...');
|
||
final resp = await http.get(url).timeout(const Duration(seconds: 20));
|
||
if (resp.statusCode != 200) {
|
||
debugPrint(
|
||
'⚠️ [StationLayerRepo] status=${resp.statusCode},保持兜底',
|
||
);
|
||
return;
|
||
}
|
||
final parsed = parseStationLayerBounds(resp.body);
|
||
debugPrint(
|
||
'📦 [StationLayerRepo] 解析到 ${parsed.length} 个图层(${sw.elapsedMilliseconds}ms): ${parsed.keys.join(', ')}',
|
||
);
|
||
if (parsed.isEmpty) {
|
||
debugPrint('⚠️ [StationLayerRepo] 解析为空,保持内置兜底');
|
||
return;
|
||
}
|
||
// 数量防护:动态解析出的图层数若明显少于内置兜底(如网络截断只解析到个别图层),
|
||
// 直接整体替换会让其余图层凭空消失。改用“兜底打底 + 动态覆盖同名”的合并策略,
|
||
// 既跟上后端最新范围,又保证已有图层不会因一次异常响应而集体消失。
|
||
if (parsed.length < kStationLayerBounds.length) {
|
||
debugPrint(
|
||
'⚠️ [StationLayerRepo] 动态解析图层数(${parsed.length}) < 兜底(${kStationLayerBounds.length}),采用合并策略防止图层消失',
|
||
);
|
||
_bounds = {...kStationLayerBounds, ...parsed};
|
||
} else {
|
||
_bounds = parsed;
|
||
debugPrint('✅ [StationLayerRepo] 已切换为动态范围');
|
||
}
|
||
_dynamicLoaded = true;
|
||
_notify();
|
||
} catch (e) {
|
||
// 保持兜底,不打断地图
|
||
debugPrint(
|
||
'❌ [StationLayerRepo] GetCapabilities 失败(${sw.elapsedMilliseconds}ms),保持兜底: $e',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 基于当前范围表构建全部场站叠加图层(滑到哪个场站区域就叠加哪个)
|
||
List<TileLayer> buildLayers({double minZoom = 16, double maxZoom = 21}) {
|
||
return _bounds.entries.map((entry) {
|
||
// 自愈兜底:万一范围表里仍残留非法 bounds(历史污染等),回退到内置兜底的同名范围,
|
||
// 避免生成“框不住任何瓦片”的坏图层导致该场站整体不显示
|
||
final b = _isValidLayerBounds(entry.value)
|
||
? entry.value
|
||
: (kStationLayerBounds[entry.key] ?? entry.value);
|
||
return TileLayer(
|
||
tileProvider: GeoWmsProvider(
|
||
layerName: entry.key,
|
||
bounds: b,
|
||
maxLevel: maxZoom.toInt(),
|
||
),
|
||
userAgentPackageName: 'com.example.app',
|
||
minZoom: minZoom,
|
||
maxZoom: maxZoom,
|
||
maxNativeZoom: maxZoom.toInt(),
|
||
tileBounds: b,
|
||
);
|
||
}).toList();
|
||
}
|
||
}
|
||
|
||
/// 校验图层范围是否为合法且有意义的经纬度矩形:
|
||
/// - 纬度 ∈ [-90,90]、经度 ∈ [-180,180]
|
||
/// - 南北/东西跨度 > 0 且 ≤ 5°(场站地块都是几百米级小范围,跨度过大必是投影错误/脏数据)
|
||
/// 非法范围一旦混入范围表,会让 getImage 的相交判断恒为 false → 瓦片全透明 → 图层“整体消失”,
|
||
/// 这正是“放大也不出来、只能退出重登(页面重建重置图层列表)才恢复”的根因。
|
||
bool _isValidLayerBounds(LatLngBounds b) {
|
||
final s = b.south;
|
||
final n = b.north;
|
||
final w = b.west;
|
||
final e = b.east;
|
||
if (s < -90 || s > 90 || n < -90 || n > 90) return false;
|
||
if (w < -180 || w > 180 || e < -180 || e > 180) return false;
|
||
final dLat = n - s;
|
||
final dLng = e - w;
|
||
if (dLat <= 0 || dLng <= 0) return false;
|
||
if (dLat > 5 || dLng > 5) return false;
|
||
return true;
|
||
}
|
||
|
||
/// 解析 WMS GetCapabilities,提取 workSpace 下每个图层的 name → EPSG:4326 范围。
|
||
/// 同时兼容 1.1.1(LatLongBoundingBox)与 1.3.0(EX_GeographicBoundingBox)。
|
||
Map<String, LatLngBounds> parseStationLayerBounds(String xmlStr) {
|
||
final result = <String, LatLngBounds>{};
|
||
try {
|
||
final doc = XmlDocument.parse(xmlStr);
|
||
for (final layer in doc.findAllElements('Layer')) {
|
||
final nameEls = layer.findElements('Name');
|
||
if (nameEls.isEmpty) continue;
|
||
final rawName = nameEls.first.innerText.trim();
|
||
// 兼容两种命名:全局端点返回 workSpace:xxx;工作区端点可能只返回裸名 xxx
|
||
final String name;
|
||
if (rawName.startsWith('$kGeoWorkspace:')) {
|
||
name = rawName;
|
||
} else if (!rawName.contains(':')) {
|
||
name = '$kGeoWorkspace:$rawName';
|
||
} else {
|
||
continue; // 其它工作区图层,跳过
|
||
}
|
||
|
||
LatLngBounds? b;
|
||
|
||
// WMS 1.1.1
|
||
final bbEls = layer.findElements('LatLongBoundingBox');
|
||
if (bbEls.isNotEmpty) {
|
||
final bb = bbEls.first;
|
||
final minx = double.tryParse(bb.getAttribute('minx') ?? '');
|
||
final miny = double.tryParse(bb.getAttribute('miny') ?? '');
|
||
final maxx = double.tryParse(bb.getAttribute('maxx') ?? '');
|
||
final maxy = double.tryParse(bb.getAttribute('maxy') ?? '');
|
||
if (minx != null &&
|
||
miny != null &&
|
||
maxx != null &&
|
||
maxy != null &&
|
||
maxx > minx &&
|
||
maxy > miny) {
|
||
b = LatLngBounds(LatLng(miny, minx), LatLng(maxy, maxx));
|
||
}
|
||
}
|
||
|
||
// WMS 1.3.0 兜底
|
||
if (b == null) {
|
||
final exEls = layer.findElements('EX_GeographicBoundingBox');
|
||
if (exEls.isNotEmpty) {
|
||
final ex = exEls.first;
|
||
double? read(String tag) {
|
||
final els = ex.findElements(tag);
|
||
return els.isEmpty
|
||
? null
|
||
: double.tryParse(els.first.innerText.trim());
|
||
}
|
||
|
||
final w = read('westBoundLongitude');
|
||
final e = read('eastBoundLongitude');
|
||
final n = read('northBoundLatitude');
|
||
final s = read('southBoundLatitude');
|
||
if (w != null && e != null && n != null && s != null && e > w && n > s) {
|
||
b = LatLngBounds(LatLng(s, w), LatLng(n, e));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (b != null) {
|
||
if (_isValidLayerBounds(b)) {
|
||
result[name] = b;
|
||
} else {
|
||
// 范围非法(经纬度越界 / 跨度过大或为 0)→ 丢弃该图层,
|
||
// 避免污染范围表导致瓦片框不住、图层整体消失
|
||
debugPrint(
|
||
'⚠️ [StationLayerRepo] 图层 $name 范围非法已丢弃: S=${b.south} N=${b.north} W=${b.west} E=${b.east}',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {
|
||
// 解析失败返回空表,调用方保持兜底
|
||
}
|
||
return result;
|
||
}
|