242 lines
9.4 KiB
Dart
242 lines
9.4 KiB
Dart
import 'dart:convert';
|
|||
|
|
import 'dart:math' as math;
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:get/get.dart';
|
||
|
|
import 'package:latlong2/latlong.dart';
|
||
|
|
import 'package:flutter_map/flutter_map.dart';
|
||
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||
|
|
|
||
|
|
import '../models/field_property.dart';
|
||
|
|
|
||
|
|
class GeoJsonImportResult {
|
||
|
|
final int imported;
|
||
|
|
final List<String> skipped;
|
||
|
|
const GeoJsonImportResult({required this.imported, required this.skipped});
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Ingatlan-parcellák geometriája (GeoJSON-import + térképi lekérdezés).
|
||
|
|
///
|
||
|
|
/// SZÁNDÉKOSAN tisztán online, nincs helyi tükör-tábla. A tábla
|
||
|
|
/// PROJEKT-FÜGGETLEN, közös katalógus — egy fizikai telek geometriája
|
||
|
|
/// nem egyetlen projekthez tartozik.
|
||
|
|
class ParcelGeometryService extends GetxService {
|
||
|
|
static ParcelGeometryService get to => Get.find();
|
||
|
|
|
||
|
|
SupabaseClient get _client => Supabase.instance.client;
|
||
|
|
|
||
|
|
// ═════════════════════════════════════════════════════════════════
|
||
|
|
// Normalizálás — PONTOSAN a levél-pipeline szabálya (ellenőrzött
|
||
|
|
// minta alapján): minden "/"-lel elválasztott számjegy-szegmensről
|
||
|
|
// levágja a vezető nullákat.
|
||
|
|
// "0173/4" → "173/4", "0620" → "620", "011/9" → "11/9"
|
||
|
|
// ═════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
static String normalizeParcelNumber(String raw) {
|
||
|
|
final trimmed = raw.trim();
|
||
|
|
final parts = trimmed.split('/');
|
||
|
|
final normalized = parts.map((p) {
|
||
|
|
final stripped = p.replaceFirst(RegExp(r'^0+(?=\d)'), '');
|
||
|
|
return stripped.isEmpty ? '0' : stripped;
|
||
|
|
}).join('/');
|
||
|
|
return normalized;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═════════════════════════════════════════════════════════════════
|
||
|
|
// Import
|
||
|
|
// ═════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
static const _settlementKeys = ['telepules', 'település', 'settlement'];
|
||
|
|
static const _parcelKeys = [
|
||
|
|
'hrsz',
|
||
|
|
'helyrajziszam',
|
||
|
|
'helyrajzi_szam',
|
||
|
|
'parcel_number',
|
||
|
|
];
|
||
|
|
|
||
|
|
static String? _extractByKeys(Map<String, dynamic> props, List<String> keys) {
|
||
|
|
final lower = {for (final e in props.entries) e.key.toLowerCase(): e.value};
|
||
|
|
for (final k in keys) {
|
||
|
|
final v = lower[k];
|
||
|
|
if (v != null && v.toString().trim().isNotEmpty)
|
||
|
|
return v.toString().trim();
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A GeoJSON tartalmának importja — Feature-önként upsert, egy hibás
|
||
|
|
/// elem nem akasztja meg a többit.
|
||
|
|
Future<GeoJsonImportResult> importGeoJson(String content,
|
||
|
|
{required String projectId, String? sourceFileName}) async {
|
||
|
|
final dynamic json = jsonDecode(content);
|
||
|
|
final features = (json is Map && json['features'] is List)
|
||
|
|
? json['features'] as List
|
||
|
|
: <dynamic>[];
|
||
|
|
|
||
|
|
var imported = 0;
|
||
|
|
final skipped = <String>[];
|
||
|
|
|
||
|
|
for (final f in features) {
|
||
|
|
try {
|
||
|
|
if (f is! Map) {
|
||
|
|
skipped.add('érvénytelen elem');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
final geometry = f['geometry'];
|
||
|
|
if (geometry is! Map) {
|
||
|
|
skipped.add('geometria nélküli elem');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
final geomType = geometry['type'] as String?;
|
||
|
|
List coords;
|
||
|
|
if (geomType == 'MultiPolygon') {
|
||
|
|
coords = geometry['coordinates'] as List;
|
||
|
|
} else if (geomType == 'Polygon') {
|
||
|
|
// Egységesítjük MultiPolygon-alakúra tároláshoz, hogy a
|
||
|
|
// megjelenítés mindig ugyanazt a szerkezetet olvassa.
|
||
|
|
coords = [geometry['coordinates']];
|
||
|
|
} else {
|
||
|
|
skipped.add('nem támogatott geometria-típus: $geomType');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
final props = (f['properties'] as Map?)?.cast<String, dynamic>() ?? {};
|
||
|
|
final settlement = _extractByKeys(props, _settlementKeys);
|
||
|
|
final rawParcel = _extractByKeys(props, _parcelKeys);
|
||
|
|
|
||
|
|
if (settlement == null || rawParcel == null) {
|
||
|
|
skipped.add(
|
||
|
|
'hiányzó település/helyrajzi szám (talált kulcsok: ${props.keys.join(", ")})');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
final normalized = normalizeParcelNumber(rawParcel);
|
||
|
|
final bbox = _computeBbox(coords);
|
||
|
|
|
||
|
|
await _client.from('terepi_seged_field_parcel_geometries').upsert({
|
||
|
|
'project_id': projectId,
|
||
|
|
'settlement': settlement,
|
||
|
|
'normalized_parcel_number': normalized,
|
||
|
|
'parcel_number_raw': rawParcel,
|
||
|
|
'geometry': {'type': 'MultiPolygon', 'coordinates': coords},
|
||
|
|
'bbox_min_lat': bbox.minLat,
|
||
|
|
'bbox_max_lat': bbox.maxLat,
|
||
|
|
'bbox_min_lon': bbox.minLon,
|
||
|
|
'bbox_max_lon': bbox.maxLon,
|
||
|
|
'source_file': sourceFileName,
|
||
|
|
}, onConflict: 'project_id,settlement,normalized_parcel_number');
|
||
|
|
|
||
|
|
imported++;
|
||
|
|
} catch (e) {
|
||
|
|
skipped.add('hiba: $e');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return GeoJsonImportResult(imported: imported, skipped: skipped);
|
||
|
|
}
|
||
|
|
|
||
|
|
_Bbox _computeBbox(List multiPolygonCoords) {
|
||
|
|
double minLat = double.infinity, maxLat = -double.infinity;
|
||
|
|
double minLon = double.infinity, maxLon = -double.infinity;
|
||
|
|
for (final polygon in multiPolygonCoords) {
|
||
|
|
for (final ring in polygon as List) {
|
||
|
|
for (final point in ring as List) {
|
||
|
|
final lon = (point[0] as num).toDouble();
|
||
|
|
final lat = (point[1] as num).toDouble();
|
||
|
|
if (lat < minLat) minLat = lat;
|
||
|
|
if (lat > maxLat) maxLat = lat;
|
||
|
|
if (lon < minLon) minLon = lon;
|
||
|
|
if (lon > maxLon) maxLon = lon;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return _Bbox(
|
||
|
|
minLat: minLat, maxLat: maxLat, minLon: minLon, maxLon: maxLon);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═════════════════════════════════════════════════════════════════
|
||
|
|
// Lekérdezés a térképi megjelenítéshez
|
||
|
|
// ═════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
/// Egy vagy több ingatlan geometriája (settlement + normalized_parcel_number
|
||
|
|
/// párok alapján) — a property-lista bármely részhalmazára hívható.
|
||
|
|
Future<List<Map<String, dynamic>>> fetchGeometriesFor(
|
||
|
|
List<FieldProperty> properties) async {
|
||
|
|
if (properties.isEmpty) return [];
|
||
|
|
|
||
|
|
// Minden átadott property ugyanahhoz az (aktív) projekthez tartozik —
|
||
|
|
// a geometria is erre a projektre szűkül.
|
||
|
|
final projectId = properties.first.projectId;
|
||
|
|
|
||
|
|
final bySettlement = <String, Set<String>>{};
|
||
|
|
for (final p in properties) {
|
||
|
|
bySettlement
|
||
|
|
.putIfAbsent(p.settlement, () => {})
|
||
|
|
.add(p.normalizedParcelNumber);
|
||
|
|
}
|
||
|
|
|
||
|
|
final result = <Map<String, dynamic>>[];
|
||
|
|
for (final entry in bySettlement.entries) {
|
||
|
|
final rows = await _client
|
||
|
|
.from('terepi_seged_field_parcel_geometries')
|
||
|
|
.select()
|
||
|
|
.eq('project_id', projectId)
|
||
|
|
.eq('settlement', entry.key)
|
||
|
|
.inFilter('normalized_parcel_number', entry.value.toList());
|
||
|
|
result.addAll(List<Map<String, dynamic>>.from(rows));
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═════════════════════════════════════════════════════════════════
|
||
|
|
// flutter_map segédek
|
||
|
|
// ═════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
List<Polygon> parsePolygons(List<Map<String, dynamic>> rows,
|
||
|
|
{Color color = Colors.deepOrange}) {
|
||
|
|
final polygons = <Polygon>[];
|
||
|
|
for (final row in rows) {
|
||
|
|
final geom = row['geometry'] as Map;
|
||
|
|
final coords = geom['coordinates'] as List; // MultiPolygon
|
||
|
|
for (final polygon in coords) {
|
||
|
|
final outerRing = (polygon as List).first as List;
|
||
|
|
final points = outerRing
|
||
|
|
.map((p) =>
|
||
|
|
LatLng((p[1] as num).toDouble(), (p[0] as num).toDouble()))
|
||
|
|
.toList();
|
||
|
|
polygons.add(Polygon(
|
||
|
|
points: points,
|
||
|
|
color: color.withOpacity(0.35),
|
||
|
|
borderColor: color,
|
||
|
|
borderStrokeWidth: 2,
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return polygons;
|
||
|
|
}
|
||
|
|
|
||
|
|
LatLngBounds? computeBounds(List<Map<String, dynamic>> rows) {
|
||
|
|
if (rows.isEmpty) return null;
|
||
|
|
double minLat = double.infinity, maxLat = -double.infinity;
|
||
|
|
double minLon = double.infinity, maxLon = -double.infinity;
|
||
|
|
for (final row in rows) {
|
||
|
|
minLat = math.min(minLat, (row['bbox_min_lat'] as num).toDouble());
|
||
|
|
maxLat = math.max(maxLat, (row['bbox_max_lat'] as num).toDouble());
|
||
|
|
minLon = math.min(minLon, (row['bbox_min_lon'] as num).toDouble());
|
||
|
|
maxLon = math.max(maxLon, (row['bbox_max_lon'] as num).toDouble());
|
||
|
|
}
|
||
|
|
return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _Bbox {
|
||
|
|
final double minLat, maxLat, minLon, maxLon;
|
||
|
|
const _Bbox({
|
||
|
|
required this.minLat,
|
||
|
|
required this.maxLat,
|
||
|
|
required this.minLon,
|
||
|
|
required this.maxLon,
|
||
|
|
});
|
||
|
|
}
|