diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index 101f013..5eb532b 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -14,6 +14,7 @@ import '../services/app_settings_service.dart'; import '../services/line_of_sight_service.dart'; import '../services/map_tile_cache_service.dart'; import '../utils/route_transitions.dart'; +import '../connector/meshcore_connector.dart'; import '../widgets/app_bar.dart'; import '../widgets/quick_switch_bar.dart'; @@ -110,10 +111,13 @@ class _LineOfSightMapScreenState extends State { }); try { + final connector = context.read(); + final frequencyMHz = _normalizeFrequencyMHz(connector.currentFreqHz); final result = await _lineOfSightService.analyzePath( [start.point, end.point], startAntennaHeightMeters: startAntenna, endAntennaHeightMeters: endAntenna, + frequencyMHz: frequencyMHz, ); if (!mounted) return; if (!_isRunRequestCurrent( @@ -424,6 +428,12 @@ class _LineOfSightMapScreenState extends State { Widget _buildControlPanel(bool isImperial) { _sanitizeSelection(); final segment = _primarySegmentResult(); + final connector = context.watch(); + final reportedFrequencyMHz = _normalizeFrequencyMHz( + connector.currentFreqHz, + ); + final displayFrequencyMHz = segment?.frequencyMHz ?? reportedFrequencyMHz; + final kFactorUsed = segment?.usedKFactor; final endpoints = _visibleEndpoints(); final distanceUnit = isImperial ? 'mi' : 'km'; final heightUnit = isImperial ? 'ft' : 'm'; @@ -488,6 +498,52 @@ class _LineOfSightMapScreenState extends State { ), ), const SizedBox(height: 4), + if (displayFrequencyMHz != null) + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 4), + child: Row( + children: [ + Text( + 'Frequency', + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Text( + '${displayFrequencyMHz.toStringAsFixed(3)} MHz', + style: TextStyle(fontSize: 11, color: Colors.grey[700]), + ), + if (kFactorUsed != null) ...[ + const SizedBox(width: 8), + Text( + 'k=${kFactorUsed.toStringAsFixed(3)}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + ), + ), + const SizedBox(width: 4), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.info_outline, size: 16), + color: Colors.grey[600], + tooltip: 'View calculation details', + onPressed: () { + _showFrequencyInfoDialog( + context, + displayFrequencyMHz, + kFactorUsed, + ); + }, + ), + ], + ], + ), + ), Text( context.l10n.losElevationAttribution, style: TextStyle(fontSize: 10, color: Colors.grey[700]), @@ -896,6 +952,56 @@ class _LineOfSightMapScreenState extends State { break; } } + + void _showFrequencyInfoDialog( + BuildContext context, + double frequencyMHz, + double kFactor, + ) { + final baselineFreq = LineOfSightService.baselineFrequencyMHz; + final baselineK = LineOfSightService.baselineKFactor; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Radio horizon calculation'), + content: Text.rich( + TextSpan( + children: [ + TextSpan( + text: + 'Starting from k=$baselineK at ${baselineFreq.toStringAsFixed(3)} MHz, ', + ), + const TextSpan(text: 'the calculation multiplies the offset by '), + TextSpan( + text: + '0.15 × (frequency − ${baselineFreq.toStringAsFixed(3)}) / ${baselineFreq.toStringAsFixed(3)} ', + ), + TextSpan( + text: + 'to get k ≈ ${kFactor.toStringAsFixed(3)} for the current ${frequencyMHz.toStringAsFixed(3)} MHz band, ', + ), + const TextSpan( + text: 'which defines the curved radio horizon cap.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_ok), + ), + ], + ), + ); + } + + double? _normalizeFrequencyMHz(int? frequencyHz) { + if (frequencyHz == null || frequencyHz <= 0) return null; + if (frequencyHz >= 1000000) return frequencyHz / 1e6; + if (frequencyHz >= 1000) return frequencyHz / 1e3; + return frequencyHz.toDouble(); + } } class _LosProfilePainter extends CustomPainter { @@ -985,30 +1091,32 @@ class _LosProfilePainter extends CustomPainter { ..strokeWidth = 2, ); - final horizonLine = ui.Path(); + const refractedLineColor = Color(0xFFFFD57F); + final refractedLine = ui.Path(); for (int i = 0; i < samples.length; i++) { final p = mapPoint( samples[i].distanceMeters, - samples[i].radioHorizonMeters, + samples[i].refractedHeightMeters, ); if (i == 0) { - horizonLine.moveTo(p.dx, p.dy); + refractedLine.moveTo(p.dx, p.dy); } else { - horizonLine.lineTo(p.dx, p.dy); + refractedLine.lineTo(p.dx, p.dy); } } - const horizonLineColor = Color(0xFF4BC0FF); - final horizonPaint = Paint() - ..color = horizonLineColor - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - canvas.drawPath(horizonLine, horizonPaint); + canvas.drawPath( + refractedLine, + Paint() + ..color = refractedLineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); final capPath = ui.Path(); for (int i = 0; i < samples.length; i++) { final p = mapPoint( samples[i].distanceMeters, - samples[i].radioHorizonMeters, + samples[i].refractedHeightMeters, ); if (i == 0) { capPath.moveTo(p.dx, p.dy); @@ -1024,7 +1132,7 @@ class _LosProfilePainter extends CustomPainter { capPath.lineTo(p.dx, p.dy); } capPath.close(); - const horizonFillColor = Color(0x404BC0FF); + const horizonFillColor = Color(0x40FFD57F); canvas.drawPath( capPath, Paint() @@ -1032,12 +1140,7 @@ class _LosProfilePainter extends CustomPainter { ..style = PaintingStyle.fill, ); - _drawLegend( - canvas, - horizonLineColor, - losLineColor, - terrainLineColor, - ); + _drawLegend(canvas, refractedLineColor, losLineColor, terrainLineColor); } @override @@ -1090,15 +1193,13 @@ class _LosProfilePainter extends CustomPainter { return painter; }).toList(); - final maxTextWidth = painters.map((p) => p.width).fold( - 0, - math.max, - ); + final maxTextWidth = painters.map((p) => p.width).fold(0, math.max); final legendWidth = legendPadding * 2 + swatchSize + swatchTextGap + maxTextWidth; - final legendHeight = legendPadding * 2 + + final legendHeight = + legendPadding * 2 + entries.length * swatchSize + (entries.length - 1) * entrySpacing; @@ -1125,10 +1226,7 @@ class _LosProfilePainter extends CustomPainter { swatchSize, swatchSize, ); - canvas.drawRect( - swatchRect, - Paint()..color = entry.color, - ); + canvas.drawRect(swatchRect, Paint()..color = entry.color); painter.paint( canvas, diff --git a/lib/services/line_of_sight_service.dart b/lib/services/line_of_sight_service.dart index b73ab51..14d8fc6 100644 --- a/lib/services/line_of_sight_service.dart +++ b/lib/services/line_of_sight_service.dart @@ -12,14 +12,14 @@ class LineOfSightSample { final double distanceMeters; final double terrainMeters; final double lineHeightMeters; - final double radioHorizonMeters; + final double refractedHeightMeters; final double clearanceMeters; const LineOfSightSample({ required this.distanceMeters, required this.terrainMeters, required this.lineHeightMeters, - required this.radioHorizonMeters, + required this.refractedHeightMeters, required this.clearanceMeters, }); } @@ -32,6 +32,8 @@ class LineOfSightResult { final double? firstObstructionDistanceMeters; final List samples; final String? errorMessage; + final double usedKFactor; + final double? frequencyMHz; const LineOfSightResult({ required this.hasData, @@ -40,6 +42,8 @@ class LineOfSightResult { required this.maxObstructionMeters, required this.firstObstructionDistanceMeters, required this.samples, + required this.usedKFactor, + this.frequencyMHz, this.errorMessage, }); @@ -50,7 +54,9 @@ class LineOfSightResult { isClear = false, maxObstructionMeters = 0, firstObstructionDistanceMeters = null, - samples = const []; + samples = const [], + usedKFactor = 4.0 / 3.0, + frequencyMHz = null; } class LineOfSightPathSegment { @@ -91,6 +97,11 @@ class LineOfSightService { static const Duration _cacheTtl = Duration(hours: 24); static const int _maxFetchAttempts = 4; // initial try + 3 retries static const Duration _initialBackoff = Duration(milliseconds: 300); + static const double _baselineFrequencyMHz = 915.0; + static const double _baselineKFactor = 4.0 / 3.0; + + static double get baselineFrequencyMHz => _baselineFrequencyMHz; + static double get baselineKFactor => _baselineKFactor; final http.Client _httpClient; final bool _ownsHttpClient; @@ -108,7 +119,7 @@ class LineOfSightService { List points, { double startAntennaHeightMeters = 1.5, double endAntennaHeightMeters = 1.5, - double kFactor = 4.0 / 3.0, + double? frequencyMHz, double obstructionToleranceMeters = 0.0, }) async { if (points.length < 2) { @@ -125,6 +136,7 @@ class LineOfSightService { var blockedSegments = 0; var unknownSegments = 0; + final kFactor = _kFactorForFrequency(frequencyMHz); for (int i = 0; i < points.length - 1; i++) { final result = await analyzeLink( points[i], @@ -132,6 +144,7 @@ class LineOfSightService { startAntennaHeightMeters: startAntennaHeightMeters, endAntennaHeightMeters: endAntennaHeightMeters, kFactor: kFactor, + frequencyMHz: frequencyMHz, obstructionToleranceMeters: obstructionToleranceMeters, ); segments.add( @@ -165,7 +178,8 @@ class LineOfSightService { LatLng end, { double startAntennaHeightMeters = 1.5, double endAntennaHeightMeters = 1.5, - double kFactor = 4.0 / 3.0, + required double kFactor, + double? frequencyMHz, double obstructionToleranceMeters = 0.0, }) async { final totalDistanceMeters = _distance.as(LengthUnit.Meter, start, end); @@ -177,6 +191,8 @@ class LineOfSightService { maxObstructionMeters: 0, firstObstructionDistanceMeters: null, samples: const [], + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, ); } @@ -205,7 +221,8 @@ class LineOfSightService { required List elevations, double startAntennaHeightMeters = 1.5, double endAntennaHeightMeters = 1.5, - double kFactor = 4.0 / 3.0, + required double kFactor, + double? frequencyMHz, double obstructionToleranceMeters = 0.0, }) { if (points.length < 2 || elevations.length != points.length) { @@ -240,7 +257,10 @@ class LineOfSightService { (2 * effectiveEarthRadius); final terrainHeight = elevations[i] + earthBulge; final clearance = lineHeight - terrainHeight; - final radioHorizonHeight = lineHeight - earthBulge; + final unrefBulge = + (distanceFromStart * (totalDistanceMeters - distanceFromStart)) / + (2 * _earthRadiusMeters); + final refractedHeight = lineHeight + (unrefBulge - earthBulge); if (clearance < -obstructionToleranceMeters) { isClear = false; @@ -256,7 +276,7 @@ class LineOfSightService { distanceMeters: distanceFromStart, terrainMeters: terrainHeight, lineHeightMeters: lineHeight, - radioHorizonMeters: radioHorizonHeight, + refractedHeightMeters: refractedHeight, clearanceMeters: clearance, ), ); @@ -269,9 +289,20 @@ class LineOfSightService { maxObstructionMeters: maxObstructionMeters, firstObstructionDistanceMeters: firstObstructionDistanceMeters, samples: samples, + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, ); } + static double _kFactorForFrequency(double? frequencyMHz) { + if (frequencyMHz == null) return _baselineKFactor; + final delta = + (frequencyMHz - _baselineFrequencyMHz) / _baselineFrequencyMHz; + final adjustment = delta * 0.15; + final scaled = _baselineKFactor * (1 + adjustment); + return scaled.clamp(1.1, 1.6).toDouble(); + } + List _buildSamplePoints( LatLng start, LatLng end, diff --git a/test/services/line_of_sight_service_test.dart b/test/services/line_of_sight_service_test.dart index 987ee6c..267a70b 100644 --- a/test/services/line_of_sight_service_test.dart +++ b/test/services/line_of_sight_service_test.dart @@ -16,6 +16,7 @@ void main() { elevations: elevations, startAntennaHeightMeters: 2, endAntennaHeightMeters: 2, + kFactor: 4.0 / 3.0, ); expect(result.hasData, isTrue); @@ -36,6 +37,7 @@ void main() { elevations: elevations, startAntennaHeightMeters: 1.5, endAntennaHeightMeters: 1.5, + kFactor: 4.0 / 3.0, ); expect(result.hasData, isTrue);