MotorSignal-App/lib/services/alerts/us_speed_camera_database.dart

189 lines
5.9 KiB
Dart

import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:geolocator/geolocator.dart';
class UsSpeedCamera {
final String id;
final double latitude;
final double longitude;
final String roadName;
final int? speedLimitMph;
final String type; // 'speed_camera', 'red_light_camera', 'speed_trap', 'average_speed_zone'
final String state;
final String? city;
final double? direction; // Heading in degrees (0-360)
final double? distanceMeters; // Transient distance computed during query
const UsSpeedCamera({
required this.id,
required this.latitude,
required this.longitude,
required this.roadName,
this.speedLimitMph,
this.type = 'speed_camera',
required this.state,
this.city,
this.direction,
this.distanceMeters,
});
factory UsSpeedCamera.fromJson(Map<String, dynamic> json) {
return UsSpeedCamera(
id: json['id']?.toString() ?? '',
latitude: (json['lat'] as num).toDouble(),
longitude: (json['lng'] as num).toDouble(),
roadName: json['road']?.toString() ?? 'Enforcement Zone',
speedLimitMph: json['limit'] != null ? (json['limit'] as num).toInt() : null,
type: json['type']?.toString() ?? 'speed_camera',
state: json['state']?.toString() ?? 'US',
city: json['city']?.toString(),
direction: json['direction'] != null ? (json['direction'] as num).toDouble() : null,
);
}
UsSpeedCamera copyWithDistance(double dist) {
return UsSpeedCamera(
id: id,
latitude: latitude,
longitude: longitude,
roadName: roadName,
speedLimitMph: speedLimitMph,
type: type,
state: state,
city: city,
direction: direction,
distanceMeters: dist,
);
}
}
class UsSpeedCameraDatabase {
static final UsSpeedCameraDatabase _instance = UsSpeedCameraDatabase._internal();
static UsSpeedCameraDatabase get instance => _instance;
UsSpeedCameraDatabase._internal();
final List<UsSpeedCamera> _allCameras = [];
final Map<String, List<UsSpeedCamera>> _spatialGrid = {};
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
int get totalCount => _allCameras.length;
Set<String> get supportedStates => _allCameras.map((c) => c.state).toSet();
/// Key format: "${(lat * 10).floor()}_${(lng * 10).floor()}" (approx 0.1 deg ~ 10 km grid)
static String _gridKey(double lat, double lng) {
return '${(lat * 10).floor()}_${(lng * 10).floor()}';
}
/// Initializes the database from an optional custom JSON string or by loading
/// the bundled asset `assets/data/us_speed_cameras.json`.
Future<void> initialize({String? jsonString}) async {
if (_isInitialized && jsonString == null) return;
try {
String data = jsonString ?? '';
if (data.isEmpty) {
try {
data = await rootBundle.loadString('assets/data/us_speed_cameras.json');
} catch (e) {
if (kDebugMode) {
debugPrint('[UsSpeedCameraDatabase] Asset load fallback: $e');
}
}
}
if (data.isNotEmpty) {
final List<dynamic> parsed = json.decode(data);
loadFromJsonList(parsed);
}
_isInitialized = true;
} catch (e) {
if (kDebugMode) {
debugPrint('[UsSpeedCameraDatabase] Initialization error: $e');
}
}
}
/// Loads camera records directly from a parsed JSON list
void loadFromJsonList(List<dynamic> jsonList) {
_allCameras.clear();
_spatialGrid.clear();
for (var item in jsonList) {
if (item is Map<String, dynamic>) {
final camera = UsSpeedCamera.fromJson(item);
if (camera.id.isNotEmpty) {
_allCameras.add(camera);
final key = _gridKey(camera.latitude, camera.longitude);
_spatialGrid.putIfAbsent(key, () => []).add(camera);
}
}
}
_isInitialized = true;
if (kDebugMode) {
debugPrint('[UsSpeedCameraDatabase] Loaded ${_allCameras.length} US speed cameras into ${_spatialGrid.length} spatial grid cells.');
}
}
/// Spatial search: Finds all cameras within [radiusMeters] of [latitude, longitude].
/// Utilizes the spatial grid index for sub-millisecond query performance.
List<UsSpeedCamera> findCamerasNear({
required double latitude,
required double longitude,
required double radiusMeters,
}) {
if (_allCameras.isEmpty) return [];
// Calculate latitude and longitude search boundaries
final latOffset = radiusMeters / 111320.0;
final cosLat = math.cos(latitude * math.pi / 180.0);
final lngOffset = radiusMeters / (111320.0 * (cosLat.abs() > 0.001 ? cosLat.abs() : 0.001));
final minLatBucket = ((latitude - latOffset) * 10).floor();
final maxLatBucket = ((latitude + latOffset) * 10).floor();
final minLngBucket = ((longitude - lngOffset) * 10).floor();
final maxLngBucket = ((longitude + lngOffset) * 10).floor();
final List<UsSpeedCamera> results = [];
final Set<String> checkedIds = {};
for (int latB = minLatBucket; latB <= maxLatBucket; latB++) {
for (int lngB = minLngBucket; lngB <= maxLngBucket; lngB++) {
final key = '${latB}_$lngB';
final candidates = _spatialGrid[key];
if (candidates == null) continue;
for (var camera in candidates) {
if (checkedIds.contains(camera.id)) continue;
checkedIds.add(camera.id);
final dist = Geolocator.distanceBetween(
latitude,
longitude,
camera.latitude,
camera.longitude,
);
if (dist <= radiusMeters) {
results.add(camera.copyWithDistance(dist));
}
}
}
}
results.sort((a, b) => (a.distanceMeters ?? 0).compareTo(b.distanceMeters ?? 0));
return results;
}
/// Resets and clears the in-memory database (useful for testing)
void reset() {
_allCameras.clear();
_spatialGrid.clear();
_isInitialized = false;
}
}