189 lines
5.9 KiB
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;
|
|
}
|
|
}
|