diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index c65fac7..f9f6a13 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -7,6 +7,7 @@ @inject NetworkLocationService NetworkLocationService @inject AzureMapsService AzureMapsService @inject ProjectStorageService ProjectStorageService +@inject MapExportService MapExportService @implements IAsyncDisposable LME Mappe Catastali @@ -208,6 +209,52 @@ else if (ActiveTab == "projects") {
+
+

Esporta contenuto mappa

+ + +
+ + + + + + +
+ + KML e KMZ sono compatibili con Google Earth. Gli sfondi satellitari e il WMS non vengono incorporati nel file vettoriale. +
+

Salva progetto

@@ -348,6 +395,8 @@ private string ActiveTab { get; set; } = "map"; private string SearchText { get; set; } = string.Empty; private string NewProjectName { get; set; } = string.Empty; + private string ExportFileName { get; set; } = "Mappa"; + private string ExportFormat { get; set; } = "geojson"; private string PoiName { get; set; } = string.Empty; private string SelectedProjectId { get; set; } = string.Empty; private string ProjectArchiveMessage { get; set; } = "Nessun progetto salvato."; @@ -374,6 +423,13 @@ private bool ShowImportedMeasurements { get; set; } private bool ShowDrawingMeasurements { get; set; } = true; private bool ProjectBusy { get; set; } + private bool ExportBusy { get; set; } + private bool ExportImported { get; set; } = true; + private bool ExportImportedSheets { get; set; } = true; + private bool ExportCadastral { get; set; } = true; + private bool ExportDrawings { get; set; } = true; + private bool ExportPoi { get; set; } = true; + private bool ExportTrail { get; set; } = true; private CancellationTokenSource? CatalogCancellation { get; set; } private CancellationTokenSource? GpsLookupCancellation { get; set; } private long LastGpsLookupAt; @@ -395,7 +451,7 @@ { "cadastre" => "Ricerca catastale", "data" => "Dati e disegni", - "projects" => "Archivio progetti", + "projects" => "Salvataggio ed esportazione", "settings" => "Impostazioni", _ => string.Empty }; @@ -404,7 +460,7 @@ { "cadastre" => "Regione, Comune, Foglio, Particella", "data" => "File GIS, misure e POI", - "projects" => "Salva e apri i tuoi lavori", + "projects" => "Progetti e file GIS", "settings" => "Mappa, GPS e servizi ufficiali", _ => string.Empty }; @@ -956,6 +1012,47 @@ return Task.CompletedTask; } + private async Task ExportMapFile() + { + if (!ExportImported && !ExportImportedSheets && !ExportCadastral + && !ExportDrawings && !ExportPoi && !ExportTrail) + { + await JS.InvokeVoidAsync("lmeMap.showInfo", "Seleziona almeno un tipo di oggetto da esportare."); + return; + } + + ExportBusy = true; + try + { + var geoJson = await JS.InvokeAsync("lmeMap.createMapExportGeoJson", new + { + includeImported = ExportImported, + includeImportedSheets = ExportImportedSheets, + includeCadastral = ExportCadastral, + includeDrawings = ExportDrawings, + includePoi = ExportPoi, + includeTrail = ExportTrail + }); + var exported = await MapExportService.CreateAsync(ExportFileName, ExportFormat, geoJson); + await Share.Default.RequestAsync(new ShareFileRequest + { + Title = $"Salva o condividi {exported.FileName}", + File = new ShareFile(exported.Path, exported.MimeType) + }); + await JS.InvokeVoidAsync( + "lmeMap.showInfo", + $"File {exported.FileName} pronto: {exported.FeatureCount} oggetti esportati."); + } + catch (Exception error) + { + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Esportazione non riuscita: {error.GetBaseException().Message}"); + } + finally + { + ExportBusy = false; + } + } + private async Task SaveProject() { var name = NewProjectName.Trim(); @@ -1018,6 +1115,7 @@ var displayOptions = await JS.InvokeAsync("lmeMap.loadProjectSnapshot", snapshot, project.Name); ApplyProjectDisplayOptions(displayOptions); NewProjectName = project.Name; + ExportFileName = project.Name; IncludeCadastralParcels = project.IncludesCadastralParcels; ActiveTab = "map"; await Task.Delay(30); diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index 3f4ff23..8e735f7 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -28,8 +28,8 @@ com.companyname.lmemappecatastaliapp - 0.2.12 - 16 + 0.2.13 + 17 None diff --git a/MauiProgram.cs b/MauiProgram.cs index cc92ba0..afe1ccc 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -23,6 +23,7 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/README.md b/README.md index 4ac6e73..9c92853 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,14 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe - Import di file KML, KMZ, GeoJSON e JSON. - Import GeoJSON compatibile con BOM, geometrie singole, array, FeatureCollection e coordinate Web Mercator EPSG:3857. - Filtro KML/KMZ `solo particelle` attivo per impostazione predefinita, con separazione dei MultiPolygon ed esclusione di GroundOverlay e perimetri del foglio; il layer foglio resta importabile e puo essere mostrato o nascosto separatamente. +- Migrazione automatica dei vecchi progetti: i GroundOverlay `DOC_...` salvati in precedenza insieme alle particelle vengono riconosciuti, separati e nascosti in tempo reale dal relativo check. - Inquadramento e pulizia dei layer importati. - Disegno di marker, linee, poligoni e rettangoli. - Area, Distanza e Cerchio disponibili nella barra Leaflet laterale, nascosta all'avvio e controllata dal pulsante nel tab Dati insieme agli altri strumenti. - Misure permanenti attivabili anche sui layer importati da KML, KMZ e GeoJSON. - Toolbar di disegno completa di icone, modifica, eliminazione singola e pulizia di tutti i disegni/POI. - Archivio multiprogetto nella cartella privata dell'app, con nome, selezione da elenco, sovrascrittura ed eliminazione. +- Tab `Salvataggio` con esportazione selettiva dell'intera mappa in GeoJSON, KML o KMZ: dati importati, cornici/fogli, particelle catastali rosse, disegni, cerchi, POI e traccia GPS. - Ogni progetto salva file GIS, POI, disegni, traccia, inquadratura e tutte le opzioni di visualizzazione: sfondo, layer catastali, trasparenza, misure, foglio importato, barra strumenti e preferenze GPS. - GPS nativo MAUI ad alta precisione con permessi Android/iOS. - Centratura automatica una tantum all'apertura: prima tramite posizione approssimativa della connessione Internet, poi tramite singola lettura GPS se la rete non restituisce una posizione; nessun punto o cerchio viene mostrato e il tracking continuo non viene avviato. @@ -24,7 +26,7 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe - Marker posizione, cerchio accuratezza in metri, tracciamento percorso. - Controllo se la posizione GPS cade dentro un poligono importato o disegnato. - Interrogazione WFS ufficiale Agenzia delle Entrate attorno alla posizione GPS per evidenziare la particella catastale corrente quando il servizio restituisce la geometria. -- Mappa a tutto schermo con tab separati Catasto, Dati, Progetti e Impostazioni, adattabile agli schermi pieghevoli. +- Mappa a tutto schermo con tab separati Catasto, Dati, Salvataggio e Impostazioni, adattabile agli schermi pieghevoli. - Ingrandimento oltre lo zoom satellitare nativo senza tessere "Map data not yet available". - Tendine concatenate Regione, Comune, Foglio e Particella. - Ricerca di localita e indirizzi eseguita nativamente con fallback Esri e OpenStreetMap, senza richieste CORS dalla WebView. diff --git a/Services/AzureMapsService.cs b/Services/AzureMapsService.cs index e85103a..6ddac84 100644 --- a/Services/AzureMapsService.cs +++ b/Services/AzureMapsService.cs @@ -13,7 +13,7 @@ public sealed class AzureMapsService public AzureMapsService() { - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.12"); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.13"); _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); } diff --git a/Services/MapExportService.cs b/Services/MapExportService.cs new file mode 100644 index 0000000..3966050 --- /dev/null +++ b/Services/MapExportService.cs @@ -0,0 +1,231 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; + +namespace LMEMappeCatastaliApp.Services; + +public sealed class MapExportService +{ + private static readonly XNamespace Kml = "http://www.opengis.net/kml/2.2"; + + public async Task CreateAsync( + string requestedName, + string requestedFormat, + string geoJson, + CancellationToken cancellationToken = default) + { + using var document = JsonDocument.Parse(geoJson); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var type) || type.GetString() != "FeatureCollection" + || !root.TryGetProperty("features", out var features) + || features.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("I dati raccolti dalla mappa non sono validi."); + } + + var featureCount = features.GetArrayLength(); + if (featureCount == 0) + { + throw new InvalidOperationException("Nessun oggetto selezionato e disponibile da esportare."); + } + + var format = requestedFormat.Trim().ToLowerInvariant() switch + { + "kml" => "kml", + "kmz" => "kmz", + _ => "geojson" + }; + var baseName = NormalizeFileName(requestedName); + var exportDirectory = Path.Combine(FileSystem.CacheDirectory, "Exports"); + Directory.CreateDirectory(exportDirectory); + var path = Path.Combine(exportDirectory, $"{baseName}.{format}"); + + if (format == "geojson") + { + using var output = File.Create(path); + await JsonSerializer.SerializeAsync(output, root, new JsonSerializerOptions { WriteIndented = true }, cancellationToken); + return new(path, "application/geo+json", featureCount, Path.GetFileName(path)); + } + + var kml = BuildKml(features, baseName); + if (format == "kml") + { + await File.WriteAllTextAsync(path, kml, new UTF8Encoding(false), cancellationToken); + return new(path, "application/vnd.google-earth.kml+xml", featureCount, Path.GetFileName(path)); + } + + await using (var output = File.Create(path)) + using (var archive = new ZipArchive(output, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("doc.kml", CompressionLevel.Optimal); + await using var entryStream = entry.Open(); + await using var writer = new StreamWriter(entryStream, new UTF8Encoding(false)); + await writer.WriteAsync(kml.AsMemory(), cancellationToken); + } + return new(path, "application/vnd.google-earth.kmz", featureCount, Path.GetFileName(path)); + } + + private static string BuildKml(JsonElement features, string documentName) + { + var document = new XElement(Kml + "Document", + new XElement(Kml + "name", documentName), + BuildStyle("imported", "ff2ecc71", "402ecc71"), + BuildStyle("imported-sheet", "ff22803d", "1022803d"), + BuildStyle("cadastral", "ff4444ef", "4224bffb"), + BuildStyle("cadastral-gps", "ffff7a00", "337aff00"), + BuildStyle("drawing", "ff1679f9", "333c92fb"), + BuildStyle("poi", "ffff7a00", "33ff7a00"), + BuildStyle("gps-trail", "ff00a5ff", "2200a5ff")); + + foreach (var feature in features.EnumerateArray()) + { + if (!feature.TryGetProperty("geometry", out var geometry) + || geometry.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + continue; + } + + var properties = feature.TryGetProperty("properties", out var propertyElement) + && propertyElement.ValueKind == JsonValueKind.Object + ? propertyElement + : default; + var category = GetString(properties, "_lmeCategory") ?? "drawing"; + var placemark = new XElement(Kml + "Placemark", + new XElement(Kml + "name", FeatureName(properties, category)), + new XElement(Kml + "styleUrl", $"#{NormalizeStyleId(category)}")); + + var extendedData = BuildExtendedData(properties); + if (extendedData is not null) + { + placemark.Add(extendedData); + } + placemark.Add(BuildGeometry(geometry)); + document.Add(placemark); + } + + return new XDocument(new XDeclaration("1.0", "utf-8", null), new XElement(Kml + "kml", document)).ToString(); + } + + private static XElement BuildStyle(string id, string lineColor, string fillColor) => + new(Kml + "Style", + new XAttribute("id", id), + new XElement(Kml + "LineStyle", + new XElement(Kml + "color", lineColor), + new XElement(Kml + "width", 3)), + new XElement(Kml + "PolyStyle", new XElement(Kml + "color", fillColor))); + + private static XElement? BuildExtendedData(JsonElement properties) + { + if (properties.ValueKind != JsonValueKind.Object) + { + return null; + } + + var data = properties.EnumerateObject() + .Where(item => item.Name != "_lmeCategory" + && item.Value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False) + .Select(item => new XElement(Kml + "Data", + new XAttribute("name", item.Name), + new XElement(Kml + "value", ScalarText(item.Value)))) + .ToArray(); + return data.Length == 0 ? null : new XElement(Kml + "ExtendedData", data); + } + + private static XElement BuildGeometry(JsonElement geometry) + { + var type = geometry.GetProperty("type").GetString(); + if (type == "GeometryCollection") + { + return new XElement(Kml + "MultiGeometry", + geometry.GetProperty("geometries").EnumerateArray().Select(BuildGeometry)); + } + + var coordinates = geometry.GetProperty("coordinates"); + return type switch + { + "Point" => new XElement(Kml + "Point", CoordinatesElement([coordinates])), + "LineString" => new XElement(Kml + "LineString", CoordinatesElement(coordinates.EnumerateArray())), + "Polygon" => BuildPolygon(coordinates), + "MultiPoint" => new XElement(Kml + "MultiGeometry", + coordinates.EnumerateArray().Select(point => new XElement(Kml + "Point", CoordinatesElement([point])))), + "MultiLineString" => new XElement(Kml + "MultiGeometry", + coordinates.EnumerateArray().Select(line => new XElement(Kml + "LineString", CoordinatesElement(line.EnumerateArray())))), + "MultiPolygon" => new XElement(Kml + "MultiGeometry", coordinates.EnumerateArray().Select(BuildPolygon)), + _ => throw new InvalidDataException($"Geometria GeoJSON non supportata: {type}.") + }; + } + + private static XElement BuildPolygon(JsonElement rings) + { + var ringList = rings.EnumerateArray().ToArray(); + var polygon = new XElement(Kml + "Polygon"); + if (ringList.Length > 0) + { + polygon.Add(new XElement(Kml + "outerBoundaryIs", + new XElement(Kml + "LinearRing", CoordinatesElement(ringList[0].EnumerateArray())))); + } + foreach (var ring in ringList.Skip(1)) + { + polygon.Add(new XElement(Kml + "innerBoundaryIs", + new XElement(Kml + "LinearRing", CoordinatesElement(ring.EnumerateArray())))); + } + return polygon; + } + + private static XElement CoordinatesElement(IEnumerable points) => + new(Kml + "coordinates", string.Join(" ", points.Select(FormatCoordinate))); + + private static string FormatCoordinate(JsonElement coordinate) + { + var values = coordinate.EnumerateArray().Take(3) + .Select(value => value.GetDouble().ToString("G17", CultureInfo.InvariantCulture)); + return string.Join(",", values); + } + + private static string FeatureName(JsonElement properties, string category) => + GetString(properties, "name") + ?? GetString(properties, "Name") + ?? GetString(properties, "LABEL") + ?? GetString(properties, "NATIONALCADASTRALREFERENCE") + ?? category switch + { + "imported-sheet" => "Cornice o foglio importato", + "cadastral" => "Particella catastale", + "cadastral-gps" => "Particella GPS", + "poi" => "Punto di interesse", + "gps-trail" => "Traccia GPS", + _ => "Oggetto mappa" + }; + + private static string? GetString(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static string ScalarText(JsonElement value) => value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.GetRawText(); + + private static string NormalizeStyleId(string category) => category switch + { + "imported" or "imported-sheet" or "cadastral" or "cadastral-gps" or "drawing" or "poi" or "gps-trail" => category, + _ => "drawing" + }; + + private static string NormalizeFileName(string requestedName) + { + var name = string.IsNullOrWhiteSpace(requestedName) ? "Mappa" : requestedName.Trim(); + foreach (var invalid in Path.GetInvalidFileNameChars()) + { + name = name.Replace(invalid, '_'); + } + name = Path.GetFileNameWithoutExtension(name).Trim().TrimEnd('.'); + return string.IsNullOrWhiteSpace(name) ? "Mappa" : name[..Math.Min(name.Length, 80)]; + } +} + +public sealed record MapExportFile(string Path, string MimeType, int FeatureCount, string FileName); diff --git a/Services/NetworkLocationService.cs b/Services/NetworkLocationService.cs index dcfceb1..8c7d686 100644 --- a/Services/NetworkLocationService.cs +++ b/Services/NetworkLocationService.cs @@ -13,7 +13,7 @@ public sealed class NetworkLocationService public NetworkLocationService() { - httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.12"); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.13"); } public async Task GetApproximateLocationAsync( diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index e0150ec..8aebabb 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -61,6 +61,7 @@ window.lmeMap = { startMeasurementDrawing, setDrawToolsVisible, clearDrawings, + createMapExportGeoJson, createProjectSnapshot, loadProjectSnapshot, getLegacyProjectSnapshot, @@ -752,10 +753,10 @@ function kmlTextToGeoJson(text, sourceName) { function filterKmlParcelFeatures(features) { const filtered = features.filter(feature => { - const properties = feature.properties || {}; - if (properties["@geometry-type"] === "groundoverlay") { + if (isSheetOrOverlayFeature(feature)) { return false; } + const properties = feature.properties || {}; const name = String(properties.name || properties.Name || properties.LABEL || "").trim(); const identity = [name, properties.layer, properties.Layer, properties.type, properties.Tipo] .filter(Boolean) @@ -770,6 +771,28 @@ function filterKmlParcelFeatures(features) { return removeLikelySheetOutlines(filtered); } +function isSheetOrOverlayFeature(feature) { + const properties = feature?.properties || {}; + const geometryType = String(properties["@geometry-type"] || "").toLowerCase(); + const name = String(properties.name || properties.Name || properties.LABEL || "").trim(); + const context = [ + name, + properties.layer, + properties.Layer, + properties.type, + properties.Tipo, + properties._lmeFolder + ].filter(Boolean).join(" ").toLowerCase(); + + if (geometryType === "groundoverlay" || /^doc[_ -]?\d+$/i.test(name)) { + return true; + } + + const hasParcelNumber = /^[a-z]?\s*\d+[a-z]?(?:[\/. -]\d+[a-z]?)?$/i.test(name); + return !hasParcelNumber + && /\bfoglio\b|\bsheet\b|quadro[ _-]?d[' ]?unione|(?:perimetro|bordo|limite|contorno)[ _-]?(?:del[ _-]?)?foglio|sheet[ _-]?boundary/.test(context); +} + function splitKmlPolygonFeatures(features) { const result = []; const addGeometry = (feature, geometry, partIndex) => { @@ -876,6 +899,7 @@ function setIncludeKmlSheetLayer(enabled) { } function setImportedSheetLayerVisible(enabled) { + migrateMisclassifiedImportedSheets(); lmeState.importedSheetsVisible = Boolean(enabled); applyImportedSheetVisibility(); const count = lmeState.importedSheets?.toGeoJSON()?.features?.length || 0; @@ -884,6 +908,50 @@ function setImportedSheetLayerVisible(enabled) { : "Layer del foglio importato nascosto; le particelle restano visibili."); } +function migrateMisclassifiedImportedSheets() { + if (!lmeState.imported || !lmeState.importedSheets) { + return 0; + } + + const current = lmeState.imported.toGeoJSON(); + const misplaced = (current.features || []).filter(isSheetOrOverlayFeature); + if (!misplaced.length) { + return 0; + } + + const retained = (current.features || []).filter(feature => !isSheetOrOverlayFeature(feature)); + lmeState.imported.clearLayers(); + addImportedFeatures({ type: "FeatureCollection", features: retained }); + addImportedSheetFeatures({ type: "FeatureCollection", features: misplaced }); + refreshImportedMeasurements(); + return misplaced.length; +} + +function addImportedFeatures(geojson) { + return L.geoJSON(geojson || emptyFeatureCollection(), { + style: { color: "#22c55e", weight: 3, opacity: 0.95, fillOpacity: 0.18 }, + pointToLayer: (feature, latlng) => L.circleMarker(latlng, { + radius: 7, + color: "#22c55e", + fillColor: "#ffffff", + fillOpacity: 0.9 + }), + onEachFeature: bindStoredFeaturePopup + }).addTo(lmeState.imported); +} + +function addImportedSheetFeatures(geojson) { + return L.geoJSON(geojson || emptyFeatureCollection(), { + style: { color: "#15803d", weight: 3, opacity: 0.85, fillOpacity: 0.04, dashArray: "9 7" }, + pointToLayer: (feature, latlng) => L.circleMarker(latlng, { + radius: 6, + color: "#15803d", + fillOpacity: 0.25 + }), + onEachFeature: bindStoredFeaturePopup + }).addTo(lmeState.importedSheets); +} + function applyImportedSheetVisibility() { if (!lmeState.map || !lmeState.importedSheets) { return; @@ -1355,6 +1423,85 @@ function formatNumber(value, digits) { }).format(value); } +function createMapExportGeoJson(options) { + migrateMisclassifiedImportedSheets(); + const requested = options || {}; + const features = []; + + if (requested.includeImported !== false) { + appendExportFeatures(features, lmeState.imported?.toGeoJSON(), "imported"); + } + if (requested.includeImportedSheets !== false) { + appendExportFeatures(features, lmeState.importedSheets?.toGeoJSON(), "imported-sheet"); + } + if (requested.includeCadastral !== false) { + appendExportFeatures(features, lmeState.selectedParcelsLayer?.toGeoJSON(), "cadastral"); + appendExportFeatures(features, lmeState.gpsParcelLayer?.toGeoJSON(), "cadastral-gps"); + } + + const drawings = lmeState.drawings?.toGeoJSON() || emptyFeatureCollection(); + if (requested.includeDrawings !== false) { + appendExportFeatures(features, drawings, "drawing", feature => !isPoiFeature(feature)); + } + if (requested.includePoi !== false) { + appendExportFeatures(features, drawings, "poi", isPoiFeature); + } + if (requested.includeTrail !== false && lmeState.gpsTrailPoints.length >= 2) { + features.push({ + type: "Feature", + properties: { name: "Traccia GPS", _lmeCategory: "gps-trail" }, + geometry: { + type: "LineString", + coordinates: lmeState.gpsTrailPoints.map(point => [ + Number(point.lng ?? point[1]), + Number(point.lat ?? point[0]) + ]) + } + }); + } + + return JSON.stringify({ type: "FeatureCollection", features }); +} + +function appendExportFeatures(target, collection, category, predicate = () => true) { + for (const sourceFeature of collection?.features || []) { + if (!sourceFeature?.geometry || !predicate(sourceFeature)) { + continue; + } + const feature = JSON.parse(JSON.stringify(sourceFeature)); + feature.properties = { ...(feature.properties || {}), _lmeCategory: category }; + if (feature.properties._lmeShape === "circle" + && feature.geometry.type === "Point" + && Number(feature.properties._lmeRadius) > 0) { + feature.geometry = circleToPolygon(feature.geometry.coordinates, Number(feature.properties._lmeRadius)); + } + target.push(feature); + } +} + +function isPoiFeature(feature) { + return feature?.geometry?.type === "Point" + && feature?.properties?._lmeShape !== "circle"; +} + +function circleToPolygon(center, radiusMeters) { + const longitude = Number(center?.[0]); + const latitude = Number(center?.[1]); + const earthRadius = 6378137; + const latitudeRadians = latitude * Math.PI / 180; + const ring = []; + for (let index = 0; index <= 64; index += 1) { + const angle = index / 64 * Math.PI * 2; + const north = Math.sin(angle) * radiusMeters; + const east = Math.cos(angle) * radiusMeters; + ring.push([ + longitude + east / (earthRadius * Math.cos(latitudeRadians)) * 180 / Math.PI, + latitude + north / earthRadius * 180 / Math.PI + ]); + } + return { type: "Polygon", coordinates: [ring] }; +} + function createProjectSnapshot(includeCadastralParcels) { const center = lmeState.map.getCenter(); return JSON.stringify({ @@ -1423,25 +1570,8 @@ function loadProjectSnapshot(raw, projectName) { const importedParts = partitionStoredImportedFeatures( data.imported || emptyFeatureCollection(), data.importedSheets || emptyFeatureCollection()); - L.geoJSON(importedParts.parcels, { - style: { color: "#22c55e", weight: 3, opacity: 0.95, fillOpacity: 0.18 }, - pointToLayer: (feature, latlng) => L.circleMarker(latlng, { - radius: 7, - color: "#22c55e", - fillColor: "#ffffff", - fillOpacity: 0.9 - }), - onEachFeature: bindStoredFeaturePopup - }).addTo(lmeState.imported); - L.geoJSON(importedParts.sheets, { - style: { color: "#15803d", weight: 3, opacity: 0.85, fillOpacity: 0.04, dashArray: "9 7" }, - pointToLayer: (feature, latlng) => L.circleMarker(latlng, { - radius: 6, - color: "#15803d", - fillOpacity: 0.25 - }), - onEachFeature: bindStoredFeaturePopup - }).addTo(lmeState.importedSheets); + addImportedFeatures(importedParts.parcels); + addImportedSheetFeatures(importedParts.sheets); const projectDisplayOptions = data.schemaVersion >= 3 && data.displayOptions ? data.displayOptions : null; @@ -1557,6 +1687,10 @@ function partitionStoredImportedFeatures(imported, storedSheets) { const sourceGroups = new Map(); (imported?.features || []).forEach(feature => { + if (isSheetOrOverlayFeature(feature)) { + sheetFeatures.push(feature); + return; + } const source = String(feature?.properties?._lmeSource || ""); if (!/\.(kml|kmz)$/i.test(source)) { parcelFeatures.push(feature);