mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-28 13:17:35 +10:00
add selectable LOS obstruction pinning for repeater placement
This commit is contained in:
@@ -62,6 +62,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
LineOfSightPathResult? _result;
|
||||
LineOfSightObstruction? _selectedObstruction;
|
||||
LineOfSightEndpoint? _start;
|
||||
LineOfSightEndpoint? _end;
|
||||
final List<LineOfSightEndpoint> _customEndpoints = [];
|
||||
@@ -111,6 +112,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
if (start == null || end == null) {
|
||||
setState(() {
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
_error = _errorSelectStartEnd;
|
||||
});
|
||||
return;
|
||||
@@ -142,6 +144,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
}
|
||||
setState(() {
|
||||
_result = result;
|
||||
_selectedObstruction = _defaultObstructionFor(result);
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -156,6 +159,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
}
|
||||
setState(() {
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
_error = context.l10n.losRunFailed(e.toString());
|
||||
});
|
||||
} finally {
|
||||
@@ -184,6 +188,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
void _selectFromMap(LineOfSightEndpoint endpoint) {
|
||||
setState(() {
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
_error = null;
|
||||
if (_start == null || (_start != null && _end != null)) {
|
||||
_start = endpoint;
|
||||
@@ -241,6 +246,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
_start = null;
|
||||
_end = null;
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
_error = _errorSelectStartEnd;
|
||||
});
|
||||
}
|
||||
@@ -251,6 +257,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
if (identical(_start, endpoint)) _start = null;
|
||||
if (identical(_end, endpoint)) _end = null;
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -377,7 +384,9 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
),
|
||||
if (_result != null && _result!.segments.isNotEmpty)
|
||||
PolylineLayer(polylines: _buildSegmentPolylines(_result!)),
|
||||
MarkerLayer(markers: _buildMarkers(endpoints)),
|
||||
MarkerLayer(
|
||||
markers: _buildMarkers(endpoints, _primaryObstructions()),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_showHud)
|
||||
@@ -445,6 +454,8 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
);
|
||||
final displayFrequencyMHz = segment?.frequencyMHz ?? reportedFrequencyMHz;
|
||||
final kFactorUsed = segment?.usedKFactor;
|
||||
final obstructions =
|
||||
segment?.obstructions ?? const <LineOfSightObstruction>[];
|
||||
final endpoints = _visibleEndpoints();
|
||||
final distanceUnit = isImperial ? 'mi' : 'km';
|
||||
final heightUnit = isImperial ? 'ft' : 'm';
|
||||
@@ -463,31 +474,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (segment != null)
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
_buildProfileView(segment, distanceUnit, heightUnit, isImperial)
|
||||
else
|
||||
SizedBox(
|
||||
height: 44,
|
||||
@@ -519,6 +506,96 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
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),
|
||||
if (displayFrequencyMHz != null)
|
||||
Padding(
|
||||
@@ -605,6 +682,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
_showDisplayNodes = value;
|
||||
_sanitizeSelection();
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -655,6 +733,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
setState(() {
|
||||
_start = value;
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
});
|
||||
if (_start != null && _end != null) {
|
||||
_runLos();
|
||||
@@ -670,6 +749,7 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
setState(() {
|
||||
_end = value;
|
||||
_result = null;
|
||||
_selectedObstruction = null;
|
||||
});
|
||||
if (_start != null && _end != null) {
|
||||
_runLos();
|
||||
@@ -769,6 +849,179 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
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) {
|
||||
final distance = isImperial
|
||||
? (result.totalDistanceMeters / 1000.0) * _kmToMiles
|
||||
@@ -820,8 +1073,51 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
||||
return polylines;
|
||||
}
|
||||
|
||||
List<Marker> _buildMarkers(List<LineOfSightEndpoint> endpoints) {
|
||||
List<Marker> _buildMarkers(
|
||||
List<LineOfSightEndpoint> endpoints,
|
||||
List<LineOfSightObstruction> obstructions,
|
||||
) {
|
||||
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)
|
||||
Marker(
|
||||
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 {
|
||||
final List<LineOfSightSample> samples;
|
||||
final String distanceUnit;
|
||||
@@ -1018,6 +1359,7 @@ class _LosProfilePainter extends CustomPainter {
|
||||
final String terrainLabel;
|
||||
final String losBeamLabel;
|
||||
final String radioHorizonLabel;
|
||||
final int? selectedSampleIndex;
|
||||
|
||||
const _LosProfilePainter({
|
||||
required this.samples,
|
||||
@@ -1027,6 +1369,7 @@ class _LosProfilePainter extends CustomPainter {
|
||||
required this.terrainLabel,
|
||||
required this.losBeamLabel,
|
||||
required this.radioHorizonLabel,
|
||||
this.selectedSampleIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1212,6 +1555,32 @@ class _LosProfilePainter extends CustomPainter {
|
||||
..color = horizonFillColor
|
||||
..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
|
||||
@@ -1222,7 +1591,8 @@ class _LosProfilePainter extends CustomPainter {
|
||||
oldDelegate.badgeTextStyle != badgeTextStyle ||
|
||||
oldDelegate.terrainLabel != terrainLabel ||
|
||||
oldDelegate.losBeamLabel != losBeamLabel ||
|
||||
oldDelegate.radioHorizonLabel != radioHorizonLabel;
|
||||
oldDelegate.radioHorizonLabel != radioHorizonLabel ||
|
||||
oldDelegate.selectedSampleIndex != selectedSampleIndex;
|
||||
}
|
||||
|
||||
void _drawUnitBadge(Canvas canvas, Size size) {
|
||||
|
||||
Reference in New Issue
Block a user