Files
flutterApp/lib/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart

596 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}