Libella megjelnítése mérőrúdhoz, transzverzális rajzolása az aktuális kitűzendő ponthoz

This commit is contained in:
2026-07-07 02:48:37 +02:00
parent ecb4892bc8
commit d3857f7fde
7 changed files with 629 additions and 0 deletions
+2
View File
@@ -23,6 +23,7 @@ import 'package:terepi_seged/services/note_photo_service.dart';
import 'package:terepi_seged/services/ntrip_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/stakeout_service.dart';
import 'package:terepi_seged/services/tilt_service.dart';
import 'package:terepi_seged/services/track_sync_service.dart';
import 'package:terepi_seged/services/ts_sync_service.dart';
@@ -67,6 +68,7 @@ Future<void> main() async {
Get.put(TrackSyncService(), permanent: true);
Get.put(LayerSyncService());
Get.put(TsSyncService());
Get.put(TiltService());
runApp(const MyApp());
}
@@ -1,7 +1,10 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/stakeout_service.dart';
@@ -43,12 +46,59 @@ class StakeoutMapLayers extends StatelessWidget {
strokeWidth: 2,
));
}
// Transzverzális az aktuális célnál: a vonalra merőleges segédvonal
// ±30 m hosszan, 10 m-enkénti méter-címkékkel — terepi akadálynál
// e mentén látszik, hova tolható el a pont.
final transversal = <LatLng>[];
final transversalTicks = <Marker>[];
if (target != null && Get.isRegistered<CoordConverterService>()) {
final lb = svc.lineBearingAt(target);
if (lb != null) {
final perpRad = (lb + 90) * math.pi / 180;
LatLng at(double d) {
final y = target.planEovY + d * math.sin(perpRad);
final x = target.planEovX + d * math.cos(perpRad);
final w = CoordConverterService.to.eovToWgsPoint(y, x);
return LatLng(w.y, w.x);
}
transversal
..add(at(-30))
..add(at(30));
for (final d in const [-30.0, -20.0, -10.0, 10.0, 20.0, 30.0]) {
transversalTicks.add(Marker(
point: at(d),
width: 24,
height: 16,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.85),
borderRadius: BorderRadius.circular(4),
),
child: Text(
d.abs().toStringAsFixed(0),
style: const TextStyle(
fontSize: 9,
color: Colors.white,
fontWeight: FontWeight.w700),
),
),
));
}
}
}
// 300 pont fölött a címkék csak a célon (teljesítmény).
final showLabels = points.length <= 300;
return Stack(children: [
PolylineLayer(polylines: [
if (transversal.isNotEmpty)
Polyline(
points: transversal,
color: Colors.orange.withValues(alpha: 0.8),
strokeWidth: 2),
...linePolys,
if (hasPos && target != null)
Polyline(
@@ -61,6 +111,7 @@ class StakeoutMapLayers extends StatelessWidget {
),
]),
MarkerLayer(markers: [
...transversalTicks,
for (final p in points)
Marker(
point: LatLng(p.planLat, p.planLon),
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_list_sheet.dart';
import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_settings_dialog.dart';
import 'package:terepi_seged/pages/map_survey/presentations/widgets/tilt_bubble.dart';
import 'package:terepi_seged/routes/app_pages.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/stakeout_service.dart';
@@ -134,6 +135,8 @@ class StakeoutPanel extends StatelessWidget {
tooltip: 'Haptikus visszajelzés',
onPressed: () => svc.hapticsEnabled.toggle(),
)),
const TiltBubble(),
const SizedBox(width: 2),
IconButton(
icon: const Icon(Icons.list_alt),
visualDensity: VisualDensity.compact,
@@ -0,0 +1,335 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../../../services/tilt_service.dart';
/// Rúd-libella buborék — kis kör alakú widget a kitűzőpanel fejlécébe.
///
/// Színküszöbök a DŐLÉSBŐL SZÁMOLT VÍZSZINTES HIBA szerint (a rúdmagasság
/// figyelembevételével), mert terepen ez a beszédes érték:
/// zöld < 1,5 cm · sárga < 3,5 cm · piros felette.
/// Mozgás közben és kalibrálatlanul szürke/kérdőjeles.
/// Koppintásra részletes nézet: nagy buborék, számok, rúdmagasság,
/// vezetett (1 vagy 2 lépéses) kalibráció.
class TiltBubble extends StatelessWidget {
const TiltBubble({super.key, this.size = 34});
final double size;
@override
Widget build(BuildContext context) {
if (!Get.isRegistered<TiltService>()) return const SizedBox.shrink();
return Obx(() {
final t = TiltService.to;
final inactive = !t.isCalibrated.value || t.isMoving.value;
final errCm = t.horizontalErrorCm;
final color = inactive
? Colors.grey
: errCm < 1.5
? Colors.green
: errCm < 3.5
? Colors.orange
: Colors.red;
return Tooltip(
message: !t.isCalibrated.value
? 'Libella: nincs kalibrálva — koppints!'
: t.isMoving.value
? 'Libella: mozgásban'
: 'Dőlés ${t.tiltDeg.value.toStringAsFixed(1)}° '
'${errCm.toStringAsFixed(1)} cm',
child: InkWell(
onTap: () => _showDetail(context),
customBorder: const CircleBorder(),
child: SizedBox(
width: size,
height: size,
child: _BubbleFace(
size: size,
color: color,
inactive: inactive,
uncalibrated: !t.isCalibrated.value,
// A buborék a "lejtés" irányába csúszik — skála: 1 fok
// dőlés ≈ a sugár ~25%-a, telítésig.
dx: t.bubbleX.value,
dy: t.bubbleY.value,
),
),
),
);
});
}
// ═════════════════════════════════════════════════════════════════
// Részletes nézet + kalibráció
// ═════════════════════════════════════════════════════════════════
Future<void> _showDetail(BuildContext context) async {
final t = TiltService.to;
final poleCtrl =
TextEditingController(text: t.poleHeight.value.toStringAsFixed(2));
await Get.dialog(AlertDialog(
title: const Text('Rúd-libella'),
content: SingleChildScrollView(
child: Obx(() {
final errCm = t.horizontalErrorCm;
final inactive = !t.isCalibrated.value || t.isMoving.value;
final color = inactive
? Colors.grey
: errCm < 1.5
? Colors.green
: errCm < 3.5
? Colors.orange
: Colors.red;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 140,
height: 140,
child: _BubbleFace(
size: 140,
color: color,
inactive: inactive,
uncalibrated: !t.isCalibrated.value,
dx: t.bubbleX.value,
dy: t.bubbleY.value,
),
),
const SizedBox(height: 10),
Text(
!t.isCalibrated.value
? 'Nincs kalibrálva'
: t.isMoving.value
? 'Mozgásban — állj meg a leolvasáshoz'
: '${t.tiltDeg.value.toStringAsFixed(2)}° ≈ '
'${errCm.toStringAsFixed(1)} cm vízszintes hiba',
style: TextStyle(
fontWeight: FontWeight.w700,
color: inactive ? Colors.grey : color),
),
Text(
'@ ${t.poleHeight.value.toStringAsFixed(2)} m rúdmagasság',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
const SizedBox(height: 12),
TextField(
controller: poleCtrl,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Rúdmagasság (m)',
isDense: true,
),
onSubmitted: (v) {
final m = double.tryParse(v.replaceAll(',', '.'));
if (m != null) t.setPoleHeight(m);
},
),
const SizedBox(height: 8),
Text(
'A jelzés csak akkor érvényes, ha a telefon MEREVEN, '
'tartóban van a rúdon. Tartó-áthelyezés után kalibrálj újra.',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
],
);
}),
),
actions: [
if (TiltService.to.isCalibrated.value)
TextButton(
onPressed: () => TiltService.to.clearCalibration(),
child: const Text('Kalibráció törlése'),
),
TextButton(onPressed: Get.back, child: const Text('Bezár')),
FilledButton.icon(
icon: const Icon(Icons.architecture, size: 18),
label: const Text('Kalibrálás'),
onPressed: () async {
final m = double.tryParse(poleCtrl.text.replaceAll(',', '.'));
if (m != null) await TiltService.to.setPoleHeight(m);
Get.back();
await _calibrationFlow();
},
),
],
));
}
/// Vezetett kalibráció: 1. lépés kötelező, 2. (180°-os átforgatásos)
/// opcionális — utóbbi a tartó ferdeségét is kiejti.
Future<void> _calibrationFlow() async {
final t = TiltService.to;
final step1Ok = await _captureStep(
title: 'Kalibráció — 1/2',
text: 'Állítsd a rudat FÜGGŐLEGESRE a szelencés libella szerint, '
'és tartsd mozdulatlanul. A mérés 2 másodpercig tart.',
);
if (step1Ok != true) return;
final v1 = await t.captureVector();
HapticFeedback.mediumImpact();
final doStep2 = await Get.dialog<bool>(AlertDialog(
title: const Text('Kalibráció — pontosítás?'),
content: const Text(
'A pontosabb (tartó-ferdeséget is kiejtő) kalibrációhoz forgasd '
'át a telefont a RÚD TENGELYE körül 180°-kal (a rúd maradjon '
'függőleges), majd folytasd. Ki is hagyhatod — akkor az 1. '
'lépés lesz a referencia.'),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Kihagyom')),
FilledButton(
onPressed: () => Get.back(result: true),
child: const Text('Átforgattam — folytatás')),
],
));
if (doStep2 == true) {
final v2 = await t.captureVector();
HapticFeedback.mediumImpact();
await t.saveReference(v1, v2);
} else {
await t.saveReference(v1);
}
Get.snackbar('Libella', 'Kalibráció kész — a buborék él.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: const Color(0xFF2E7D32),
colorText: const Color(0xFFFFFFFF));
}
Future<bool?> _captureStep({required String title, required String text}) {
return Get.dialog<bool>(AlertDialog(
title: Text(title),
content: Text(text),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Mégse')),
FilledButton(
onPressed: () => Get.back(result: true),
child: const Text('Indítás')),
],
));
}
}
// ═══════════════════════════════════════════════════════════════════
class _BubbleFace extends StatelessWidget {
final double size;
final Color color;
final bool inactive;
final bool uncalibrated;
final double dx;
final double dy;
const _BubbleFace({
required this.size,
required this.color,
required this.inactive,
required this.uncalibrated,
required this.dx,
required this.dy,
});
@override
Widget build(BuildContext context) {
// Skála: sin(4°) dőlésnél ér a széléhez.
final r = size / 2;
final scale = (r - size * 0.14) / 0.07;
var ox = dx * scale;
var oy = -dy * scale; // képernyő-y lefelé nő
final d = (ox * ox + oy * oy);
final maxR = r - size * 0.14;
if (d > maxR * maxR) {
final f = maxR / math.sqrt(d);
ox *= f;
oy *= f;
}
return CustomPaint(
painter: _BubblePainter(
color: color,
inactive: inactive,
ox: ox,
oy: oy,
dotRadius: size * 0.13,
),
child: uncalibrated
? Center(
child: Text('?',
style: TextStyle(
fontSize: size * 0.45,
fontWeight: FontWeight.w700,
color: Colors.grey)))
: null,
);
}
}
class _BubblePainter extends CustomPainter {
final Color color;
final bool inactive;
final double ox;
final double oy;
final double dotRadius;
_BubblePainter({
required this.color,
required this.inactive,
required this.ox,
required this.oy,
required this.dotRadius,
});
@override
void paint(Canvas canvas, Size size) {
final c = Offset(size.width / 2, size.height / 2);
final r = size.width / 2;
canvas.drawCircle(
c,
r - 1,
Paint()
..color = color.withOpacity(inactive ? 0.06 : 0.12)
..style = PaintingStyle.fill);
canvas.drawCircle(
c,
r - 1,
Paint()
..color = color.withOpacity(inactive ? 0.5 : 0.9)
..strokeWidth = 1.6
..style = PaintingStyle.stroke);
// Belső célkör
canvas.drawCircle(
c,
r * 0.35,
Paint()
..color = color.withOpacity(0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke);
if (!inactive) {
canvas.drawCircle(c + Offset(ox, oy), dotRadius, Paint()..color = color);
}
}
@override
bool shouldRepaint(_BubblePainter old) =>
old.ox != ox ||
old.oy != oy ||
old.color != color ||
old.inactive != inactive;
}
+8
View File
@@ -9,6 +9,7 @@ import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/tilt_service.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../models/stakeout_point.dart';
@@ -53,12 +54,14 @@ class StakeoutService extends GetxService {
active.value = value;
if (value) {
WakelockPlus.enable();
if (Get.isRegistered<TiltService>()) TiltService.to.setActive(true);
await load();
_applyPhase();
_scheduleHaptic();
} else {
_hapticTimer?.cancel();
WakelockPlus.disable();
if (Get.isRegistered<TiltService>()) TiltService.to.setActive(false);
}
}
@@ -467,6 +470,11 @@ class StakeoutService extends GetxService {
devInline: inline,
devCrossline: crossline,
devDz: t.planEovZ != null ? t.planEovZ! - useAlt : null,
tiltDeg: Get.isRegistered<TiltService>() &&
TiltService.to.isCalibrated.value &&
!TiltService.to.isMoving.value
? TiltService.to.tiltDeg.value
: null,
fixQuality: gnss.gpsQuality.value,
accuracy: gnss.horizontalAccuracy,
stakedAt: DateTime.now(),
+229
View File
@@ -0,0 +1,229 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:get/get.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// "Telefon rúd-libella" — a kitűzőrúd függőlegességének jelzése a
/// telefon gyorsulásmérőjéből.
///
/// NEM tilt-kompenzáció (a pozíciót nem javítja), hanem tilt-JELZÉS:
/// a fizikai szelencés libellát váltja ki a képernyőn. Előny az
/// iránytűvel szemben: csak a gravitáció irányát méri, ezért a vevő és
/// a fém rúd MÁGNESES zavara nem érinti.
///
/// Kalibráció (mert a telefontartó sosem áll pontosan a rúd tengelyében):
/// 1. lépés: rúd függőlegesen a szelencés libella szerint → 2 s átlagolt
/// gravitáció-vektor = referencia.
/// 2. lépés (opcionális, pontosabb): ugyanez a telefon rúd-tengely
/// körüli 180°-os átforgatása után — a két vektor átlagában a tartó
/// ferdesége kiesik, a valódi rúd-tengely marad.
///
/// Használhatósági részletek:
/// * MOZGÁS-detektálás: séta közben a gyorsulásmérő a lépések
/// gyorsulását is méri, a "gravitáció" iránya hamis → a jelzés
/// ilyenkor inaktív (szürke), csak megálláskor élesedik. Ez egybeesik
/// a munkafolyamattal: a libella a tárolás előtti pillanatban számít.
/// * SIMÍTÁS: exponenciális aluláteresztő szűrő, ~10 Hz-es kijelzési
/// frissítés — enélkül a buborék idegesítően remegne.
/// * A dőlésből számolt VÍZSZINTES HIBA a beszédes érték:
/// hiba ≈ rúdmagasság × sin(dőlés) — 2 m-es rúdnál 0,5° már ~1,7 cm.
class TiltService extends GetxService {
static TiltService get to => Get.find();
// ── Reaktív állapot ──────────────────────────────────────────────
final active = false.obs;
final isCalibrated = false.obs;
final isMoving = false.obs;
final isCapturing = false.obs; // kalibrációs mintavétel fut
/// Dőlés a kalibrált rúd-tengelyhez képest (fok).
final tiltDeg = 0.0.obs;
/// A buborék 2D iránya a telefon képernyő-síkjában (egység: a dőlés
/// szinusza; a widget skálázza). x: jobbra +, y: felfelé +.
final bubbleX = 0.0.obs;
final bubbleY = 0.0.obs;
/// Rúdmagasság (m) — a vízszintes hiba számításához; szerkeszthető.
final poleHeight = 2.0.obs;
/// Dőlésből számolt vízszintes eltolódás (cm).
double get horizontalErrorCm =>
poleHeight.value * math.sin(tiltDeg.value * math.pi / 180) * 100;
// ── Belső állapot ────────────────────────────────────────────────
StreamSubscription? _sub;
// Referencia (rúd-tengely) egységvektor a telefon koordinátáiban.
double _refX = 0, _refY = 0, _refZ = 1;
// Szűrt gyorsulás-vektor.
double _fx = 0, _fy = 0, _fz = 0;
bool _filterSeeded = false;
static const _alpha = 0.15; // aluláteresztő szűrő együttható
// Mozgás-detektálás: |a| szórása csúszóablakban.
final _magWindow = <double>[];
static const _windowLen = 25; // ~0,5 s 50 Hz-en
static const _movingStdThreshold = 0.35; // m/s²
DateTime _lastUiPush = DateTime.fromMillisecondsSinceEpoch(0);
@override
Future<void> onInit() async {
super.onInit();
final prefs = await SharedPreferences.getInstance();
poleHeight.value = prefs.getDouble('tilt_pole_height') ?? 2.0;
final refJson = prefs.getString('tilt_reference');
if (refJson != null) {
final r = jsonDecode(refJson) as Map<String, dynamic>;
_refX = (r['x'] as num).toDouble();
_refY = (r['y'] as num).toDouble();
_refZ = (r['z'] as num).toDouble();
isCalibrated.value = true;
}
}
@override
void onClose() {
_sub?.cancel();
super.onClose();
}
/// A szenzor-stream csak aktív kitűzés/mérés módban fut (akku-kímélés).
void setActive(bool value) {
if (active.value == value) return;
active.value = value;
if (value) {
_filterSeeded = false;
_magWindow.clear();
_sub = accelerometerEventStream(
samplingPeriod: const Duration(milliseconds: 20))
.listen(_onSample);
} else {
_sub?.cancel();
_sub = null;
}
}
void _onSample(AccelerometerEvent e) {
// Szűrés.
if (!_filterSeeded) {
_fx = e.x;
_fy = e.y;
_fz = e.z;
_filterSeeded = true;
} else {
_fx += _alpha * (e.x - _fx);
_fy += _alpha * (e.y - _fy);
_fz += _alpha * (e.z - _fz);
}
// Mozgás-detektálás a NYERS magnitúdó szórásából.
final mag = math.sqrt(e.x * e.x + e.y * e.y + e.z * e.z);
_magWindow.add(mag);
if (_magWindow.length > _windowLen) _magWindow.removeAt(0);
if (_magWindow.length >= _windowLen ~/ 2) {
final mean = _magWindow.reduce((a, b) => a + b) / _magWindow.length;
final varSum =
_magWindow.fold<double>(0, (s, v) => s + (v - mean) * (v - mean));
final std = math.sqrt(varSum / _magWindow.length);
isMoving.value = std > _movingStdThreshold;
}
// UI-frissítés max. ~12 Hz-en.
final now = DateTime.now();
if (now.difference(_lastUiPush).inMilliseconds < 80) return;
_lastUiPush = now;
_updateTilt();
}
void _updateTilt() {
final fmag = math.sqrt(_fx * _fx + _fy * _fy + _fz * _fz);
if (fmag < 1e-6) return;
// Dőlés = a szűrt gravitáció és a referencia-tengely szöge.
final dot = (_fx * _refX + _fy * _refY + _fz * _refZ) / fmag;
tiltDeg.value = math.acos(dot.clamp(-1.0, 1.0)) * 180 / math.pi;
// Buborék-irány: a gravitáció referencia-síkra vetített komponense,
// a telefon képernyő-tengelyeiben. (x: jobbra, y: a képernyőn felfelé
// — a szenzor y-tengelye a képernyőn felfelé mutat.)
final gx = _fx / fmag, gy = _fy / fmag, gz = _fz / fmag;
final proj = gx * _refX + gy * _refY + gz * _refZ;
bubbleX.value = gx - proj * _refX;
bubbleY.value = gy - proj * _refY;
// A z-komponenst a 2D buborék nem jeleníti meg — függőleges tartónál
// (telefon képernyője ~vízszintes normálissal) x/y hordozza az infót.
}
// ═════════════════════════════════════════════════════════════════
// Kalibráció
// ═════════════════════════════════════════════════════════════════
/// [seconds] másodpercig átlagolja a NYERS gravitáció-vektort.
/// Mozgás esetén a minta zajos lesz — a hívó UI figyelmeztet előtte.
Future<({double x, double y, double z})> captureVector(
{int seconds = 2}) async {
isCapturing.value = true;
double sx = 0, sy = 0, sz = 0;
var n = 0;
final sub = accelerometerEventStream(
samplingPeriod: const Duration(milliseconds: 20))
.listen((e) {
sx += e.x;
sy += e.y;
sz += e.z;
n++;
});
try {
await Future.delayed(Duration(seconds: seconds));
} finally {
await sub.cancel();
isCapturing.value = false;
}
if (n == 0) return (x: 0.0, y: 0.0, z: 1.0);
final mag = math.sqrt(sx * sx + sy * sy + sz * sz);
return (x: sx / mag, y: sy / mag, z: sz / mag);
}
/// Referencia mentése. Egy lépésnél [v2] = null; két lépésnél
/// (180°-os átforgatás) a két egységvektor ÁTLAGA a rúd-tengely —
/// a tartó ferdesége a két mérésben ellentétes előjelű, így kiesik.
Future<void> saveReference(({double x, double y, double z}) v1,
[({double x, double y, double z})? v2]) async {
double x = v1.x, y = v1.y, z = v1.z;
if (v2 != null) {
x = (v1.x + v2.x) / 2;
y = (v1.y + v2.y) / 2;
z = (v1.z + v2.z) / 2;
}
final mag = math.sqrt(x * x + y * y + z * z);
if (mag < 1e-6) return;
_refX = x / mag;
_refY = y / mag;
_refZ = z / mag;
isCalibrated.value = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'tilt_reference', jsonEncode({'x': _refX, 'y': _refY, 'z': _refZ}));
}
Future<void> setPoleHeight(double meters) async {
if (meters <= 0) return;
poleHeight.value = meters;
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('tilt_pole_height', meters);
}
Future<void> clearCalibration() async {
isCalibrated.value = false;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('tilt_reference');
}
}