Files
MobilApp/lib/services/stakeout_import_service.dart
T

482 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:terepi_seged/services/coord_converter_service.dart';
import '../../models/stakeout_point.dart';
/// Oszlop-szerepek a CSV-megfeleltetéshez.
enum ColumnRole {
ignore('—'),
station('Állomás / pontszám'),
line('Vonal'),
name('Megnevezés'),
eovY('EOV Y (kelet)'),
eovX('EOV X (észak)'),
lat('Szélesség (lat)'),
lon('Hosszúság (lon)'),
elevation('Magasság');
final String label;
const ColumnRole(this.label);
}
/// Az elemzés eredménye — ebből épül az előnézeti képernyő.
class CsvPreview {
final String fileName;
final String delimiter;
final bool hasHeader;
final bool decimalComma;
final List<String> headers; // fejléc vagy "1. oszlop" ...
final List<List<String>> sampleRows; // első max. 6 adatsor
final List<List<String>> allRows; // minden adatsor (nyers)
final List<ColumnRole> guessedRoles;
final String detectedCrs; // 'EOV' | 'WGS84' | '?'
final int badRowCount;
CsvPreview({
required this.fileName,
required this.delimiter,
required this.hasHeader,
required this.decimalComma,
required this.headers,
required this.sampleRows,
required this.allRows,
required this.guessedRoles,
required this.detectedCrs,
required this.badRowCount,
});
int get rowCount => allRows.length;
}
/// CSV / GeoJSON kitűzési pont import — magyar terepi sajátosságokkal:
/// * elválasztó-felismerés (';' a magyar Excel alapértelmezése, ',', tab)
/// * tizedesVESSZŐ kezelése
/// * kódolás: UTF-8, hibánál Latin-1 visszaesés (a koordináták így is
/// hibátlanok, legfeljebb az ő/ű torzulhat a nevekben)
/// * oszlopszerep-felismerés ÉRTÉKTARTOMÁNY alapján — Magyarországon az
/// EOV Y (421960 ezer), EOV X (48384 ezer), lat (45,548,8) és
/// lon (1623) tartományok páronként diszjunktak, így a felismerés
/// nagyon megbízható; a fejlécnevek csak megerősítésként számítanak.
class StakeoutImportService {
StakeoutImportService._();
// ── Magyar értéktartományok ──────────────────────────────────────
static bool _isEovY(double v) => v >= 421000 && v <= 960000;
static bool _isEovX(double v) => v >= 48000 && v <= 384000;
static bool _isLat(double v) => v >= 45.5 && v <= 48.8;
static bool _isLon(double v) => v >= 16.0 && v <= 23.0;
static bool _isElev(double v) => v >= -50 && v <= 3000;
// ═════════════════════════════════════════════════════════════════
// CSV elemzés
// ═════════════════════════════════════════════════════════════════
static Future<CsvPreview> analyzeCsv(File file) async {
final bytes = await file.readAsBytes();
String text;
try {
text = utf8.decode(bytes);
} catch (_) {
text = latin1.decode(bytes); // Windows-1250 közelítése
}
// BOM eltávolítás
if (text.isNotEmpty && text.codeUnitAt(0) == 0xFEFF) {
text = text.substring(1);
}
final lines = text
.split(RegExp(r'\r\n|\r|\n'))
.where((l) => l.trim().isNotEmpty)
.toList();
if (lines.isEmpty) {
throw const FormatException('A fájl üres.');
}
// 1. Elválasztó: amelyikből a legtöbb van KONZISZTENSEN a sorokban.
final delimiter = _detectDelimiter(lines.take(20).toList());
// 2. Sorok felbontása.
var rows = lines.map((l) => _splitLine(l, delimiter)).toList();
final colCount = rows.map((r) => r.length).reduce((a, b) => a > b ? a : b);
// Rövid sorok kipótlása üres cellákkal, hogy a táblázat téglalap legyen.
rows = rows
.map((r) => [...r, ...List.filled(colCount - r.length, '')])
.toList();
// 3. Tizedesvessző? Ha ';' az elválasztó és sok "123,45" mintájú cella
// van, akkor a vessző tizedesjel.
final decimalComma = _detectDecimalComma(rows.take(30).toList());
// 4. Fejléc: az első sor akkor fejléc, ha a cellái NEM számok, de a
// második soréi többségében igen.
final hasHeader = rows.length > 1 &&
_numericRatio(rows.first, decimalComma) < 0.5 &&
_numericRatio(rows[1], decimalComma) >= 0.5;
final headers = hasHeader
? rows.first.map((h) => h.trim()).toList()
: List.generate(colCount, (i) => '${i + 1}. oszlop');
final dataRows = hasHeader ? rows.sublist(1) : rows;
// 5. Oszlopszerepek felismerése.
final roles = _guessRoles(headers, dataRows, decimalComma);
final crs =
roles.contains(ColumnRole.eovY) && roles.contains(ColumnRole.eovX)
? 'EOV'
: roles.contains(ColumnRole.lat) && roles.contains(ColumnRole.lon)
? 'WGS84'
: '?';
return CsvPreview(
fileName: file.uri.pathSegments.last,
delimiter: delimiter,
hasHeader: hasHeader,
decimalComma: decimalComma,
headers: headers,
sampleRows: dataRows.take(6).toList(),
allRows: dataRows,
guessedRoles: roles,
detectedCrs: crs,
badRowCount: 0, // a tényleges építéskor derül ki
);
}
static String _detectDelimiter(List<String> lines) {
var best = ';';
var bestScore = -1;
for (final d in [';', ',', '\t']) {
final counts = lines.map((l) => d.allMatches(l).length).toList();
final min = counts.reduce((a, b) => a < b ? a : b);
// Pontszám: minden sorban legyen legalább 1, és konzisztens legyen.
final score = min > 0 ? min * 10 - (counts.toSet().length - 1) : -1;
if (score > bestScore) {
bestScore = score;
best = d;
}
}
return best;
}
/// Egyszerű felbontás idézőjel-kezeléssel ("a;b" egyben marad).
static List<String> _splitLine(String line, String delimiter) {
final cells = <String>[];
final sb = StringBuffer();
var inQuotes = false;
for (var i = 0; i < line.length; i++) {
final c = line[i];
if (c == '"') {
inQuotes = !inQuotes;
} else if (c == delimiter && !inQuotes) {
cells.add(sb.toString().trim());
sb.clear();
} else {
sb.write(c);
}
}
cells.add(sb.toString().trim());
return cells;
}
static final _decimalCommaRe = RegExp(r'^-?\d+,\d+$');
static final _decimalDotRe = RegExp(r'^-?\d+\.\d+$');
static bool _detectDecimalComma(List<List<String>> rows) {
var comma = 0, dot = 0;
for (final row in rows) {
for (final cell in row) {
if (_decimalCommaRe.hasMatch(cell)) comma++;
if (_decimalDotRe.hasMatch(cell)) dot++;
}
}
return comma > dot;
}
/// Cella → szám, a tizedesjel-beállítás figyelembevételével.
static double? parseNum(String cell, bool decimalComma) {
var s = cell.trim();
if (s.isEmpty) return null;
if (decimalComma) {
s = s.replaceAll(' ', '').replaceAll(',', '.');
}
return double.tryParse(s);
}
static double _numericRatio(List<String> row, bool decimalComma) {
if (row.isEmpty) return 0;
final n = row.where((c) => parseNum(c, decimalComma) != null).length;
return n / row.length;
}
// ── Szerepfelismerés ─────────────────────────────────────────────
static List<ColumnRole> _guessRoles(
List<String> headers, List<List<String>> rows, bool decimalComma) {
final n = headers.length;
final sample = rows.take(200).toList();
final roles = List<ColumnRole>.filled(n, ColumnRole.ignore);
// Oszloponkénti statisztika.
final stats = List.generate(n, (c) {
final values = <double>[];
final raw = <String>[];
for (final row in sample) {
if (c >= row.length || row[c].isEmpty) continue;
raw.add(row[c]);
final v = parseNum(row[c], decimalComma);
if (v != null) values.add(v);
}
final distinct = raw.toSet().length;
final allInt =
values.isNotEmpty && values.every((v) => v == v.roundToDouble());
double share(bool Function(double) test) =>
values.isEmpty ? 0 : values.where(test).length / values.length;
return (
numericRatio: raw.isEmpty ? 0.0 : values.length / raw.length,
values: values,
distinct: distinct,
count: raw.length,
allInt: allInt,
eovY: share(_isEovY),
eovX: share(_isEovX),
lat: share(_isLat),
lon: share(_isLon),
elev: share(_isElev),
);
});
String h(int c) => headers[c].toLowerCase();
bool hHas(int c, List<String> keys) => keys.any((k) => h(c).contains(k));
// 1. Koordináták — tartomány alapján (95% feletti találat kell),
// fejlécnév csak döntetlennél számít.
int pick(double Function(int) score, List<String> headerKeys) {
var best = -1;
var bestScore = 0.94;
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
var s = score(c);
if (s > 0.94 && hHas(c, headerKeys)) s += 0.05;
if (s > bestScore) {
bestScore = s;
best = c;
}
}
return best;
}
final cEovY = pick((c) => stats[c].eovY, ['y', 'kelet', 'east']);
if (cEovY >= 0) roles[cEovY] = ColumnRole.eovY;
final cEovX = pick((c) => stats[c].eovX, ['x', 'észak', 'eszak', 'north']);
if (cEovX >= 0) roles[cEovX] = ColumnRole.eovX;
final cLat = pick((c) => stats[c].lat, ['lat', 'fi', 'szél', 'szel']);
if (cLat >= 0) roles[cLat] = ColumnRole.lat;
final cLon = pick((c) => stats[c].lon, ['lon', 'lambda', 'hossz']);
if (cLon >= 0) roles[cLon] = ColumnRole.lon;
// 2. Magasság: numerikus, elfogadható tartomány, NEM egész-azonosító
// jellegű; fejléc segít.
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
final s = stats[c];
if (s.numericRatio > 0.9 &&
s.elev > 0.94 &&
(hHas(c, ['z', 'mag', 'elev', 'h']) || !s.allInt)) {
roles[c] = ColumnRole.elevation;
break;
}
}
// 3. Állomás/pontszám: egész, (közel) egyedi értékek.
var bestStation = -1;
var bestUnique = 0.9;
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
final s = stats[c];
if (s.numericRatio > 0.95 && s.allInt && s.count > 0) {
var unique = s.distinct / s.count;
if (hHas(c, ['psz', 'pont', 'station', 'áll', 'all', 'id'])) {
unique += 0.05;
}
if (unique > bestUnique) {
bestUnique = unique;
bestStation = c;
}
}
}
if (bestStation >= 0) roles[bestStation] = ColumnRole.station;
// 4. Vonal: kevés egyedi értékű oszlop (a pontszám sokszorosa tartozik
// egy vonalhoz); fejléc segít.
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
final s = stats[c];
final lowCardinality =
s.count >= 10 && s.distinct <= (s.count / 4).ceil();
if (hHas(c, ['line', 'vonal', 'ln']) || lowCardinality) {
roles[c] = ColumnRole.line;
break;
}
}
// 5. Név: az első még szabad, többségében szöveges oszlop.
for (var c = 0; c < n; c++) {
if (roles[c] != ColumnRole.ignore) continue;
if (stats[c].numericRatio < 0.5 && stats[c].count > 0) {
roles[c] = ColumnRole.name;
break;
}
}
return roles;
}
// ═════════════════════════════════════════════════════════════════
// Pontok építése a megfeleltetés alapján
// ═════════════════════════════════════════════════════════════════
/// A (kézzel jóváhagyott) szerep-kiosztás alapján felépíti a pontokat.
/// Visszaadja a pontokat és a kihagyott (hibás) sorok számát.
static ({List<StakeoutPoint> points, int skipped}) buildPoints({
required CsvPreview preview,
required List<ColumnRole> roles,
required int projectId,
}) {
int col(ColumnRole r) => roles.indexOf(r);
final cStation = col(ColumnRole.station);
final cLine = col(ColumnRole.line);
final cName = col(ColumnRole.name);
final cEovY = col(ColumnRole.eovY);
final cEovX = col(ColumnRole.eovX);
final cLat = col(ColumnRole.lat);
final cLon = col(ColumnRole.lon);
final cElev = col(ColumnRole.elevation);
final isEov = cEovY >= 0 && cEovX >= 0;
if (!isEov && !(cLat >= 0 && cLon >= 0)) {
throw const FormatException(
'Hiányzó koordináta-oszlopok: EOV Y+X vagy lat+lon kell.');
}
final conv = CoordConverterService.to;
final points = <StakeoutPoint>[];
var skipped = 0;
var autoStation = 1;
for (final row in preview.allRows) {
double? get(int c) => c >= 0 && c < row.length
? parseNum(row[c], preview.decimalComma)
: null;
double eovY, eovX, lat, lon;
if (isEov) {
final y = get(cEovY), x = get(cEovX);
if (y == null || x == null || !_isEovY(y) || !_isEovX(x)) {
skipped++;
continue;
}
eovY = y;
eovX = x;
final p = conv.eovToWgsPoint(y, x);
lon = p.x;
lat = p.y;
} else {
final la = get(cLat), lo = get(cLon);
if (la == null || lo == null || !_isLat(la) || !_isLon(lo)) {
skipped++;
continue;
}
lat = la;
lon = lo;
final p = conv.wgsToEovPoint(lo, la);
eovY = p.x;
eovX = p.y;
}
final stationVal = get(cStation);
final station = stationVal?.round() ?? autoStation++;
final line = cLine >= 0 && cLine < row.length ? row[cLine].trim() : '';
final name = cName >= 0 && cName < row.length && row[cName].isNotEmpty
? row[cName].trim()
: station.toString();
points.add(StakeoutPoint(
projectId: projectId,
lineId: line,
station: station,
name: name,
source: 'csv',
planEovY: eovY,
planEovX: eovX,
planEovZ: get(cElev),
planLat: lat,
planLon: lon,
));
}
return (points: points, skipped: skipped);
}
// ═════════════════════════════════════════════════════════════════
// GeoJSON
// ═════════════════════════════════════════════════════════════════
/// GeoJSON → CsvPreview-kompatibilis táblázat: a property-k az oszlopok,
/// plusz a kinyert lon/lat. Így ugyanaz az előnézeti/megfeleltetési
/// képernyő szolgálja ki, mint a CSV-t.
static Future<CsvPreview> analyzeGeojson(File file) async {
final json = jsonDecode(await file.readAsString());
final features = (json['features'] as List?) ?? [];
final propKeys = <String>{};
final rows = <List<String>>[];
for (final f in features) {
final geom = f['geometry'];
if (geom == null || geom['type'] != 'Point') continue;
propKeys.addAll(
((f['properties'] as Map?) ?? {}).keys.map((k) => k.toString()));
}
final keys = propKeys.toList();
for (final f in features) {
final geom = f['geometry'];
if (geom == null || geom['type'] != 'Point') continue;
final coords = geom['coordinates'] as List;
final props = (f['properties'] as Map?) ?? {};
rows.add([
for (final k in keys) '${props[k] ?? ''}',
'${coords[0]}', // lon
'${coords[1]}', // lat
if (coords.length > 2) '${coords[2]}' else '',
]);
}
if (rows.isEmpty) {
throw const FormatException('A GeoJSON nem tartalmaz Point elemet.');
}
final headers = [...keys, 'lon', 'lat', 'z'];
final roles = _guessRoles(headers, rows, false);
// A GeoJSON szabvány szerint mindig WGS84 — a koordináta-oszlopokat
// ismerjük, kényszerítjük.
roles[headers.length - 3] = ColumnRole.lon;
roles[headers.length - 2] = ColumnRole.lat;
if (rows.any((r) => r.last.isNotEmpty)) {
roles[headers.length - 1] = ColumnRole.elevation;
}
return CsvPreview(
fileName: file.uri.pathSegments.last,
delimiter: ',',
hasHeader: true,
decimalComma: false,
headers: headers,
sampleRows: rows.take(6).toList(),
allRows: rows,
guessedRoles: roles,
detectedCrs: 'WGS84',
badRowCount: 0,
);
}
}