2 Commits

Author SHA1 Message Date
Nedifinita
1b05a6092c perf 2025-09-24 17:02:04 +08:00
Nedifinita
5141af58ac feat: optimize map zoom calculation and added location update 2025-09-24 16:31:57 +08:00
3 changed files with 263 additions and 54 deletions

View File

@@ -1,10 +1,12 @@
import 'dart:math' as math;
import 'dart:isolate';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:lbjconsole/models/merged_record.dart'; import '../models/merged_record.dart';
import 'package:lbjconsole/services/database_service.dart'; import '../services/database_service.dart';
import 'package:lbjconsole/models/train_record.dart'; import '../models/train_record.dart';
import 'package:lbjconsole/services/merge_service.dart'; import '../services/merge_service.dart';
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
final Function(bool isEditing) onEditModeChanged; final Function(bool isEditing) onEditModeChanged;
@@ -30,6 +32,9 @@ class HistoryScreenState extends State<HistoryScreen> {
bool _isAtTop = true; bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings(); MergeSettings _mergeSettings = MergeSettings();
final Map<String, double> _mapOptimalZoom = {};
final Map<String, bool> _mapCalculating = {};
int getSelectedCount() => _selectedRecords.length; int getSelectedCount() => _selectedRecords.length;
Set<String> getSelectedRecordIds() => _selectedRecords; Set<String> getSelectedRecordIds() => _selectedRecords;
List<Object> getDisplayItems() => _displayItems; List<Object> getDisplayItems() => _displayItems;
@@ -256,6 +261,66 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
} }
double _calculateOptimalZoom(List<LatLng> positions, {double containerWidth = 400, double containerHeight = 220}) {
if (positions.isEmpty) return 15.0;
if (positions.length == 1) return 17.0;
double minLat = positions[0].latitude;
double maxLat = positions[0].latitude;
double minLng = positions[0].longitude;
double maxLng = positions[0].longitude;
for (final pos in positions) {
minLat = math.min(minLat, pos.latitude);
maxLat = math.max(maxLat, pos.latitude);
minLng = math.min(minLng, pos.longitude);
maxLng = math.max(maxLng, pos.longitude);
}
double latToY(double lat) {
final latRad = lat * math.pi / 180.0;
return math.log(math.tan(latRad) + 1.0/math.cos(latRad));
}
double lngToX(double lng) {
return lng * math.pi / 180.0;
}
final minX = lngToX(minLng);
final maxX = lngToX(maxLng);
final minY = latToY(minLat);
final maxY = latToY(maxLat);
const worldSize = 2.0 * math.pi;
final widthWorld = (maxX - minX) / worldSize;
final heightWorld = (maxY - minY) / worldSize;
const paddingRatio = 0.8;
final widthZoom = math.log((containerWidth * paddingRatio) / (widthWorld * 256.0)) / math.log(2.0);
final heightZoom = math.log((containerHeight * paddingRatio) / (heightWorld * 256.0)) / math.log(2.0);
final optimalZoom = math.min(widthZoom, heightZoom);
return math.max(1.0, math.min(20.0, optimalZoom));
}
double _calculateDistance(LatLng pos1, LatLng pos2) {
const earthRadius = 6371000;
final lat1 = pos1.latitude * math.pi / 180;
final lat2 = pos2.latitude * math.pi / 180;
final deltaLat = (pos2.latitude - pos1.latitude) * math.pi / 180;
final deltaLng = (pos2.longitude - pos1.longitude) * math.pi / 180;
final a = math.sin(deltaLat / 2) * math.sin(deltaLat / 2) +
math.cos(lat1) * math.cos(lat2) *
math.sin(deltaLng / 2) * math.sin(deltaLng / 2);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return earthRadius * c;
}
String _getLocationInfo(TrainRecord record) { String _getLocationInfo(TrainRecord record) {
List<String> parts = []; List<String> parts = [];
if (record.route.isNotEmpty && record.route != "<NUL>") if (record.route.isNotEmpty && record.route != "<NUL>")
@@ -275,7 +340,25 @@ class HistoryScreenState extends State<HistoryScreen> {
.whereType<LatLng>() .whereType<LatLng>()
.toList(); .toList();
if (positions.isEmpty) return const SizedBox.shrink(); if (positions.isEmpty) return const SizedBox.shrink();
final mapId = records.map((r) => r.uniqueId).join('_');
final bounds = LatLngBounds.fromPoints(positions); final bounds = LatLngBounds.fromPoints(positions);
if (!_mapOptimalZoom.containsKey(mapId) && !(_mapCalculating[mapId] ?? false)) {
_mapCalculating[mapId] = true;
_calculateOptimalZoomAsync(positions, containerWidth: 400, containerHeight: 220).then((optimalZoom) {
if (mounted) {
setState(() {
_mapOptimalZoom[mapId] = optimalZoom;
_mapCalculating[mapId] = false;
});
}
});
}
final zoomLevel = _mapOptimalZoom[mapId] ?? _getDefaultZoom(positions);
return Column(children: [ return Column(children: [
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
@@ -286,10 +369,9 @@ class HistoryScreenState extends State<HistoryScreen> {
child: FlutterMap( child: FlutterMap(
options: MapOptions( options: MapOptions(
initialCenter: bounds.center, initialCenter: bounds.center,
initialZoom: 10, initialZoom: zoomLevel,
minZoom: 5, minZoom: 5,
maxZoom: 18, maxZoom: 18),
cameraConstraint: CameraConstraint.contain(bounds: bounds)),
children: [ children: [
TileLayer( TileLayer(
urlTemplate: urlTemplate:
@@ -314,6 +396,12 @@ class HistoryScreenState extends State<HistoryScreen> {
]); ]);
} }
double _getDefaultZoom(List<LatLng> positions) {
if (positions.length == 1) return 15.0;
if (positions.length < 10) return 12.0;
return 10.0;
}
Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) { Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) {
final isSelected = _selectedRecords.contains(record.uniqueId); final isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded = final isExpanded =
@@ -495,19 +583,29 @@ class HistoryScreenState extends State<HistoryScreen> {
Widget _buildExpandedContent(TrainRecord record) { Widget _buildExpandedContent(TrainRecord record) {
final position = _parsePosition(record.positionInfo); final position = _parsePosition(record.positionInfo);
if (position == null) return const SizedBox.shrink();
return FutureBuilder<double>(
future: Future(() => _calculateOptimalZoom([position], containerWidth: 400, containerHeight: 220)),
builder: (context, zoomSnapshot) {
if (!zoomSnapshot.hasData) {
return const SizedBox.shrink();
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (position != null)
Column(children: [
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
height: 220, height: 220,
width: double.infinity,
margin: const EdgeInsets.symmetric(vertical: 4), margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Colors.grey[900]), color: Colors.grey[900]),
child: FlutterMap( child: FlutterMap(
options: options: MapOptions(
MapOptions(initialCenter: position, initialZoom: 15.0), initialCenter: position,
initialZoom: zoomSnapshot.data!
),
children: [ children: [
TileLayer( TileLayer(
urlTemplate: urlTemplate:
@@ -528,8 +626,9 @@ class HistoryScreenState extends State<HistoryScreen> {
color: Colors.white, size: 20))) color: Colors.white, size: 20)))
]) ])
])) ]))
])
]); ]);
},
);
} }
LatLng? _parsePosition(String? positionInfo) { LatLng? _parsePosition(String? positionInfo) {
@@ -566,4 +665,104 @@ class HistoryScreenState extends State<HistoryScreen> {
return null; return null;
} }
} }
Future<_BoundaryBox> _calculateBoundaryBoxParallel(List<LatLng> positions) async {
if (positions.isEmpty) {
return _BoundaryBox(0, 0, 0, 0);
}
if (positions.length < 100) {
return _calculateBoundaryBoxIsolate(positions);
}
final chunkSize = (positions.length / 4).ceil();
final chunks = <List<LatLng>>[];
for (int i = 0; i < positions.length; i += chunkSize) {
final end = math.min(i + chunkSize, positions.length);
chunks.add(positions.sublist(i, end));
}
final results = await Future.wait(
chunks.map((chunk) => Isolate.run(() => _calculateBoundaryBoxIsolate(chunk)))
);
double minLat = results[0].minLat;
double maxLat = results[0].maxLat;
double minLng = results[0].minLng;
double maxLng = results[0].maxLng;
for (final box in results.skip(1)) {
minLat = math.min(minLat, box.minLat);
maxLat = math.max(maxLat, box.maxLat);
minLng = math.min(minLng, box.minLng);
maxLng = math.max(maxLng, box.maxLng);
}
return _BoundaryBox(minLat, maxLat, minLng, maxLng);
}
Future<double> _calculateOptimalZoomAsync(List<LatLng> positions, {required double containerWidth, required double containerHeight}) async {
if (positions.isEmpty) return 15.0;
if (positions.length == 1) return 17.0;
final boundaryBox = await _calculateBoundaryBoxParallel(positions);
double latToY(double lat) {
final latRad = lat * math.pi / 180.0;
return math.log(math.tan(latRad) + 1.0/math.cos(latRad));
}
double lngToX(double lng) {
return lng * math.pi / 180.0;
}
final minX = lngToX(boundaryBox.minLng);
final maxX = lngToX(boundaryBox.maxLng);
final minY = latToY(boundaryBox.minLat);
final maxY = latToY(boundaryBox.maxLat);
const worldSize = 2.0 * math.pi;
final widthWorld = (maxX - minX) / worldSize;
final heightWorld = (maxY - minY) / worldSize;
const paddingRatio = 0.8;
final widthZoom = math.log((containerWidth * paddingRatio) / (widthWorld * 256.0)) / math.log(2.0);
final heightZoom = math.log((containerHeight * paddingRatio) / (heightWorld * 256.0)) / math.log(2.0);
final optimalZoom = math.min(widthZoom, heightZoom);
return math.max(1.0, math.min(20.0, optimalZoom));
}
}
class _BoundaryBox {
final double minLat;
final double maxLat;
final double minLng;
final double maxLng;
_BoundaryBox(this.minLat, this.maxLat, this.minLng, this.maxLng);
}
_BoundaryBox _calculateBoundaryBoxIsolate(List<LatLng> positions) {
if (positions.isEmpty) {
return _BoundaryBox(0, 0, 0, 0);
}
double minLat = positions[0].latitude;
double maxLat = positions[0].latitude;
double minLng = positions[0].longitude;
double maxLng = positions[0].longitude;
for (final pos in positions) {
minLat = math.min(minLat, pos.latitude);
maxLat = math.max(maxLat, pos.latitude);
minLng = math.min(minLng, pos.longitude);
maxLng = math.max(maxLng, pos.longitude);
}
return _BoundaryBox(minLat, maxLat, minLng, maxLng);
} }

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -26,8 +27,8 @@ class _MapScreenState extends State<MapScreen> {
bool _isMapInitialized = false; bool _isMapInitialized = false;
bool _isFollowingLocation = false; bool _isFollowingLocation = false;
bool _isLocationPermissionGranted = false; bool _isLocationPermissionGranted = false;
Timer? _locationTimer;
static const LatLng _defaultPosition = LatLng(39.9042, 116.4074);
@override @override
void initState() { void initState() {
@@ -35,12 +36,13 @@ class _MapScreenState extends State<MapScreen> {
_initializeMap(); _initializeMap();
_loadTrainRecords(); _loadTrainRecords();
_loadSettings(); _loadSettings();
_requestLocationPermission(); _startLocationUpdates();
} }
@override @override
void dispose() { void dispose() {
_saveSettings(); _saveSettings();
_locationTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -49,6 +51,9 @@ class _MapScreenState extends State<MapScreen> {
Future<void> _requestLocationPermission() async { Future<void> _requestLocationPermission() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请开启定位服务')),
);
return; return;
} }
@@ -58,6 +63,9 @@ class _MapScreenState extends State<MapScreen> {
} }
if (permission == LocationPermission.deniedForever) { if (permission == LocationPermission.deniedForever) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('定位权限被拒绝,请在设置中开启')),
);
return; return;
} }
@@ -78,12 +86,39 @@ class _MapScreenState extends State<MapScreen> {
_userLocation = LatLng(position.latitude, position.longitude); _userLocation = LatLng(position.latitude, position.longitude);
}); });
if (!_isMapInitialized && _userLocation != null) { } catch (e) {
_mapController.move(_userLocation!, _currentZoom);
} }
} catch (e) {}
} }
void _startLocationUpdates() {
_requestLocationPermission();
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (_isLocationPermissionGranted) {
_getCurrentLocation();
}
});
}
Future<void> _forceUpdateLocation() async {
try {
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.best,
);
final newLocation = LatLng(position.latitude, position.longitude);
setState(() {
_userLocation = newLocation;
});
_mapController.move(newLocation, 15.0);
} catch (e) {
}
}
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
try { try {
final settings = await DatabaseService.instance.getAllSettings(); final settings = await DatabaseService.instance.getAllSettings();
@@ -159,13 +194,12 @@ class _MapScreenState extends State<MapScreen> {
} else if (_lastTrainLocation != null) { } else if (_lastTrainLocation != null) {
targetLocation = _lastTrainLocation; targetLocation = _lastTrainLocation;
} else { } else {
targetLocation = _defaultPosition; _isMapInitialized = true;
return;
} }
WidgetsBinding.instance.addPostFrameCallback((_) {
_centerMap(targetLocation!, zoom: _currentZoom); _centerMap(targetLocation!, zoom: _currentZoom);
_isMapInitialized = true; _isMapInitialized = true;
});
} }
void _centerMap(LatLng location, {double? zoom}) { void _centerMap(LatLng location, {double? zoom}) {
@@ -313,7 +347,7 @@ class _MapScreenState extends State<MapScreen> {
} }
void _centerToMyLocation() { void _centerToMyLocation() {
_centerMap(_lastTrainLocation ?? _defaultPosition, zoom: 15.0); _centerMap(_lastTrainLocation ?? const LatLng(39.9042, 116.4074), zoom: 15.0);
} }
void _centerToLastTrain() { void _centerToLastTrain() {
@@ -537,11 +571,12 @@ class _MapScreenState extends State<MapScreen> {
FlutterMap( FlutterMap(
mapController: _mapController, mapController: _mapController,
options: MapOptions( options: MapOptions(
initialCenter: _lastTrainLocation ?? _defaultPosition, initialCenter: _lastTrainLocation ?? const LatLng(39.9042, 116.4074),
initialZoom: _currentZoom, initialZoom: _currentZoom,
initialRotation: _currentRotation, initialRotation: _currentRotation,
minZoom: 4.0, minZoom: 4.0,
maxZoom: 18.0, maxZoom: 18.0,
onPositionChanged: (MapCamera camera, bool hasGesture) { onPositionChanged: (MapCamera camera, bool hasGesture) {
if (hasGesture) { if (hasGesture) {
setState(() { setState(() {
@@ -552,28 +587,6 @@ class _MapScreenState extends State<MapScreen> {
_saveSettings(); _saveSettings();
} }
}, },
onTap: (_, point) {
for (final record in _trainRecords) {
final coords = record.getCoordinates();
final dmsCoords = _parseDmsCoordinate(record.positionInfo);
LatLng? recordPosition;
if (dmsCoords != null) {
recordPosition = dmsCoords;
} else if (coords['lat'] != 0.0 && coords['lng'] != 0.0) {
recordPosition = LatLng(coords['lat']!, coords['lng']!);
}
if (recordPosition != null) {
final distance = const Distance()
.as(LengthUnit.Meter, recordPosition, point);
if (distance < 50) {
_showTrainDetailsDialog(record, recordPosition);
break;
}
}
}
},
), ),
children: [ children: [
TileLayer( TileLayer(
@@ -622,10 +635,7 @@ class _MapScreenState extends State<MapScreen> {
heroTag: 'myLocation', heroTag: 'myLocation',
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: const Color(0xFF1E1E1E),
onPressed: () { onPressed: () {
_getCurrentLocation(); _forceUpdateLocation();
if (_userLocation != null) {
_centerMap(_userLocation!, zoom: 15.0);
}
}, },
child: const Icon(Icons.my_location, color: Colors.white), child: const Icon(Icons.my_location, color: Colors.white),
), ),

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.1.5-flutter version: 0.1.6-flutter
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4