@ -62,6 +62,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
bool _loading = false ;
bool _loading = false ;
String ? _error ;
String ? _error ;
LineOfSightPathResult ? _result ;
LineOfSightPathResult ? _result ;
LineOfSightObstruction ? _selectedObstruction ;
LineOfSightEndpoint ? _start ;
LineOfSightEndpoint ? _start ;
LineOfSightEndpoint ? _end ;
LineOfSightEndpoint ? _end ;
final List < LineOfSightEndpoint > _customEndpoints = [ ] ;
final List < LineOfSightEndpoint > _customEndpoints = [ ] ;
@ -111,6 +112,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
if ( start = = null | | end = = null ) {
if ( start = = null | | end = = null ) {
setState ( ( ) {
setState ( ( ) {
_result = null ;
_result = null ;
_selectedObstruction = null ;
_error = _errorSelectStartEnd ;
_error = _errorSelectStartEnd ;
} ) ;
} ) ;
return ;
return ;
@ -142,6 +144,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
}
}
setState ( ( ) {
setState ( ( ) {
_result = result ;
_result = result ;
_selectedObstruction = _defaultObstructionFor ( result ) ;
} ) ;
} ) ;
} catch ( e ) {
} catch ( e ) {
if ( ! mounted ) return ;
if ( ! mounted ) return ;
@ -156,6 +159,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
}
}
setState ( ( ) {
setState ( ( ) {
_result = null ;
_result = null ;
_selectedObstruction = null ;
_error = context . l10n . losRunFailed ( e . toString ( ) ) ;
_error = context . l10n . losRunFailed ( e . toString ( ) ) ;
} ) ;
} ) ;
} finally {
} finally {
@ -184,6 +188,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
void _selectFromMap ( LineOfSightEndpoint endpoint ) {
void _selectFromMap ( LineOfSightEndpoint endpoint ) {
setState ( ( ) {
setState ( ( ) {
_result = null ;
_result = null ;
_selectedObstruction = null ;
_error = null ;
_error = null ;
if ( _start = = null | | ( _start ! = null & & _end ! = null ) ) {
if ( _start = = null | | ( _start ! = null & & _end ! = null ) ) {
_start = endpoint ;
_start = endpoint ;
@ -241,6 +246,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
_start = null ;
_start = null ;
_end = null ;
_end = null ;
_result = null ;
_result = null ;
_selectedObstruction = null ;
_error = _errorSelectStartEnd ;
_error = _errorSelectStartEnd ;
} ) ;
} ) ;
}
}
@ -251,6 +257,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
if ( identical ( _start , endpoint ) ) _start = null ;
if ( identical ( _start , endpoint ) ) _start = null ;
if ( identical ( _end , endpoint ) ) _end = null ;
if ( identical ( _end , endpoint ) ) _end = null ;
_result = null ;
_result = null ;
_selectedObstruction = null ;
} ) ;
} ) ;
}
}
@ -377,7 +384,9 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
) ,
) ,
if ( _result ! = null & & _result ! . segments . isNotEmpty )
if ( _result ! = null & & _result ! . segments . isNotEmpty )
PolylineLayer ( polylines: _buildSegmentPolylines ( _result ! ) ) ,
PolylineLayer ( polylines: _buildSegmentPolylines ( _result ! ) ) ,
MarkerLayer ( markers: _buildMarkers ( endpoints ) ) ,
MarkerLayer (
markers: _buildMarkers ( endpoints , _primaryObstructions ( ) ) ,
) ,
] ,
] ,
) ,
) ,
if ( _showHud )
if ( _showHud )
@ -445,6 +454,8 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
) ;
) ;
final displayFrequencyMHz = segment ? . frequencyMHz ? ? reportedFrequencyMHz ;
final displayFrequencyMHz = segment ? . frequencyMHz ? ? reportedFrequencyMHz ;
final kFactorUsed = segment ? . usedKFactor ;
final kFactorUsed = segment ? . usedKFactor ;
final obstructions =
segment ? . obstructions ? ? const < LineOfSightObstruction > [ ] ;
final endpoints = _visibleEndpoints ( ) ;
final endpoints = _visibleEndpoints ( ) ;
final distanceUnit = isImperial ? ' mi ' : ' km ' ;
final distanceUnit = isImperial ? ' mi ' : ' km ' ;
final heightUnit = isImperial ? ' ft ' : ' m ' ;
final heightUnit = isImperial ? ' ft ' : ' m ' ;
@ -463,31 +474,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
crossAxisAlignment: CrossAxisAlignment . start ,
crossAxisAlignment: CrossAxisAlignment . start ,
children: [
children: [
if ( segment ! = null )
if ( segment ! = null )
SizedBox (
_buildProfileView ( segment , distanceUnit , heightUnit , isImperial )
height: 160 ,
width: double . infinity ,
child: CustomPaint (
painter: _LosProfilePainter (
samples: segment . samples ,
distanceUnit: distanceUnit ,
heightUnit: heightUnit ,
badgeTextStyle:
Theme . of ( context ) . textTheme . labelSmall ? . copyWith (
color: Colors . white70 ,
fontSize: 10 ,
fontWeight: FontWeight . w600 ,
) ? ?
const TextStyle (
color: Colors . white70 ,
fontSize: 10 ,
fontWeight: FontWeight . w600 ,
) ,
terrainLabel: context . l10n . losLegendTerrain ,
losBeamLabel: context . l10n . losLegendLosBeam ,
radioHorizonLabel: context . l10n . losLegendRadioHorizon ,
) ,
) ,
)
else
else
SizedBox (
SizedBox (
height: 44 ,
height: 44 ,
@ -519,6 +506,96 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
fontWeight: FontWeight . w600 ,
fontWeight: FontWeight . w600 ,
) ,
) ,
) ,
) ,
if ( obstructions . isNotEmpty ) . . . [
const SizedBox ( height: 8 ) ,
Text (
context . l10n . losBlockedSpotsTitle ,
style: const TextStyle (
fontSize: 12 ,
fontWeight: FontWeight . w700 ,
) ,
) ,
const SizedBox ( height: 4 ) ,
Text (
context . l10n . losBlockedSpotsHint ,
style: TextStyle ( fontSize: 11 , color: Colors . grey [ 700 ] ) ,
) ,
const SizedBox ( height: 6 ) ,
Wrap (
spacing: 6 ,
runSpacing: 6 ,
children: [
for ( final obstruction in obstructions )
ChoiceChip (
label: Text (
_obstructionChipLabel ( obstruction , isImperial ) ,
style: const TextStyle ( fontSize: 11 ) ,
) ,
selected:
_selectedObstruction ? . sampleIndex = =
obstruction . sampleIndex ,
onSelected: ( _ ) = > _selectObstruction ( obstruction ) ,
) ,
] ,
) ,
if ( _selectedObstruction ! = null ) . . . [
const SizedBox ( height: 8 ) ,
DecoratedBox (
decoration: BoxDecoration (
color: Colors . orange . withValues ( alpha: 0.1 ) ,
borderRadius: BorderRadius . circular ( 10 ) ,
border: Border . all (
color: Colors . deepOrangeAccent . withValues ( alpha: 0.45 ) ,
) ,
) ,
child: Padding (
padding: const EdgeInsets . all ( 10 ) ,
child: Column (
crossAxisAlignment: CrossAxisAlignment . start ,
children: [
Text (
context . l10n . losSelectedObstructionTitle ,
style: const TextStyle (
fontSize: 12 ,
fontWeight: FontWeight . w700 ,
) ,
) ,
const SizedBox ( height: 4 ) ,
Text (
context . l10n . losSelectedObstructionDetails (
_formatHeightValue (
_selectedObstruction ! . obstructionMeters ,
isImperial ,
) ,
heightUnit ,
_formatDistanceValue (
_selectedObstruction ! . distanceMeters ,
isImperial ,
) ,
distanceUnit ,
_formatDistanceValue (
segment ! . totalDistanceMeters -
_selectedObstruction ! . distanceMeters ,
isImperial ,
) ,
) ,
style: const TextStyle ( fontSize: 11 ) ,
) ,
const SizedBox ( height: 4 ) ,
Text (
' ${ _selectedObstruction ! . point . latitude . toStringAsFixed ( 5 ) } , '
' ${ _selectedObstruction ! . point . longitude . toStringAsFixed ( 5 ) } ' ,
style: TextStyle (
fontSize: 11 ,
color: Colors . grey [ 700 ] ,
) ,
) ,
] ,
) ,
) ,
) ,
] ,
] ,
const SizedBox ( height: 4 ) ,
const SizedBox ( height: 4 ) ,
if ( displayFrequencyMHz ! = null )
if ( displayFrequencyMHz ! = null )
Padding (
Padding (
@ -605,6 +682,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
_showDisplayNodes = value ;
_showDisplayNodes = value ;
_sanitizeSelection ( ) ;
_sanitizeSelection ( ) ;
_result = null ;
_result = null ;
_selectedObstruction = null ;
} ) ;
} ) ;
} ,
} ,
) ,
) ,
@ -655,6 +733,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
setState ( ( ) {
setState ( ( ) {
_start = value ;
_start = value ;
_result = null ;
_result = null ;
_selectedObstruction = null ;
} ) ;
} ) ;
if ( _start ! = null & & _end ! = null ) {
if ( _start ! = null & & _end ! = null ) {
_runLos ( ) ;
_runLos ( ) ;
@ -670,6 +749,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
setState ( ( ) {
setState ( ( ) {
_end = value ;
_end = value ;
_result = null ;
_result = null ;
_selectedObstruction = null ;
} ) ;
} ) ;
if ( _start ! = null & & _end ! = null ) {
if ( _start ! = null & & _end ! = null ) {
_runLos ( ) ;
_runLos ( ) ;
@ -769,6 +849,179 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
return _result ! . segments . first . result ;
return _result ! . segments . first . result ;
}
}
List < LineOfSightObstruction > _primaryObstructions ( ) {
return _primarySegmentResult ( ) ? . obstructions ? ? const [ ] ;
}
LineOfSightObstruction ? _defaultObstructionFor (
LineOfSightPathResult ? result ,
) {
if ( result = = null | | result . segments . isEmpty ) return null ;
final obstructions = result . segments . first . result . obstructions ;
if ( obstructions . isEmpty ) return null ;
return obstructions . reduce (
( current , next ) = >
next . obstructionMeters > current . obstructionMeters ? next : current ,
) ;
}
void _selectObstruction ( LineOfSightObstruction obstruction ) {
setState ( ( ) {
_selectedObstruction = obstruction ;
} ) ;
}
String _formatDistanceValue ( double meters , bool isImperial ) {
final value = isImperial ? ( meters / 1000.0 ) * _kmToMiles : meters / 1000.0 ;
return value . toStringAsFixed ( 2 ) ;
}
String _formatHeightValue ( double meters , bool isImperial ) {
final value = isImperial ? meters * _metersToFeet : meters ;
return value . toStringAsFixed ( 1 ) ;
}
String _obstructionChipLabel (
LineOfSightObstruction obstruction ,
bool isImperial ,
) {
final distanceUnit = isImperial ? ' mi ' : ' km ' ;
final heightUnit = isImperial ? ' ft ' : ' m ' ;
return context . l10n . losBlockedSpotChip (
_formatDistanceValue ( obstruction . distanceMeters , isImperial ) ,
distanceUnit ,
_formatHeightValue ( obstruction . obstructionMeters , isImperial ) ,
heightUnit ,
) ;
}
Widget _buildProfileView (
LineOfSightResult segment ,
String distanceUnit ,
String heightUnit ,
bool isImperial ,
) {
if ( segment . samples . length < 2 ) {
return SizedBox (
height: 160 ,
width: double . infinity ,
child: CustomPaint (
painter: _LosProfilePainter (
samples: segment . samples ,
distanceUnit: distanceUnit ,
heightUnit: heightUnit ,
badgeTextStyle:
Theme . of ( context ) . textTheme . labelSmall ? . copyWith (
color: Colors . white70 ,
fontSize: 10 ,
fontWeight: FontWeight . w600 ,
) ? ?
const TextStyle (
color: Colors . white70 ,
fontSize: 10 ,
fontWeight: FontWeight . w600 ,
) ,
terrainLabel: context . l10n . losLegendTerrain ,
losBeamLabel: context . l10n . losLegendLosBeam ,
radioHorizonLabel: context . l10n . losLegendRadioHorizon ,
selectedSampleIndex: _selectedObstruction ? . sampleIndex ,
) ,
) ,
) ;
}
return SizedBox (
height: 160 ,
width: double . infinity ,
child: LayoutBuilder (
builder: ( context , constraints ) {
final size = Size ( constraints . maxWidth , 160 ) ;
final geometry = _LosProfileGeometry (
samples: segment . samples ,
size: size ,
) ;
return Stack (
clipBehavior: Clip . none ,
children: [
Positioned . fill (
child: CustomPaint (
painter: _LosProfilePainter (
samples: segment . samples ,
distanceUnit: distanceUnit ,
heightUnit: heightUnit ,
badgeTextStyle:
Theme . of ( context ) . textTheme . labelSmall ? . copyWith (
color: Colors . white70 ,
fontSize: 10 ,
fontWeight: FontWeight . w600 ,
) ? ?
const TextStyle (
color: Colors . white70 ,
fontSize: 10 ,
fontWeight: FontWeight . w600 ,
) ,
terrainLabel: context . l10n . losLegendTerrain ,
losBeamLabel: context . l10n . losLegendLosBeam ,
radioHorizonLabel: context . l10n . losLegendRadioHorizon ,
selectedSampleIndex: _selectedObstruction ? . sampleIndex ,
) ,
) ,
) ,
for ( final obstruction in segment . obstructions )
Builder (
builder: ( context ) {
final sample = segment . samples [ obstruction . sampleIndex ] ;
final position = geometry . mapPoint (
sample . distanceMeters ,
sample . terrainMeters ,
) ;
final isSelected =
_selectedObstruction ? . sampleIndex = =
obstruction . sampleIndex ;
final markerSize = isSelected ? 18.0 : 14.0 ;
final left = ( position . dx - markerSize / 2 )
. clamp ( 0.0 , math . max ( 0.0 , size . width - markerSize ) )
. toDouble ( ) ;
final top = ( position . dy - markerSize / 2 )
. clamp ( 0.0 , math . max ( 0.0 , size . height - markerSize ) )
. toDouble ( ) ;
return Positioned (
left: left ,
top: top ,
child: Tooltip (
message: _obstructionChipLabel ( obstruction , isImperial ) ,
child: GestureDetector (
onTap: ( ) = > _selectObstruction ( obstruction ) ,
child: Container (
width: markerSize ,
height: markerSize ,
decoration: BoxDecoration (
color: isSelected
? Colors . amberAccent
: Colors . deepOrangeAccent ,
shape: BoxShape . circle ,
border: Border . all (
color: isSelected
? Colors . white
: Colors . black87 ,
width: isSelected ? 2 : 1.5 ,
) ,
boxShadow: const [
BoxShadow ( color: Colors . black45 , blurRadius: 4 ) ,
] ,
) ,
) ,
) ,
) ,
) ;
} ,
) ,
] ,
) ;
} ,
) ,
) ;
}
String _profileStats ( LineOfSightResult result , bool isImperial ) {
String _profileStats ( LineOfSightResult result , bool isImperial ) {
final distance = isImperial
final distance = isImperial
? ( result . totalDistanceMeters / 1000.0 ) * _kmToMiles
? ( result . totalDistanceMeters / 1000.0 ) * _kmToMiles
@ -820,8 +1073,51 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
return polylines ;
return polylines ;
}
}
List < Marker > _buildMarkers ( List < LineOfSightEndpoint > endpoints ) {
List < Marker > _buildMarkers (
List < LineOfSightEndpoint > endpoints ,
List < LineOfSightObstruction > obstructions ,
) {
return [
return [
for ( final obstruction in obstructions )
Marker (
point: obstruction . point ,
width: 52 ,
height: 52 ,
child: GestureDetector (
onTap: ( ) = > _selectObstruction ( obstruction ) ,
child: Center (
child: Container (
width:
_selectedObstruction ? . sampleIndex = = obstruction . sampleIndex
? 36
: 24 ,
height:
_selectedObstruction ? . sampleIndex = = obstruction . sampleIndex
? 36
: 24 ,
decoration: BoxDecoration (
shape: BoxShape . circle ,
color: Colors . transparent ,
border: Border . all (
color:
_selectedObstruction ? . sampleIndex = =
obstruction . sampleIndex
? Colors . amberAccent
: Colors . deepOrangeAccent ,
width:
_selectedObstruction ? . sampleIndex = =
obstruction . sampleIndex
? 4
: 3 ,
) ,
boxShadow: const [
BoxShadow ( color: Colors . black26 , blurRadius: 6 ) ,
] ,
) ,
) ,
) ,
) ,
) ,
for ( final endpoint in endpoints )
for ( final endpoint in endpoints )
Marker (
Marker (
point: endpoint . point ,
point: endpoint . point ,
@ -1010,6 +1306,51 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
}
}
}
}
class _LosProfileGeometry {
static const horizontalPadding = 12.0 ;
static const verticalPadding = 12.0 ;
final List < LineOfSightSample > samples ;
final Size size ;
late final double minY = samples
. map (
( s ) = > math . min (
math . min ( s . terrainMeters , s . lineHeightMeters ) ,
s . refractedHeightMeters ,
) ,
)
. reduce ( math . min ) ;
late final double maxY = samples
. map (
( s ) = > math . max (
math . max ( s . terrainMeters , s . lineHeightMeters ) ,
s . refractedHeightMeters ,
) ,
)
. reduce ( math . max ) ;
late final double ySpan = math . max ( 1.0 , maxY - minY ) ;
late final double maxDist = math . max ( 1.0 , samples . last . distanceMeters ) ;
late final double chartWidth = math . max (
1.0 ,
size . width - horizontalPadding * 2 ,
) ;
late final double chartHeight = math . max (
1.0 ,
size . height - verticalPadding * 2 ,
) ;
_LosProfileGeometry ( { required this . samples , required this . size } ) ;
Offset mapPoint ( double distanceMeters , double elevationMeters ) {
final px = horizontalPadding + ( distanceMeters / maxDist ) * chartWidth ;
final py =
size . height -
verticalPadding -
( ( elevationMeters - minY ) / ySpan ) * chartHeight ;
return Offset ( px , py ) ;
}
}
class _LosProfilePainter extends CustomPainter {
class _LosProfilePainter extends CustomPainter {
final List < LineOfSightSample > samples ;
final List < LineOfSightSample > samples ;
final String distanceUnit ;
final String distanceUnit ;
@ -1018,6 +1359,7 @@ class _LosProfilePainter extends CustomPainter {
final String terrainLabel ;
final String terrainLabel ;
final String losBeamLabel ;
final String losBeamLabel ;
final String radioHorizonLabel ;
final String radioHorizonLabel ;
final int ? selectedSampleIndex ;
const _LosProfilePainter ( {
const _LosProfilePainter ( {
required this . samples ,
required this . samples ,
@ -1027,6 +1369,7 @@ class _LosProfilePainter extends CustomPainter {
required this . terrainLabel ,
required this . terrainLabel ,
required this . losBeamLabel ,
required this . losBeamLabel ,
required this . radioHorizonLabel ,
required this . radioHorizonLabel ,
this . selectedSampleIndex ,
} ) ;
} ) ;
@ override
@ override
@ -1212,6 +1555,32 @@ class _LosProfilePainter extends CustomPainter {
. . color = horizonFillColor
. . color = horizonFillColor
. . style = PaintingStyle . fill ,
. . style = PaintingStyle . fill ,
) ;
) ;
if ( selectedSampleIndex ! = null & &
selectedSampleIndex ! > = 0 & &
selectedSampleIndex ! < samples . length ) {
final selectedSample = samples [ selectedSampleIndex ! ] ;
final selectedPoint = mapPoint (
selectedSample . distanceMeters ,
selectedSample . terrainMeters ,
) ;
canvas . drawLine (
Offset ( selectedPoint . dx , verticalPadding ) ,
Offset ( selectedPoint . dx , size . height - verticalPadding ) ,
Paint ( )
. . color = Colors . amberAccent . withValues ( alpha: 0.7 )
. . strokeWidth = 1.5 ,
) ;
canvas . drawCircle ( selectedPoint , 7 , Paint ( ) . . color = Colors . amberAccent ) ;
canvas . drawCircle (
selectedPoint ,
8.5 ,
Paint ( )
. . color = Colors . white
. . style = PaintingStyle . stroke
. . strokeWidth = 1.5 ,
) ;
}
}
}
@ override
@ override
@ -1222,7 +1591,8 @@ class _LosProfilePainter extends CustomPainter {
oldDelegate . badgeTextStyle ! = badgeTextStyle | |
oldDelegate . badgeTextStyle ! = badgeTextStyle | |
oldDelegate . terrainLabel ! = terrainLabel | |
oldDelegate . terrainLabel ! = terrainLabel | |
oldDelegate . losBeamLabel ! = losBeamLabel | |
oldDelegate . losBeamLabel ! = losBeamLabel | |
oldDelegate . radioHorizonLabel ! = radioHorizonLabel ;
oldDelegate . radioHorizonLabel ! = radioHorizonLabel | |
oldDelegate . selectedSampleIndex ! = selectedSampleIndex ;
}
}
void _drawUnitBadge ( Canvas canvas , Size size ) {
void _drawUnitBadge ( Canvas canvas , Size size ) {