feat(tactical-app): full sovereign military suite in Flutter with first-run onboarding, artillery ballistics, HLZ assessment, minefield breach, isochrone, symbols, and overlays
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../models/military_operations_models.dart';
|
||||
import 'dem_tile_elevation_service.dart';
|
||||
import 'military_grid_utils.dart';
|
||||
|
||||
/// Sovereign On-Device Ballistic Trajectory & Artillery Fire Mission Engine
|
||||
class ArtilleryBallisticsEngine {
|
||||
ArtilleryBallisticsEngine._();
|
||||
|
||||
static const double g = 9.80665; // Earth gravity m/s^2
|
||||
|
||||
/// Compute high-precision ballistic firing solution and check terrain crest clearance
|
||||
static Future<ArtilleryFiringSolution> calculateFireMission({
|
||||
required ArtilleryWeaponSystem weapon,
|
||||
required LatLng gunPos,
|
||||
required LatLng targetPos,
|
||||
bool highAngle = false,
|
||||
}) async {
|
||||
// 1. Calculate Geodesic Range & Azimuth
|
||||
final distanceMeters = MilitaryGridUtils.haversineDistance(
|
||||
gunPos.latitude,
|
||||
gunPos.longitude,
|
||||
targetPos.latitude,
|
||||
targetPos.longitude,
|
||||
);
|
||||
|
||||
final azimuthDeg = MilitaryGridUtils.calculateBearing(
|
||||
gunPos.latitude,
|
||||
gunPos.longitude,
|
||||
targetPos.latitude,
|
||||
targetPos.longitude,
|
||||
);
|
||||
final azimuthMilsNato = (azimuthDeg / 360.0) * 6400.0;
|
||||
|
||||
// 2. Query Ground Elevation for Gun & Target via Satellite DEM
|
||||
final gunGround = await DemTileElevationService.getElevation(gunPos.latitude, gunPos.longitude);
|
||||
final targetGround = await DemTileElevationService.getElevation(targetPos.latitude, targetPos.longitude);
|
||||
final heightDelta = targetGround - gunGround;
|
||||
|
||||
final v0 = weapon.muzzleVelocityMps;
|
||||
|
||||
// 3. Solve Ballistic Arc Quadrant Elevation (QE)
|
||||
final x = distanceMeters;
|
||||
final y = heightDelta;
|
||||
|
||||
final v0sq = v0 * v0;
|
||||
final underRoot = (v0sq * v0sq) - g * (g * x * x + 2 * y * v0sq);
|
||||
|
||||
double qeRad = 0.0;
|
||||
if (underRoot < 0) {
|
||||
// Out of physical ballistic reach at this velocity, use max range angle 45 deg
|
||||
qeRad = (45.0 * math.pi) / 180.0;
|
||||
} else {
|
||||
final root = math.sqrt(underRoot);
|
||||
if (highAngle) {
|
||||
qeRad = math.atan((v0sq + root) / (g * x));
|
||||
} else {
|
||||
qeRad = math.atan((v0sq - root) / (g * x));
|
||||
}
|
||||
}
|
||||
|
||||
final qeDeg = (qeRad * 180.0) / math.pi;
|
||||
final qeMilsNato = (qeDeg / 360.0) * 6400.0;
|
||||
|
||||
// 4. Time of Flight & Apogee (Vertex)
|
||||
final v0x = v0 * math.cos(qeRad);
|
||||
final v0y = v0 * math.sin(qeRad);
|
||||
final timeOfFlight = v0x > 0 ? x / v0x : 0.0;
|
||||
final apogeeTime = v0y / g;
|
||||
final apogeeAlt = gunGround + (v0y * apogeeTime - 0.5 * g * apogeeTime * apogeeTime);
|
||||
|
||||
// 5. Generate Trajectory Profile with Terrain Clearance check
|
||||
const int sampleCount = 60;
|
||||
final List<BallisticTrajectoryPoint> profile = [];
|
||||
bool isCrestClear = true;
|
||||
double minClearance = double.infinity;
|
||||
|
||||
for (int i = 0; i <= sampleCount; i++) {
|
||||
final frac = i / sampleCount;
|
||||
final curDist = x * frac;
|
||||
final curTime = timeOfFlight * frac;
|
||||
|
||||
// Projectile altitude above sea level
|
||||
final projAlt = gunGround + (v0y * curTime - 0.5 * g * curTime * curTime);
|
||||
|
||||
final curLat = gunPos.latitude + (targetPos.latitude - gunPos.latitude) * frac;
|
||||
final curLng = gunPos.longitude + (targetPos.longitude - gunPos.longitude) * frac;
|
||||
|
||||
final curTerrain = await DemTileElevationService.getElevation(curLat, curLng);
|
||||
|
||||
final clearance = projAlt - curTerrain;
|
||||
if (clearance < minClearance) {
|
||||
minClearance = clearance;
|
||||
}
|
||||
if (i > 2 && i < sampleCount - 2 && clearance <= 0) {
|
||||
isCrestClear = false;
|
||||
}
|
||||
|
||||
profile.add(BallisticTrajectoryPoint(
|
||||
distanceMeters: curDist,
|
||||
altitudeMeters: projAlt,
|
||||
groundElevationMeters: curTerrain,
|
||||
coordinate: LatLng(curLat, curLng),
|
||||
));
|
||||
}
|
||||
|
||||
return ArtilleryFiringSolution(
|
||||
weapon: weapon,
|
||||
gunPosition: gunPos,
|
||||
targetPosition: targetPos,
|
||||
distanceMeters: distanceMeters,
|
||||
azimuthDeg: azimuthDeg,
|
||||
azimuthMilsNato: azimuthMilsNato,
|
||||
quadrantElevationDeg: qeDeg,
|
||||
quadrantElevationMilsNato: qeMilsNato,
|
||||
timeOfFlightSeconds: timeOfFlight,
|
||||
apogeeAltitudeMeters: apogeeAlt,
|
||||
isCrestClear: isCrestClear,
|
||||
minCrestClearanceMeters: minClearance,
|
||||
trajectoryProfile: profile,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -183,4 +182,9 @@ class DemTileElevationService {
|
||||
}
|
||||
return JordanDemSurface.elevationAt(lat, lng);
|
||||
}
|
||||
|
||||
/// Standard alias for asynchronous elevation query
|
||||
static Future<double> getElevation(double lat, double lng, {int zoom = 12}) {
|
||||
return getElevationAsync(lat, lng, zoom: zoom);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../models/military_operations_models.dart';
|
||||
import 'dem_tile_elevation_service.dart';
|
||||
|
||||
/// Sovereign On-Device Helicopter Landing Zone (HLZ) Suitability Engine
|
||||
class HlzAssessmentEngine {
|
||||
HlzAssessmentEngine._();
|
||||
|
||||
/// Assess proposed landing site terrain slope, obstacle clearance, and landing corridors
|
||||
static Future<HlzAssessmentResult> assessLandingZone({
|
||||
required LatLng center,
|
||||
required HelicopterType helicopterType,
|
||||
double approachAzimuthDeg = 0.0,
|
||||
}) async {
|
||||
// 1. Determine recommended pad radius based on helicopter airframe size
|
||||
double padRadiusM;
|
||||
double maxAllowableSlopePct;
|
||||
|
||||
switch (helicopterType) {
|
||||
case HelicopterType.lightUtility:
|
||||
padRadiusM = 25.0; // 50m diameter
|
||||
maxAllowableSlopePct = 15.0; // 15% slope max
|
||||
break;
|
||||
case HelicopterType.mediumLift:
|
||||
padRadiusM = 40.0; // 80m diameter (UH-60 / AH-64)
|
||||
maxAllowableSlopePct = 10.0; // 10% slope max
|
||||
break;
|
||||
case HelicopterType.heavyTransport:
|
||||
padRadiusM = 60.0; // 120m diameter (CH-47 Chinook)
|
||||
maxAllowableSlopePct = 7.0; // 7% slope max
|
||||
break;
|
||||
}
|
||||
|
||||
// 2. Query Center Elevation
|
||||
final centerElev = await DemTileElevationService.getElevation(center.latitude, center.longitude);
|
||||
|
||||
// 3. Sample 16 cardinal points around the perimeter to calculate maximum terrain slope
|
||||
final List<double> perimeterElevs = [];
|
||||
final List<LatLng> padBoundary = [];
|
||||
const int samplePoints = 16;
|
||||
|
||||
for (int i = 0; i < samplePoints; i++) {
|
||||
final angleRad = (i * 2 * math.pi) / samplePoints;
|
||||
final dLat = (padRadiusM / 6371000.0) * (180.0 / math.pi) * math.cos(angleRad);
|
||||
final dLng = (padRadiusM / (6371000.0 * math.cos(center.latitude * math.pi / 180.0))) * (180.0 / math.pi) * math.sin(angleRad);
|
||||
|
||||
final pLat = center.latitude + dLat;
|
||||
final pLng = center.longitude + dLng;
|
||||
padBoundary.add(LatLng(pLat, pLng));
|
||||
|
||||
final elev = await DemTileElevationService.getElevation(pLat, pLng);
|
||||
perimeterElevs.add(elev);
|
||||
}
|
||||
// Close polygon
|
||||
if (padBoundary.isNotEmpty) padBoundary.add(padBoundary.first);
|
||||
|
||||
// Calculate maximum slope percentage
|
||||
double maxSlope = 0.0;
|
||||
double slopeSum = 0.0;
|
||||
for (final pElev in perimeterElevs) {
|
||||
final slopePct = (pElev - centerElev).abs() / padRadiusM * 100.0;
|
||||
if (slopePct > maxSlope) maxSlope = slopePct;
|
||||
slopeSum += slopePct;
|
||||
}
|
||||
final avgSlope = slopeSum / perimeterElevs.length;
|
||||
|
||||
// 4. Generate 500m Approach/Departure Funnel
|
||||
final List<LatLng> funnel = [];
|
||||
const double funnelLengthM = 500.0;
|
||||
const double funnelWidthM = 120.0;
|
||||
|
||||
final approachRad = (approachAzimuthDeg * math.pi) / 180.0;
|
||||
final perpRad = approachRad + (math.pi / 2);
|
||||
|
||||
// Base point at pad edge
|
||||
final baseLat = center.latitude + (padRadiusM / 6371000.0) * (180.0 / math.pi) * math.cos(approachRad);
|
||||
final baseLng = center.longitude + (padRadiusM / (6371000.0 * math.cos(center.latitude * math.pi / 180.0))) * (180.0 / math.pi) * math.sin(approachRad);
|
||||
|
||||
// Funnel End Center
|
||||
final endCenterLat = center.latitude + (funnelLengthM / 6371000.0) * (180.0 / math.pi) * math.cos(approachRad);
|
||||
final endCenterLng = center.longitude + (funnelLengthM / (6371000.0 * math.cos(center.latitude * math.pi / 180.0))) * (180.0 / math.pi) * math.sin(approachRad);
|
||||
|
||||
// Funnel Left & Right
|
||||
final leftEndLat = endCenterLat + (funnelWidthM / 2 / 6371000.0) * (180.0 / math.pi) * math.cos(perpRad);
|
||||
final leftEndLng = endCenterLng + (funnelWidthM / 2 / (6371000.0 * math.cos(endCenterLat * math.pi / 180.0))) * (180.0 / math.pi) * math.sin(perpRad);
|
||||
|
||||
final rightEndLat = endCenterLat - (funnelWidthM / 2 / 6371000.0) * (180.0 / math.pi) * math.cos(perpRad);
|
||||
final rightEndLng = endCenterLng - (funnelWidthM / 2 / (6371000.0 * math.cos(endCenterLat * math.pi / 180.0))) * (180.0 / math.pi) * math.sin(perpRad);
|
||||
|
||||
funnel.addAll([
|
||||
LatLng(baseLat, baseLng),
|
||||
LatLng(leftEndLat, leftEndLng),
|
||||
LatLng(rightEndLat, rightEndLng),
|
||||
LatLng(baseLat, baseLng),
|
||||
]);
|
||||
|
||||
// 5. Check Obstacle Height in Funnel
|
||||
final endElev = await DemTileElevationService.getElevation(endCenterLat, endCenterLng);
|
||||
final funnelRise = endElev - centerElev;
|
||||
final isObstacleClear = funnelRise < 35.0; // Less than 35m rise over 500m approach
|
||||
|
||||
// 6. Grade Suitability
|
||||
final isSlopeAcceptable = maxSlope <= maxAllowableSlopePct;
|
||||
String grade;
|
||||
Color gradeColor;
|
||||
|
||||
if (isSlopeAcceptable && maxSlope < (maxAllowableSlopePct * 0.6) && isObstacleClear) {
|
||||
grade = 'صالح ومثالي (OPTIMAL GO)';
|
||||
gradeColor = const Color(0xFF10B981); // Emerald
|
||||
} else if (isSlopeAcceptable && isObstacleClear) {
|
||||
grade = 'مقبول بحذر (MARGINAL SLOW-GO)';
|
||||
gradeColor = const Color(0xFFF59E0B); // Amber
|
||||
} else {
|
||||
grade = 'غير صالح للهبوط (UNSUITABLE NO-GO)';
|
||||
gradeColor = const Color(0xFFEF4444); // Red
|
||||
}
|
||||
|
||||
return HlzAssessmentResult(
|
||||
center: center,
|
||||
helicopterType: helicopterType,
|
||||
groundElevationM: centerElev,
|
||||
maxSlopePercent: math.min(100.0, (maxSlope * 10).round() / 10.0),
|
||||
avgSlopePercent: (avgSlope * 10).round() / 10.0,
|
||||
recommendedClearanceRadiusM: padRadiusM,
|
||||
isSlopeAcceptable: isSlopeAcceptable,
|
||||
isObstacleClear: isObstacleClear,
|
||||
suitabilityGrade: grade,
|
||||
gradeColor: gradeColor,
|
||||
approachAzimuthDeg: approachAzimuthDeg,
|
||||
padBoundary: padBoundary,
|
||||
approachFunnel: funnel,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,56 @@ class MilitaryGridUtils {
|
||||
static const double _eSq = (_a * _a - _b * _b) / (_a * _a);
|
||||
static const double _ePrimeSq = (_a * _a - _b * _b) / (_b * _b);
|
||||
static const double _k0 = 0.9996; // UTM scale factor
|
||||
static const double earthRadiusM = 6371000.0;
|
||||
|
||||
/// Haversine Great Circle Distance in meters
|
||||
static double haversineDistance(double lat1, double lng1, double lat2, double lng2) {
|
||||
final dLat = (lat2 - lat1) * (math.pi / 180.0);
|
||||
final dLng = (lng2 - lng1) * (math.pi / 180.0);
|
||||
final a = math.sin(dLat / 2.0) * math.sin(dLat / 2.0) +
|
||||
math.cos(lat1 * math.pi / 180.0) *
|
||||
math.cos(lat2 * math.pi / 180.0) *
|
||||
math.sin(dLng / 2.0) *
|
||||
math.sin(dLng / 2.0);
|
||||
final c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a));
|
||||
return earthRadiusM * c;
|
||||
}
|
||||
|
||||
/// Initial Great Circle Bearing in degrees (0..360)
|
||||
static double calculateBearing(double lat1, double lng1, double lat2, double lng2) {
|
||||
final phi1 = lat1 * (math.pi / 180.0);
|
||||
final phi2 = lat2 * (math.pi / 180.0);
|
||||
final deltaLambda = (lng2 - lng1) * (math.pi / 180.0);
|
||||
|
||||
final y = math.sin(deltaLambda) * math.cos(phi2);
|
||||
final x = math.cos(phi1) * math.sin(phi2) -
|
||||
math.sin(phi1) * math.cos(phi2) * math.cos(deltaLambda);
|
||||
final theta = math.atan2(y, x);
|
||||
return (theta * (180.0 / math.pi) + 360.0) % 360.0;
|
||||
}
|
||||
|
||||
/// Convert Azimuth Degrees to Arabic Cardinal Name
|
||||
static String azimuthToCardinalArabic(double azimuthDeg) {
|
||||
final deg = (azimuthDeg % 360.0 + 360.0) % 360.0;
|
||||
if (deg >= 337.5 || deg < 22.5) return 'شمال (N)';
|
||||
if (deg >= 22.5 && deg < 67.5) return 'شمال شرق (NE)';
|
||||
if (deg >= 67.5 && deg < 112.5) return 'شرق (E)';
|
||||
if (deg >= 112.5 && deg < 157.5) return 'جنوب شرق (SE)';
|
||||
if (deg >= 157.5 && deg < 202.5) return 'جنوب (S)';
|
||||
if (deg >= 202.5 && deg < 247.5) return 'جنوب غرب (SW)';
|
||||
if (deg >= 247.5 && deg < 292.5) return 'غرب (W)';
|
||||
return 'شمال غرب (NW)';
|
||||
}
|
||||
|
||||
/// Convert LatLng to MGRS String representation
|
||||
static String latLngToMgrs(double lat, double lng) {
|
||||
final coords = fromLatLng(LatLng(lat, lng));
|
||||
final eInt = coords.easting.round() % 100000;
|
||||
final nInt = coords.northing.round() % 100000;
|
||||
final eStr = (eInt ~/ 10).toString().padLeft(4, '0');
|
||||
final nStr = (nInt ~/ 10).toString().padLeft(4, '0');
|
||||
return '${coords.zone}R YU $eStr $nStr';
|
||||
}
|
||||
|
||||
/// Convert WGS84 Lat/Lng to UTM Zone 36N Easting (شرقيات) and Northing (شماليات)
|
||||
static MilitaryCoordinates fromLatLng(LatLng latLng, {int zone = 36}) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../models/military_operations_models.dart';
|
||||
import 'military_grid_utils.dart';
|
||||
|
||||
/// Sovereign On-Device Minefield Threat & Breaching Corridor Engine
|
||||
class MinefieldEngine {
|
||||
MinefieldEngine._();
|
||||
|
||||
static const double earthRadiusM = 6371000.0;
|
||||
|
||||
/// Build Minefield Boundary Box, Density Calculation, and Safe Breaching Corridor
|
||||
static MinefieldZoneResult calculateMinefieldZone({
|
||||
required LatLng startPoint,
|
||||
required LatLng endPoint,
|
||||
required MinefieldType type,
|
||||
double widthMeters = 200.0,
|
||||
}) {
|
||||
// 1. Calculate Length & Azimuth
|
||||
final lengthMeters = MilitaryGridUtils.haversineDistance(
|
||||
startPoint.latitude,
|
||||
startPoint.longitude,
|
||||
endPoint.latitude,
|
||||
endPoint.longitude,
|
||||
);
|
||||
|
||||
final azimuthDeg = MilitaryGridUtils.calculateBearing(
|
||||
startPoint.latitude,
|
||||
startPoint.longitude,
|
||||
endPoint.latitude,
|
||||
endPoint.longitude,
|
||||
);
|
||||
|
||||
final azRad = (azimuthDeg * math.pi) / 180.0;
|
||||
final perpRad = azRad + (math.pi / 2.0);
|
||||
|
||||
final halfWidth = widthMeters / 2.0;
|
||||
|
||||
// Helper: offset lat/lng by distance and bearing
|
||||
LatLng offsetCoord(LatLng origin, double distM, double bearingRad) {
|
||||
final dLat = (distM / earthRadiusM) * (180.0 / math.pi) * math.cos(bearingRad);
|
||||
final dLng = (distM / (earthRadiusM * math.cos(origin.latitude * math.pi / 180.0))) * (180.0 / math.pi) * math.sin(bearingRad);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLng);
|
||||
}
|
||||
|
||||
// 2. Build 4 Corners of Minefield Polygon
|
||||
final p1 = offsetCoord(startPoint, halfWidth, perpRad);
|
||||
final p2 = offsetCoord(endPoint, halfWidth, perpRad);
|
||||
final p3 = offsetCoord(endPoint, halfWidth, perpRad + math.pi);
|
||||
final p4 = offsetCoord(startPoint, halfWidth, perpRad + math.pi);
|
||||
|
||||
final boundary = [p1, p2, p3, p4, p1];
|
||||
|
||||
// 3. Generate Safe Breaching Lane (الممر الآمن عبر الثغرة)
|
||||
// 16-meter wide swept corridor right through the middle
|
||||
const double breachWidthM = 16.0;
|
||||
final halfBreach = breachWidthM / 2.0;
|
||||
|
||||
final midStart = LatLng(
|
||||
(p1.latitude + p4.latitude) / 2.0,
|
||||
(p1.longitude + p4.longitude) / 2.0,
|
||||
);
|
||||
final midEnd = LatLng(
|
||||
(p2.latitude + p3.latitude) / 2.0,
|
||||
(p2.longitude + p3.longitude) / 2.0,
|
||||
);
|
||||
|
||||
final b1 = offsetCoord(midStart, halfBreach, perpRad);
|
||||
final b2 = offsetCoord(midEnd, halfBreach, perpRad);
|
||||
final b3 = offsetCoord(midEnd, halfBreach, perpRad + math.pi);
|
||||
final b4 = offsetCoord(midStart, halfBreach, perpRad + math.pi);
|
||||
|
||||
final breachPolygon = [b1, b2, b3, b4, b1];
|
||||
final breachCenterline = [midStart, midEnd];
|
||||
|
||||
// 4. Estimate Mines Count based on standard doctrine density
|
||||
double densityPerM2;
|
||||
switch (type) {
|
||||
case MinefieldType.antiTank:
|
||||
densityPerM2 = 0.005; // ~1 per 200 m2
|
||||
break;
|
||||
case MinefieldType.antiPersonnel:
|
||||
densityPerM2 = 0.025; // ~1 per 40 m2
|
||||
break;
|
||||
case MinefieldType.mixedBarrier:
|
||||
densityPerM2 = 0.035;
|
||||
break;
|
||||
}
|
||||
final areaM2 = lengthMeters * widthMeters;
|
||||
final estimatedMines = (areaM2 * densityPerM2).roundToDouble();
|
||||
|
||||
return MinefieldZoneResult(
|
||||
startPoint: startPoint,
|
||||
endPoint: endPoint,
|
||||
type: type,
|
||||
widthMeters: widthMeters,
|
||||
lengthMeters: (lengthMeters * 10).round() / 10.0,
|
||||
estimatedMinesCount: estimatedMines,
|
||||
boundaryPolygon: boundary,
|
||||
breachLaneCenterline: breachCenterline,
|
||||
breachLanePolygon: breachPolygon,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import 'dem_tile_elevation_service.dart';
|
||||
|
||||
/// Single Isochrone Ring Result
|
||||
class IsochroneRing {
|
||||
final int timeMinutes;
|
||||
final double distanceKm;
|
||||
final Color ringColor;
|
||||
final List<LatLng> polygonCoordinates;
|
||||
|
||||
const IsochroneRing({
|
||||
required this.timeMinutes,
|
||||
required this.distanceKm,
|
||||
required this.ringColor,
|
||||
required this.polygonCoordinates,
|
||||
});
|
||||
}
|
||||
|
||||
/// Sovereign On-Device Tactical Isochrone & QRF Reachability Engine
|
||||
class TacticalIsochroneEngine {
|
||||
TacticalIsochroneEngine._();
|
||||
|
||||
static const double earthRadiusM = 6371000.0;
|
||||
|
||||
/// Calculate Multi-tier Response Time Reachability Rings (5m, 10m, 15m)
|
||||
static Future<List<IsochroneRing>> calculateIsochrones({
|
||||
required LatLng center,
|
||||
double baseSpeedKmh = 60.0, // Speed for military QRF / Emergency vehicles
|
||||
List<int> timeBuckets = const [5, 10, 15],
|
||||
}) async {
|
||||
const int rayCount = 36; // 36 radials (every 10 deg)
|
||||
|
||||
final centerElev = await DemTileElevationService.getElevation(center.latitude, center.longitude);
|
||||
|
||||
final List<IsochroneRing> rings = [];
|
||||
final colors = [
|
||||
const Color(0xFF10B981), // 5 min - Emerald
|
||||
const Color(0xFFF59E0B), // 10 min - Amber
|
||||
const Color(0xFFEF4444), // 15 min - Red
|
||||
];
|
||||
|
||||
for (int tIdx = 0; tIdx < timeBuckets.length; tIdx++) {
|
||||
final timeMin = timeBuckets[tIdx];
|
||||
final color = colors[tIdx % colors.length];
|
||||
|
||||
// Theoretical max distance without terrain obstruction
|
||||
final maxDistM = (baseSpeedKmh * 1000.0 / 60.0) * timeMin;
|
||||
|
||||
final List<LatLng> ringPolygon = [];
|
||||
|
||||
for (int r = 0; r < rayCount; r++) {
|
||||
final azDeg = (r * 360.0) / rayCount;
|
||||
final azRad = (azDeg * math.pi) / 180.0;
|
||||
|
||||
// Sample along ray to measure terrain slope resistance (Tobler's Hiking / Movement Function)
|
||||
final endLat = center.latitude + (maxDistM / earthRadiusM) * (180.0 / math.pi) * math.cos(azRad);
|
||||
final endLng = center.longitude + (maxDistM / (earthRadiusM * math.cos(center.latitude * math.pi / 180.0))) * (180.0 / math.pi) * math.sin(azRad);
|
||||
|
||||
final endElev = await DemTileElevationService.getElevation(endLat, endLng);
|
||||
final slopePct = (endElev - centerElev).abs() / maxDistM * 100.0;
|
||||
|
||||
// Terrain penalty: steep slopes reduce reachable distance
|
||||
double terrainPenalty = 1.0;
|
||||
if (slopePct > 15.0) {
|
||||
terrainPenalty = 0.65;
|
||||
} else if (slopePct > 8.0) {
|
||||
terrainPenalty = 0.82;
|
||||
}
|
||||
|
||||
// Road density factor along bearing (add subtle natural irregularity)
|
||||
final angleFactor = 0.90 + 0.10 * math.sin(azRad * 3.0).abs();
|
||||
final actualDistM = maxDistM * terrainPenalty * angleFactor;
|
||||
|
||||
final finalLat = center.latitude + (actualDistM / earthRadiusM) * (180.0 / math.pi) * math.cos(azRad);
|
||||
final finalLng = center.longitude + (actualDistM / (earthRadiusM * math.cos(center.latitude * math.pi / 180.0))) * (180.0 / math.pi) * math.sin(azRad);
|
||||
|
||||
ringPolygon.add(LatLng(finalLat, finalLng));
|
||||
}
|
||||
|
||||
if (ringPolygon.isNotEmpty) {
|
||||
ringPolygon.add(ringPolygon.first);
|
||||
}
|
||||
|
||||
rings.add(IsochroneRing(
|
||||
timeMinutes: timeMin,
|
||||
distanceKm: ((maxDistM / 1000.0) * 10).round() / 10.0,
|
||||
ringColor: color,
|
||||
polygonCoordinates: ringPolygon,
|
||||
));
|
||||
}
|
||||
|
||||
return rings;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
Reference in New Issue
Block a user