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 headers; // fejléc vagy "1. oszlop" ... final List> sampleRows; // első max. 6 adatsor final List> allRows; // minden adatsor (nyers) final List 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 (421–960 ezer), EOV X (48–384 ezer), lat (45,5–48,8) és /// lon (16–23) 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 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 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 _splitLine(String line, String delimiter) { final cells = []; 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> 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 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 _guessRoles( List headers, List> rows, bool decimalComma) { final n = headers.length; final sample = rows.take(200).toList(); final roles = List.filled(n, ColumnRole.ignore); // Oszloponkénti statisztika. final stats = List.generate(n, (c) { final values = []; final raw = []; 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 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 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 points, int skipped}) buildPoints({ required CsvPreview preview, required List 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 = []; 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 analyzeGeojson(File file) async { final json = jsonDecode(await file.readAsString()); final features = (json['features'] as List?) ?? []; final propKeys = {}; final rows = >[]; 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, ); } }