Compare commits

...
16 Commits
20 changed files with 13686 additions and 619 deletions

No files matched your search

+1307 -155
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -22,14 +22,14 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Display name --> <!-- Display name -->
<ApplicationTitle>LMEMappeCatastaliApp</ApplicationTitle> <ApplicationTitle>LME Mappe Catastali</ApplicationTitle>
<!-- App Identifier --> <!-- App Identifier -->
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId> <ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>0.2.15</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion> <ApplicationVersion>19</ApplicationVersion>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged --> <!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType> <WindowsPackageType>None</WindowsPackageType>
@@ -64,6 +64,7 @@
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" /> <PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" />
<PackageReference Include="Parquet.Net" Version="6.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+7
View File
@@ -17,6 +17,13 @@ public static class MauiProgram
builder.Services.AddMauiBlazorWebView(); builder.Services.AddMauiBlazorWebView();
builder.Services.AddSingleton<PreciseLocationService>(); builder.Services.AddSingleton<PreciseLocationService>();
builder.Services.AddSingleton<CadastralCatalogService>();
builder.Services.AddSingleton<OfficialCadastreService>();
builder.Services.AddSingleton<LocationSearchService>();
builder.Services.AddSingleton<NetworkLocationService>();
builder.Services.AddSingleton<AzureMapsService>();
builder.Services.AddSingleton<ProjectStorageService>();
builder.Services.AddSingleton<MapExportService>();
#if DEBUG #if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools(); builder.Services.AddBlazorWebViewDeveloperTools();
+48 -8
View File
@@ -4,19 +4,42 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
## Funzioni gia implementate ## Funzioni gia implementate
- Mappa interattiva Leaflet con sfondo satellitare Esri e sfondo stradale OpenStreetMap. - Mappa interattiva Leaflet con sfondi Esri, OpenStreetMap, OpenTopoMap, CARTO Voyager, Google Maps e Azure Maps configurabili tramite chiave.
- Layer WMS ufficiale Agenzia delle Entrate: particelle, numeri particella, fogli, fabbricati, strade e acque. - Layer WMS ufficiale Agenzia delle Entrate: particelle, numeri particella, fogli, fabbricati, strade e acque.
- Trasparenza configurabile del layer catastale. - Trasparenza configurabile del layer catastale.
- Interrogazione WMS con click/tap sulla mappa. - Interrogazione WMS con click/tap sulla mappa.
- Import di file KML, KMZ, GeoJSON e JSON. - 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. - Inquadramento e pulizia dei layer importati.
- Disegno di marker, linee, poligoni e rettangoli. - Disegno di marker, linee, poligoni e rettangoli.
- POI/progetti salvabili in locale con `localStorage`. - 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 distinti `Progetti` e `Salvataggio`: il primo gestisce archivio e opzioni live di visualizzazione, il secondo esporta selettivamente l'intera mappa in GeoJSON, KML o KMZ.
- 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. - 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.
- Punto e cerchio di precisione del GPS live possono essere mostrati o nascosti dalle impostazioni senza fermare il rilevamento.
- Marker posizione, cerchio accuratezza in metri, tracciamento percorso. - Marker posizione, cerchio accuratezza in metri, tracciamento percorso.
- Controllo se la posizione GPS cade dentro un poligono importato o disegnato. - 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. - Interrogazione WFS ufficiale Agenzia delle Entrate attorno alla posizione GPS per evidenziare la particella catastale corrente quando il servizio restituisce la geometria.
- Ricerca Comune/Foglio/Particella tramite indice OnData e successivo recupero geometria dal WFS ufficiale AdE. - 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.
- Catalogo dei Comuni incluso nell'APK e cataloghi regionali OnData scaricati in cache solo quando servono.
- Ricerca catastale eseguita dal codice .NET nativo, senza DuckDB WebAssembly nella WebView Android.
- La registrazione della traccia GPS e' disattivata all'avvio; la toolbar di disegno completa puo' essere mostrata o nascosta dal tab Dati.
- Pannello `Layer` accessibile direttamente dalla mappa, con checkbox sincronizzati per confini, numeri particella, fogli, fabbricati, strade e acque.
- Le particelle cercate possono essere aggiunte una dopo l'altra, mantenendo contemporaneamente sulla mappa tutti i confini selezionati.
- Comandi separati per rimuovere le particelle selezionate o pulire importazioni, disegni, POI e traccia GPS.
- Anteprima WMS ufficiale mostrata dentro l'app senza aprire una nuova finestra Android.
- Confini delle particelle selezionate e della particella GPS sovrapposti anche all'anteprima WMS corrente.
- Richieste WMS catastali in EPSG:4258 compatibili con il servizio AdE; i numeri particella sono visibili alle scale di dettaglio consentite dal servizio (circa zoom 17 o superiore).
- Collegamenti separati al Geoportale pubblico e all'Area riservata AdE per CAPTCHA, SPID/CIE e download ufficiali.
## Limite ufficiale AdE da considerare ## Limite ufficiale AdE da considerare
@@ -26,7 +49,24 @@ Il WFS dell'Agenzia delle Entrate consente `GetFeature` con bounding box, ma non
2. una chiamata WFS con bounding box piccola intorno a quel punto; 2. una chiamata WFS con bounding box piccola intorno a quel punto;
3. filtro lato app sul risultato per selezionare la particella corretta. 3. filtro lato app sul risultato per selezionare la particella corretta.
Questo passaggio e' implementato nella schermata "Particella catastale". Per la massima affidabilita inserisci il codice catastale del Comune, ad esempio `F205` per Milano o `H501` per Roma. Questo passaggio e' implementato nel tab `Catasto`. Regione e Comune sono disponibili subito; alla prima selezione di un Comune l'app scarica il file della regione, da circa 6 a 72 MB, e lo conserva nella cache del dispositivo. Fogli e particelle vengono poi popolati automaticamente.
I layer cartografici pubblici WMS/WFS sono open data e non richiedono CAPTCHA. Il CAPTCHA o l'autenticazione SPID/CIE appartengono ai servizi web di consultazione e download documentale: l'app li apre nel sito ufficiale dell'Agenzia e non tenta di aggirarli.
## Azure Maps Gen2
Nel tab `Impostazioni` sono disponibili gli sfondi `Strade - Azure Maps` e `Satellite - Azure Maps`. Per usarli:
1. crea una risorsa Azure Maps con piano Gen2 nel portale Azure;
2. copia una delle chiavi di sottoscrizione della risorsa;
3. inseriscila in `Chiave sottoscrizione Azure Maps Gen2` e premi `Salva chiave Azure Maps`.
La chiave viene validata prima del salvataggio e rimane nel `SecureStorage` del dispositivo; non e' inclusa nel codice o nell'APK. Lasciando vuoto il campo e premendo il pulsante, la chiave viene rimossa.
La quota gratuita mensile Gen2 indicata da Microsoft comprende 5.000 transazioni per le mappe di base e 1.000 per le immagini satellitari. Una transazione raggruppa fino a 15 richieste di tessere. Oltre la quota si applicano le condizioni economiche della sottoscrizione Azure, quindi e' opportuno configurare budget e avvisi nel portale.
- Creazione account e autenticazione: https://learn.microsoft.com/azure/azure-maps/how-to-manage-account-keys
- Prezzi e quota gratuita: https://azure.microsoft.com/pricing/details/azure-maps/
## Aprire in Visual Studio ## Aprire in Visual Studio
@@ -50,16 +90,16 @@ Per Android:
dotnet build -f net9.0-android -p:AndroidSdkDirectory=C:\Users\livio\AppData\Local\Android\Sdk dotnet build -f net9.0-android -p:AndroidSdkDirectory=C:\Users\livio\AppData\Local\Android\Sdk
``` ```
## APK generato ## APK Release generato
La build Debug Android genera: La pubblicazione Android genera:
```text ```text
C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Debug\net9.0-android\com.companyname.lmemappecatastaliapp-Signed.apk C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Release\net9.0-android\publish\com.companyname.lmemappecatastaliapp-Signed.apk
``` ```
Per installarlo con telefono collegato e debug USB attivo: Per installarlo con telefono collegato e debug USB attivo:
```powershell ```powershell
C:\Users\livio\AppData\Local\Android\Sdk\platform-tools\adb.exe install -r C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Debug\net9.0-android\com.companyname.lmemappecatastaliapp-Signed.apk C:\Users\livio\AppData\Local\Android\Sdk\platform-tools\adb.exe install -r C:\Users\livio\Documents\LMEMappeCatastali\LMEMappeCatastaliApp\bin\Release\net9.0-android\publish\com.companyname.lmemappecatastaliapp-Signed.apk
``` ```
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
using System.Text.Json;
namespace LMEMappeCatastaliApp.Services;
public sealed class AzureMapsService
{
private const string TilesetEndpoint = "https://atlas.microsoft.com/map/tileset";
private readonly HttpClient _httpClient = new()
{
Timeout = TimeSpan.FromSeconds(20)
};
private string? _validatedKey;
public AzureMapsService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.15");
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
}
public async Task ValidateKeyAsync(string subscriptionKey, CancellationToken cancellationToken = default)
{
var key = subscriptionKey.Trim();
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException("Chiave Azure Maps mancante.", nameof(subscriptionKey));
}
if (string.Equals(_validatedKey, key, StringComparison.Ordinal))
{
return;
}
var url = $"{TilesetEndpoint}?api-version=2024-04-01&tilesetId=microsoft.base.road";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("subscription-key", key);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden
? "Chiave Azure Maps non valida o priva dei permessi Render."
: $"Azure Maps ha risposto {((int)response.StatusCode)}.");
}
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
using var payload = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken);
if (!payload.RootElement.TryGetProperty("tiles", out _))
{
throw new InvalidDataException("Azure Maps ha restituito una configurazione non valida.");
}
_validatedKey = key;
}
}
+342
View File
@@ -0,0 +1,342 @@
using System.Text;
using System.Text.Json;
using Parquet;
using Parquet.Data;
using Parquet.Schema;
namespace LMEMappeCatastaliApp.Services;
public sealed class CadastralCatalogService
{
private const string DataBaseUrl = "https://raw.githubusercontent.com/ondata/dati_catastali/main/S_0000_ITALIA/anagrafica/";
private readonly HttpClient _httpClient = new()
{
Timeout = Timeout.InfiniteTimeSpan
};
private readonly SemaphoreSlim _downloadLock = new(1, 1);
private IReadOnlyList<MunicipalityOption>? _municipalities;
public CadastralCatalogService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2");
}
public async Task<IReadOnlyList<RegionOption>> GetRegionsAsync()
{
var municipalities = await GetAllMunicipalitiesAsync();
return municipalities
.GroupBy(item => item.DataFile, StringComparer.OrdinalIgnoreCase)
.Select(group => new RegionOption(RegionName(group.Key), group.Key))
.OrderBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
public async Task<IReadOnlyList<MunicipalityOption>> GetMunicipalitiesAsync(string dataFile)
{
var municipalities = await GetAllMunicipalitiesAsync();
return municipalities
.Where(item => string.Equals(item.DataFile, dataFile, StringComparison.OrdinalIgnoreCase))
.OrderBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
public async Task<IReadOnlyList<string>> GetSheetsAsync(
MunicipalityOption municipality,
IProgress<CatalogProgress>? progress = null,
CancellationToken cancellationToken = default)
{
var path = await EnsureRegionFileAsync(municipality.DataFile, progress, cancellationToken);
progress?.Report(new CatalogProgress("Leggo i fogli del Comune...", null));
var sheets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using var reader = await ParquetReader.CreateAsync(path, cancellationToken: cancellationToken);
var communeField = RequiredField(reader.Schema, "comune");
var sheetField = RequiredField(reader.Schema, "foglio");
for (var groupIndex = 0; groupIndex < reader.RowGroupCount; groupIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
using var group = reader.OpenRowGroupReader(groupIndex);
if (!MayContain(group.GetStatistics(communeField), municipality.Code))
{
continue;
}
var count = checked((int)group.RowCount);
var communes = new string[count];
var sheetValues = new string[count];
await group.ReadAsync(communeField, communes, cancellationToken: cancellationToken);
await group.ReadAsync(sheetField, sheetValues, cancellationToken: cancellationToken);
for (var index = 0; index < count; index++)
{
if (string.Equals(communes[index], municipality.Code, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(sheetValues[index]))
{
sheets.Add(sheetValues[index]);
}
}
}
progress?.Report(new CatalogProgress($"Trovati {sheets.Count} fogli.", 1));
return sheets.OrderBy(value => value, NaturalStringComparer.Instance).ToArray();
}
public async Task<IReadOnlyList<ParcelOption>> GetParcelsAsync(
MunicipalityOption municipality,
string sheet,
IProgress<CatalogProgress>? progress = null,
CancellationToken cancellationToken = default)
{
var path = await EnsureRegionFileAsync(municipality.DataFile, progress, cancellationToken);
progress?.Report(new CatalogProgress("Leggo le particelle del foglio...", null));
var parcels = new Dictionary<string, ParcelOption>(StringComparer.OrdinalIgnoreCase);
await using var reader = await ParquetReader.CreateAsync(path, cancellationToken: cancellationToken);
var communeField = RequiredField(reader.Schema, "comune");
var sheetField = RequiredField(reader.Schema, "foglio");
var parcelField = RequiredField(reader.Schema, "particella");
var longitudeField = RequiredField(reader.Schema, "x");
var latitudeField = RequiredField(reader.Schema, "y");
var inspireIdField = RequiredField(reader.Schema, "INSPIREID_LOCALID");
for (var groupIndex = 0; groupIndex < reader.RowGroupCount; groupIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
using var group = reader.OpenRowGroupReader(groupIndex);
if (!MayContain(group.GetStatistics(communeField), municipality.Code)
|| !MayContain(group.GetStatistics(sheetField), sheet))
{
continue;
}
var count = checked((int)group.RowCount);
var communes = new string[count];
var sheets = new string[count];
await group.ReadAsync(communeField, communes, cancellationToken: cancellationToken);
await group.ReadAsync(sheetField, sheets, cancellationToken: cancellationToken);
var matchingIndexes = Enumerable.Range(0, count)
.Where(index => string.Equals(communes[index], municipality.Code, StringComparison.OrdinalIgnoreCase)
&& string.Equals(sheets[index], sheet, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (matchingIndexes.Length == 0)
{
continue;
}
var parcelValues = new string[count];
var inspireIds = new string[count];
var longitudes = new long?[count];
var latitudes = new long?[count];
await group.ReadAsync(parcelField, parcelValues, cancellationToken: cancellationToken);
await group.ReadAsync(inspireIdField, inspireIds, cancellationToken: cancellationToken);
await group.ReadAsync<long>(longitudeField, longitudes.AsMemory(), cancellationToken: cancellationToken);
await group.ReadAsync<long>(latitudeField, latitudes.AsMemory(), cancellationToken: cancellationToken);
foreach (var index in matchingIndexes)
{
var number = parcelValues[index];
if (string.IsNullOrWhiteSpace(number)
|| parcels.ContainsKey(number)
|| !latitudes[index].HasValue
|| !longitudes[index].HasValue)
{
continue;
}
parcels[number] = new ParcelOption(
number,
latitudes[index]!.Value / 1_000_000d,
longitudes[index]!.Value / 1_000_000d,
inspireIds[index] ?? string.Empty);
}
}
progress?.Report(new CatalogProgress($"Trovate {parcels.Count} particelle.", 1));
return parcels.Values.OrderBy(item => item.Number, NaturalStringComparer.Instance).ToArray();
}
private async Task<IReadOnlyList<MunicipalityOption>> GetAllMunicipalitiesAsync()
{
if (_municipalities is not null)
{
return _municipalities;
}
await using var stream = await FileSystem.Current.OpenAppPackageFileAsync("Data/municipalities.json");
_municipalities = await JsonSerializer.DeserializeAsync<List<MunicipalityOption>>(
stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? [];
return _municipalities;
}
private async Task<string> EnsureRegionFileAsync(
string dataFile,
IProgress<CatalogProgress>? progress,
CancellationToken cancellationToken)
{
var safeFileName = Path.GetFileName(dataFile);
var destination = Path.Combine(FileSystem.Current.CacheDirectory, $"catasto-{safeFileName}");
if (IsValidParquetFile(destination))
{
progress?.Report(new CatalogProgress("Uso il catalogo regionale salvato sul telefono.", 1));
return destination;
}
await _downloadLock.WaitAsync(cancellationToken);
try
{
if (IsValidParquetFile(destination))
{
return destination;
}
var temporary = destination + ".download";
if (File.Exists(temporary))
{
File.Delete(temporary);
}
using var response = await _httpClient.GetAsync(
DataBaseUrl + Uri.EscapeDataString(safeFileName),
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength;
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var target = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true);
var buffer = new byte[128 * 1024];
long downloaded = 0;
int read;
while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0)
{
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
downloaded += read;
double? fraction = total > 0 ? (double)downloaded / total.Value : null;
var megabytes = downloaded / 1_048_576d;
progress?.Report(new CatalogProgress($"Scarico dati regionali: {megabytes:0.0} MB", fraction));
}
await target.FlushAsync(cancellationToken);
target.Close();
File.Move(temporary, destination, true);
return destination;
}
catch
{
var temporary = destination + ".download";
if (File.Exists(temporary))
{
File.Delete(temporary);
}
throw;
}
finally
{
_downloadLock.Release();
}
}
private static bool IsValidParquetFile(string path)
{
if (!File.Exists(path) || new FileInfo(path).Length < 12)
{
return false;
}
using var stream = File.OpenRead(path);
Span<byte> marker = stackalloc byte[4];
if (stream.Read(marker) != marker.Length || Encoding.ASCII.GetString(marker) != "PAR1")
{
return false;
}
stream.Seek(-4, SeekOrigin.End);
return stream.Read(marker) == marker.Length && Encoding.ASCII.GetString(marker) == "PAR1";
}
private static DataField RequiredField(ParquetSchema schema, string name) =>
schema.FindDataField(name) ?? throw new InvalidDataException($"Colonna catastale mancante: {name}");
private static bool MayContain(DataColumnStatistics? statistics, string wanted)
{
if (statistics is null)
{
return true;
}
var minimum = StatisticText(statistics.MinValue);
var maximum = StatisticText(statistics.MaxValue);
return minimum is null || maximum is null
|| (string.Compare(wanted, minimum, StringComparison.OrdinalIgnoreCase) >= 0
&& string.Compare(wanted, maximum, StringComparison.OrdinalIgnoreCase) <= 0);
}
private static string? StatisticText(object? value) => value switch
{
null => null,
string text => text,
byte[] bytes => Encoding.UTF8.GetString(bytes),
ReadOnlyMemory<byte> memory => Encoding.UTF8.GetString(memory.Span),
_ => value.ToString()
};
private static string RegionName(string dataFile)
{
var key = Path.GetFileNameWithoutExtension(dataFile);
return key switch
{
"01_Piemonte" => "Piemonte",
"02_ValledAosta" => "Valle d'Aosta",
"03_Lombardia" => "Lombardia",
"05_Veneto" => "Veneto",
"06_Friuli-VeneziaGiulia" => "Friuli-Venezia Giulia",
"07_Liguria" => "Liguria",
"08_Emilia-Romagna" => "Emilia-Romagna",
"09_Toscana" => "Toscana",
"10_Umbria" => "Umbria",
"11_Marche" => "Marche",
"12_Lazio" => "Lazio",
"13_Abruzzo" => "Abruzzo",
"14_Molise" => "Molise",
"15_Campania" => "Campania",
"16_Puglia" => "Puglia",
"17_Basilicata" => "Basilicata",
"18_Calabria" => "Calabria",
"19_Sicilia" => "Sicilia",
"20_Sardegna" => "Sardegna",
_ => key.Length > 3 ? key[3..] : key
};
}
}
public sealed record RegionOption(string Name, string DataFile);
public sealed record MunicipalityOption(string Code, string Name, string DataFile);
public sealed record ParcelOption(string Number, double Latitude, double Longitude, string InspireId);
public sealed record CatalogProgress(string Message, double? Fraction);
internal sealed class NaturalStringComparer : IComparer<string>
{
public static NaturalStringComparer Instance { get; } = new();
public int Compare(string? left, string? right)
{
if (ReferenceEquals(left, right)) return 0;
if (left is null) return -1;
if (right is null) return 1;
var leftNumber = long.TryParse(left, out var leftValue);
var rightNumber = long.TryParse(right, out var rightValue);
if (leftNumber && rightNumber)
{
var numeric = leftValue.CompareTo(rightValue);
return numeric != 0 ? numeric : string.Compare(left, right, StringComparison.OrdinalIgnoreCase);
}
return string.Compare(left, right, StringComparison.CurrentCultureIgnoreCase);
}
}
+185
View File
@@ -0,0 +1,185 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Maui.Devices.Sensors;
namespace LMEMappeCatastaliApp.Services;
public sealed class LocationSearchService
{
private const string NominatimEndpoint = "https://nominatim.openstreetmap.org/search";
private const string ArcGisEndpoint = "https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates";
private readonly HttpClient _httpClient = new()
{
Timeout = TimeSpan.FromSeconds(20)
};
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly Dictionary<string, LocationSearchResult?> _cache = new(StringComparer.OrdinalIgnoreCase);
private DateTimeOffset _lastRemoteRequest = DateTimeOffset.MinValue;
public LocationSearchService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.2");
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
_httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd("it-IT");
}
public async Task<LocationSearchResult?> SearchAsync(string text, CancellationToken cancellationToken = default)
{
var query = text.Trim();
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
if (TryParseCoordinates(query, out var coordinates))
{
return coordinates;
}
if (_cache.TryGetValue(query, out var cached))
{
return cached;
}
var nativeResult = await TryNativeGeocodingAsync(query);
if (nativeResult is not null)
{
_cache[query] = nativeResult;
return nativeResult;
}
var arcGisResult = await SearchArcGisAsync(query, cancellationToken);
if (arcGisResult is not null)
{
return arcGisResult;
}
var remoteResult = await SearchNominatimAsync(query, cancellationToken);
_cache[query] = remoteResult;
return remoteResult;
}
private async Task<LocationSearchResult?> SearchArcGisAsync(string query, CancellationToken cancellationToken)
{
try
{
var url = $"{ArcGisEndpoint}?f=json&forStorage=false&sourceCountry=ITA&maxLocations=1&outFields=Match_addr,Addr_type,Score&SingleLine={Uri.EscapeDataString(query)}";
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
var payload = await JsonSerializer.DeserializeAsync<ArcGisResponse>(content, cancellationToken: cancellationToken);
var candidate = payload?.Candidates?.FirstOrDefault();
if (candidate?.Location is null || candidate.Score < 60)
{
return null;
}
return new LocationSearchResult(candidate.Location.Y, candidate.Location.X, candidate.Address ?? query);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
return null;
}
}
private static async Task<LocationSearchResult?> TryNativeGeocodingAsync(string query)
{
try
{
var locations = await Geocoding.Default.GetLocationsAsync(query);
var location = locations?.FirstOrDefault();
return location is null
? null
: new LocationSearchResult(location.Latitude, location.Longitude, query);
}
catch (Exception)
{
return null;
}
}
private async Task<LocationSearchResult?> SearchNominatimAsync(string query, CancellationToken cancellationToken)
{
await _requestLock.WaitAsync(cancellationToken);
try
{
var wait = TimeSpan.FromSeconds(1) - (DateTimeOffset.UtcNow - _lastRemoteRequest);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, cancellationToken);
}
var url = $"{NominatimEndpoint}?format=jsonv2&limit=1&accept-language=it&q={Uri.EscapeDataString(query)}";
_lastRemoteRequest = DateTimeOffset.UtcNow;
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Il servizio di ricerca ha risposto {((int)response.StatusCode)}.");
}
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
var results = await JsonSerializer.DeserializeAsync<List<NominatimResult>>(content, cancellationToken: cancellationToken);
var result = results?.FirstOrDefault();
if (result is null
|| !double.TryParse(result.Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude)
|| !double.TryParse(result.Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
{
return null;
}
return new LocationSearchResult(latitude, longitude, result.DisplayName ?? query);
}
finally
{
_requestLock.Release();
}
}
private static bool TryParseCoordinates(string query, out LocationSearchResult? result)
{
result = null;
var parts = query.Split([' ', ',', ';'], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2
|| !double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude)
|| !double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude)
|| Math.Abs(latitude) > 90
|| Math.Abs(longitude) > 180)
{
return false;
}
result = new LocationSearchResult(
latitude,
longitude,
$"Coordinate: {latitude:F6}, {longitude:F6}");
return true;
}
private sealed record NominatimResult(
[property: JsonPropertyName("lat")] string? Latitude,
[property: JsonPropertyName("lon")] string? Longitude,
[property: JsonPropertyName("display_name")] string? DisplayName);
private sealed record ArcGisResponse(
[property: JsonPropertyName("candidates")] List<ArcGisCandidate>? Candidates);
private sealed record ArcGisCandidate(
[property: JsonPropertyName("address")] string? Address,
[property: JsonPropertyName("score")] double Score,
[property: JsonPropertyName("location")] ArcGisLocation? Location);
private sealed record ArcGisLocation(
[property: JsonPropertyName("x")] double X,
[property: JsonPropertyName("y")] double Y);
}
public sealed record LocationSearchResult(double Latitude, double Longitude, string Label);
+231
View File
@@ -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<MapExportFile> 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<JsonElement> 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);
+74
View File
@@ -0,0 +1,74 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Maui.Networking;
namespace LMEMappeCatastaliApp.Services;
public sealed class NetworkLocationService
{
private readonly HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(6)
};
public NetworkLocationService()
{
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2.15");
}
public async Task<NetworkLocationSnapshot?> GetApproximateLocationAsync(
CancellationToken cancellationToken = default)
{
if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
{
return null;
}
using var response = await httpClient.GetAsync("https://ipapi.co/json/", cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
var result = await response.Content.ReadFromJsonAsync<IpLocationResponse>(cancellationToken);
if (result is null
|| result.Error == true
|| !result.Latitude.HasValue
|| !result.Longitude.HasValue
|| Math.Abs(result.Latitude.Value) > 90
|| Math.Abs(result.Longitude.Value) > 180)
{
return null;
}
var labelParts = new[] { result.City, result.Region, result.CountryName }
.Where(value => !string.IsNullOrWhiteSpace(value));
return new NetworkLocationSnapshot(
result.Latitude.Value,
result.Longitude.Value,
string.Join(", ", labelParts));
}
private sealed class IpLocationResponse
{
[JsonPropertyName("latitude")]
public double? Latitude { get; init; }
[JsonPropertyName("longitude")]
public double? Longitude { get; init; }
[JsonPropertyName("city")]
public string? City { get; init; }
[JsonPropertyName("region")]
public string? Region { get; init; }
[JsonPropertyName("country_name")]
public string? CountryName { get; init; }
[JsonPropertyName("error")]
public bool? Error { get; init; }
}
}
public sealed record NetworkLocationSnapshot(double Latitude, double Longitude, string Label);
+85
View File
@@ -0,0 +1,85 @@
using System.Globalization;
using System.Xml.Linq;
namespace LMEMappeCatastaliApp.Services;
public sealed class OfficialCadastreService
{
private const string WfsEndpoint = "https://wfs.cartografia.agenziaentrate.gov.it/inspire/wfs/owfs01.php";
private readonly HttpClient _httpClient = new()
{
Timeout = TimeSpan.FromSeconds(30)
};
public OfficialCadastreService()
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LMEMappeCatastali/0.2");
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/gml+xml");
}
public async Task<string> GetParcelsNearAsync(
double latitude,
double longitude,
double radiusMeters,
CancellationToken cancellationToken = default)
{
var meters = Math.Clamp(radiusMeters, 2, 30);
var deltaLatitude = meters / 111_320d;
var longitudeScale = Math.Cos(latitude * Math.PI / 180d);
var deltaLongitude = meters / (111_320d * Math.Max(0.1, longitudeScale));
var bbox = string.Join(",",
[
Format(latitude - deltaLatitude),
Format(longitude - deltaLongitude),
Format(latitude + deltaLatitude),
Format(longitude + deltaLongitude)
]);
var query = new Dictionary<string, string>
{
["language"] = "ita",
["SERVICE"] = "WFS",
["VERSION"] = "2.0.0",
["TYPENAMES"] = "CP:CadastralParcel",
["SRSNAME"] = "urn:ogc:def:crs:EPSG::6706",
["BBOX"] = bbox,
["REQUEST"] = "GetFeature",
["COUNT"] = "20"
};
var url = WfsEndpoint + "?" + string.Join("&", query.Select(item =>
$"{Uri.EscapeDataString(item.Key)}={Uri.EscapeDataString(item.Value)}"));
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseContentRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Il servizio WFS AdE ha risposto {((int)response.StatusCode)}.");
}
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
ThrowIfExceptionReport(xml);
return xml;
}
private static string Format(double value) => value.ToString("R", CultureInfo.InvariantCulture);
private static void ThrowIfExceptionReport(string xml)
{
try
{
var document = XDocument.Parse(xml);
var exceptionText = document.Descendants()
.FirstOrDefault(element => element.Name.LocalName == "ExceptionText")
?.Value
.Trim();
if (!string.IsNullOrWhiteSpace(exceptionText))
{
throw new InvalidOperationException($"Servizio WFS AdE: {exceptionText}");
}
}
catch (System.Xml.XmlException error)
{
throw new InvalidDataException("Il servizio WFS AdE ha restituito una risposta non valida.", error);
}
}
}
+41 -8
View File
@@ -20,6 +20,37 @@ public sealed class PreciseLocationService : IAsyncDisposable
return status; return status;
} }
public async Task<LocationSnapshot?> GetCurrentLocationAsync(CancellationToken cancellationToken = default)
{
var permission = await EnsurePermissionAsync();
if (permission != PermissionStatus.Granted)
{
return null;
}
Location? location = null;
try
{
var request = new GeolocationRequest(GeolocationAccuracy.Best, TimeSpan.FromSeconds(8));
location = await Geolocation.Default.GetLocationAsync(request, cancellationToken);
}
catch (FeatureNotSupportedException)
{
return null;
}
if (location is null)
{
var lastKnown = await Geolocation.Default.GetLastKnownLocationAsync();
if (lastKnown is not null && lastKnown.Timestamp >= DateTimeOffset.UtcNow.AddMinutes(-15))
{
location = lastKnown;
}
}
return location is null ? null : ToSnapshot(location);
}
public async Task StartTrackingAsync(Func<LocationSnapshot, Task> onLocation, Func<string, Task> onError) public async Task StartTrackingAsync(Func<LocationSnapshot, Task> onLocation, Func<string, Task> onError)
{ {
if (trackingCancellation is not null) if (trackingCancellation is not null)
@@ -47,14 +78,7 @@ public sealed class PreciseLocationService : IAsyncDisposable
var location = await Geolocation.Default.GetLocationAsync(request, token); var location = await Geolocation.Default.GetLocationAsync(request, token);
if (location is not null) if (location is not null)
{ {
await onLocation(new LocationSnapshot( await onLocation(ToSnapshot(location));
location.Latitude,
location.Longitude,
location.Accuracy,
location.Altitude,
location.Course,
location.Speed,
location.Timestamp));
} }
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -84,6 +108,15 @@ public sealed class PreciseLocationService : IAsyncDisposable
StopTracking(); StopTracking();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
private static LocationSnapshot ToSnapshot(Location location) => new(
location.Latitude,
location.Longitude,
location.Accuracy,
location.Altitude,
location.Course,
location.Speed,
location.Timestamp);
} }
public sealed record LocationSnapshot( public sealed record LocationSnapshot(
+202
View File
@@ -0,0 +1,202 @@
using System.Text;
using System.Text.Json;
namespace LMEMappeCatastaliApp.Services;
public sealed class ProjectStorageService
{
private const string ProjectExtension = ".lmeproject.json";
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private string ProjectsDirectory => Path.Combine(FileSystem.AppDataDirectory, "Projects");
public async Task<IReadOnlyList<MapProjectSummary>> GetProjectsAsync()
{
await _gate.WaitAsync();
try
{
var files = await ReadProjectFilesAsync();
return files
.Select(item => item.Document.ToSummary())
.OrderByDescending(item => item.UpdatedAt)
.ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
finally
{
_gate.Release();
}
}
public async Task<MapProjectSummary> SaveAsync(
string name,
string snapshotJson,
bool includesCadastralParcels,
string? projectId = null)
{
var normalizedName = NormalizeName(name);
using var snapshotDocument = JsonDocument.Parse(snapshotJson);
if (snapshotDocument.RootElement.ValueKind != JsonValueKind.Object)
{
throw new InvalidDataException("I dati della mappa non sono validi.");
}
await _gate.WaitAsync();
try
{
Directory.CreateDirectory(ProjectsDirectory);
var files = await ReadProjectFilesAsync();
var existing = files.FirstOrDefault(item =>
(!string.IsNullOrWhiteSpace(projectId) && item.Document.Id == projectId)
|| string.Equals(item.Document.Name, normalizedName, StringComparison.CurrentCultureIgnoreCase));
var now = DateTimeOffset.UtcNow;
var id = existing?.Document.Id ?? Guid.NewGuid().ToString("N");
var document = new MapProjectDocument
{
SchemaVersion = 1,
Id = id,
Name = normalizedName,
CreatedAt = existing?.Document.CreatedAt ?? now,
UpdatedAt = now,
IncludesCadastralParcels = includesCadastralParcels,
Map = snapshotDocument.RootElement.Clone()
};
var path = existing?.Path ?? Path.Combine(ProjectsDirectory, BuildFileName(normalizedName, id));
var temporaryPath = path + ".tmp";
try
{
await File.WriteAllTextAsync(
temporaryPath,
JsonSerializer.Serialize(document, _jsonOptions),
new UTF8Encoding(false));
File.Move(temporaryPath, path, true);
}
finally
{
if (File.Exists(temporaryPath))
{
File.Delete(temporaryPath);
}
}
return document.ToSummary();
}
finally
{
_gate.Release();
}
}
public async Task<string> LoadAsync(string projectId)
{
await _gate.WaitAsync();
try
{
var project = (await ReadProjectFilesAsync())
.FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
return project.Document.Map.GetRawText();
}
finally
{
_gate.Release();
}
}
public async Task DeleteAsync(string projectId)
{
await _gate.WaitAsync();
try
{
var project = (await ReadProjectFilesAsync())
.FirstOrDefault(item => item.Document.Id == projectId)
?? throw new FileNotFoundException("Il progetto selezionato non esiste piu'.");
File.Delete(project.Path);
}
finally
{
_gate.Release();
}
}
private async Task<IReadOnlyList<ProjectFileEntry>> ReadProjectFilesAsync()
{
Directory.CreateDirectory(ProjectsDirectory);
var projects = new List<ProjectFileEntry>();
foreach (var path in Directory.EnumerateFiles(ProjectsDirectory, $"*{ProjectExtension}"))
{
try
{
await using var stream = File.OpenRead(path);
var document = await JsonSerializer.DeserializeAsync<MapProjectDocument>(stream, _jsonOptions);
if (document is not null
&& !string.IsNullOrWhiteSpace(document.Id)
&& !string.IsNullOrWhiteSpace(document.Name)
&& document.Map.ValueKind == JsonValueKind.Object)
{
projects.Add(new ProjectFileEntry(path, document));
}
}
catch (JsonException)
{
// A damaged project is ignored so the remaining archive stays usable.
}
catch (IOException)
{
}
}
return projects;
}
private static string NormalizeName(string name)
{
var normalized = string.Join(' ', (name ?? string.Empty).Split(
[' ', '\t', '\r', '\n'],
StringSplitOptions.RemoveEmptyEntries));
if (string.IsNullOrWhiteSpace(normalized))
{
throw new ArgumentException("Inserisci il nome del progetto.");
}
return normalized.Length <= 80 ? normalized : normalized[..80];
}
private static string BuildFileName(string name, string id)
{
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
var safeName = new string(name
.Select(character => invalid.Contains(character) ? '-' : character)
.ToArray())
.Trim(' ', '.');
safeName = string.IsNullOrWhiteSpace(safeName) ? "Progetto" : safeName;
return $"{safeName}--{id[..8]}{ProjectExtension}";
}
private sealed record ProjectFileEntry(string Path, MapProjectDocument Document);
private sealed class MapProjectDocument
{
public int SchemaVersion { get; init; }
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public bool IncludesCadastralParcels { get; init; }
public JsonElement Map { get; init; }
public MapProjectSummary ToSummary() => new(Id, Name, UpdatedAt, IncludesCadastralParcels);
}
}
public sealed record MapProjectSummary(
string Id,
string Name,
DateTimeOffset UpdatedAt,
bool IncludesCadastralParcels);
+682 -138
View File
@@ -1,14 +1,32 @@
:root {
--ink: #17212b;
--muted: #63717c;
--surface: #ffffff;
--panel: #f4f6f7;
--line: #d6dde2;
--accent: #14705e;
--accent-strong: #0c5749;
--warning: #a34b08;
--nav-height: 64px;
}
html, html,
body, body,
#app, #app,
.app-shell { .app-shell,
.map-app,
.map-stage {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
}
html,
body {
font-family: Inter, "Segoe UI", Arial, sans-serif; font-family: Inter, "Segoe UI", Arial, sans-serif;
background: #101820; background: #d8e0e4;
color: #17212b; color: var(--ink);
} }
button, button,
@@ -18,28 +36,32 @@ select {
} }
button { button {
min-height: 38px; min-height: 42px;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
padding: 0 12px; padding: 0 14px;
background: #176b87; background: var(--accent);
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
} }
button:active { button:active:not(:disabled) {
transform: translateY(1px); transform: translateY(1px);
} }
button:disabled {
opacity: 0.48;
}
input, input,
select { select {
width: 100%; width: 100%;
min-height: 38px; min-height: 44px;
border: 1px solid #c8d3dc; border: 1px solid #bfcbd2;
border-radius: 6px; border-radius: 6px;
padding: 0 10px; padding: 0 11px;
background: #fff; background: #fff;
color: #17212b; color: var(--ink);
} }
input[type="range"], input[type="range"],
@@ -47,35 +69,271 @@ input[type="checkbox"] {
min-height: auto; min-height: auto;
} }
.map-app { .map-stage {
display: grid; position: relative;
grid-template-columns: 390px minmax(0, 1fr);
width: 100%;
height: 100%;
} }
.tool-panel { #map {
z-index: 800; position: absolute;
inset: 0;
background: #d8e0e4;
}
.map-toolbar {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
z-index: 900;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 52px;
padding: 7px 8px 7px 13px;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 8px;
background: rgba(22, 31, 38, 0.9);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22);
color: #fff;
}
.app-identity {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.app-identity strong {
font-size: 18px;
white-space: nowrap;
}
.app-identity span {
overflow: hidden;
color: #bfe6dc;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-button {
min-height: 38px;
background: #fff;
color: var(--ink);
}
.toolbar-actions,
.layer-button {
display: flex;
align-items: center;
gap: 7px;
}
.toolbar-actions {
flex: 0 0 auto;
}
.layer-button.is-active {
background: #ccebe3;
color: var(--accent-strong);
}
.layer-symbol {
width: 20px;
height: 20px;
background: url("../lib/leaflet/images/layers.png") center / contain no-repeat;
}
.map-search {
position: absolute;
top: 72px;
left: 12px;
right: 12px;
z-index: 890;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
max-width: 640px;
padding: 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22);
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
transition: opacity 140ms ease, transform 140ms ease;
}
.map-search.is-open {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.map-layer-panel {
position: absolute;
top: 72px;
right: 12px;
z-index: 940;
display: grid;
width: min(320px, calc(100% - 24px));
max-height: calc(100% - 152px);
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 8px;
padding: 9px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22);
}
.map-layer-panel > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 0 7px 4px;
border-bottom: 1px solid var(--line);
}
.layer-panel-close {
width: 36px;
min-width: 36px;
min-height: 34px;
padding: 0;
background: #e9eef0;
color: var(--ink);
font-size: 23px;
}
.map-layer-option {
display: flex;
align-items: center;
gap: 9px;
min-height: 45px;
padding: 6px 4px;
border-bottom: 1px solid #e5eaed;
}
.map-layer-option input {
flex: 0 0 auto;
width: 19px;
height: 19px;
}
.map-layer-option span {
display: grid;
gap: 2px;
}
.map-layer-option small {
color: var(--muted);
font-size: 10px;
font-weight: 600;
}
.layer-panel-actions {
display: grid;
gap: 7px;
padding-top: 9px;
}
.gps-overlay {
position: absolute;
top: 72px;
right: auto;
bottom: auto;
left: 12px;
z-index: 850;
display: grid;
grid-template-columns: minmax(160px, auto) auto auto;
align-items: center;
gap: 7px;
max-width: calc(100% - 24px);
padding: 7px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.2);
transition: top 140ms ease;
}
.gps-overlay.search-open {
top: 140px;
}
.gps-state {
display: grid;
min-width: 0;
padding: 0 6px;
font-size: 11px;
}
.gps-state span,
.gps-state strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gps-state strong {
color: #a33227;
}
.gps-main,
.gps-center {
min-height: 38px;
padding: 0 10px;
white-space: nowrap;
}
.gps-center {
background: #e7ecef;
color: var(--ink);
}
.leaflet-top.leaflet-right {
top: 68px;
right: 2px;
}
.map-stage.search-active .leaflet-top.leaflet-right {
top: 136px;
}
.leaflet-touch .leaflet-draw-toolbar a {
width: 38px;
height: 38px;
}
.tool-sheet {
position: absolute;
top: 0;
bottom: var(--nav-height);
left: 0;
z-index: 1100;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; width: min(430px, calc(100% - 48px));
overflow-y: auto; border-right: 1px solid var(--line);
padding: 16px; background: var(--panel);
background: #f6f8f9; box-shadow: 7px 0 24px rgba(0, 0, 0, 0.24);
border-right: 1px solid #d8e0e6;
} }
.brand { .sheet-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 14px;
padding: 18px 16px 13px;
border-bottom: 1px solid var(--line);
background: #fff;
} }
.eyebrow { .eyebrow {
margin: 0 0 3px; margin: 0 0 4px;
color: #667785; color: var(--muted);
font-size: 12px; font-size: 11px;
font-weight: 800; font-weight: 800;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -88,74 +346,98 @@ p {
h1 { h1 {
margin-bottom: 0; margin-bottom: 0;
font-size: 25px; font-size: 22px;
line-height: 1.08; line-height: 1.12;
} }
h2 { h2 {
margin-bottom: 10px; margin: 10px 0 2px;
font-size: 15px; font-size: 15px;
} }
.status { .close-button {
border-radius: 999px; width: 42px;
padding: 5px 9px; min-width: 42px;
background: #dff5e5; padding: 0;
color: #166534; background: #e9eef0;
font-size: 12px; color: var(--ink);
font-weight: 800; font-size: 27px;
line-height: 1;
}
.panel-content {
display: grid;
gap: 12px;
overflow-y: auto;
padding: 15px 16px 24px;
} }
.panel-section { .panel-section {
padding: 12px;
border: 1px solid #dbe4ea;
border-radius: 8px;
background: #fff;
}
.input-row,
.button-row {
display: flex;
gap: 8px;
}
.input-row input {
min-width: 0;
}
.field-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; gap: 12px;
gap: 10px; padding: 0 0 16px;
border-bottom: 1px solid var(--line);
} }
.field-grid.compact { .panel-section:last-child {
grid-template-columns: 1fr; padding-bottom: 0;
border-bottom: 0;
}
.panel-section h2 {
margin-top: 0;
}
.field-help {
margin-top: -7px;
color: var(--muted);
font-size: 10px;
line-height: 1.35;
} }
label { label {
display: grid; display: grid;
gap: 5px; gap: 5px;
color: #475866; color: #45545f;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
} }
.hint { .button-row {
margin: 10px 0 0; display: flex;
color: #667785; gap: 8px;
font-size: 12px; }
line-height: 1.35;
.button-row > * {
flex: 1;
}
.primary-command,
.secondary-command {
width: 100%;
}
.secondary-command {
border: 1px solid #b9c7ce;
background: #fff;
color: var(--ink);
}
.danger-command {
width: 100%;
border: 1px solid #cfaca8;
background: #fff;
color: #9b2f26;
} }
.file-picker { .file-picker {
position: relative; position: relative;
min-height: 42px; min-height: 52px;
place-items: center; place-items: center;
border: 1px dashed #93a7b5; border: 1px dashed #7e969f;
border-radius: 8px; border-radius: 8px;
background: #f2f7fa; background: #fff;
color: #176b87; color: var(--accent-strong);
cursor: pointer; cursor: pointer;
} }
@@ -166,120 +448,382 @@ label {
cursor: pointer; cursor: pointer;
} }
.catalog-progress,
.service-note {
display: grid;
gap: 7px;
padding: 11px;
border: 1px solid #c7dce0;
border-radius: 8px;
background: #edf7f5;
color: #24443e;
font-size: 12px;
line-height: 1.4;
}
.catalog-progress progress {
width: 100%;
accent-color: var(--accent);
}
.service-note p {
margin-bottom: 0;
}
.protected-service {
border-color: #ead4b9;
background: #fff7ea;
color: #5d4024;
}
.layer-toggles { .layer-toggles {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 9px;
margin-top: 10px; padding: 4px 0;
} }
.layer-toggles label, .layer-toggles label,
.toggle-line { .toggle-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
}
.layer-toggles input,
.toggle-line input {
width: 18px;
height: 18px;
}
.project-save-option {
padding: 10px 11px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
.project-archive-state {
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.measurement-mode-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px; gap: 7px;
} }
.gps-readout { .measurement-mode-grid button {
display: grid;
gap: 6px;
margin-bottom: 10px;
padding: 10px;
border-radius: 8px;
background: #eef6ff;
color: #21405d;
font-size: 13px;
}
.gps-readout strong {
color: #b91c1c;
}
.map-stage {
position: relative;
min-width: 0; min-width: 0;
height: 100%; padding: 0 8px;
} }
#map { .lme-measure-label {
width: 100%; width: 0 !important;
height: 100%; height: 0 !important;
background: #d4dde3; border: 0 !important;
background: transparent !important;
pointer-events: none !important;
}
.lme-measure-label span {
position: absolute;
transform: translate(-50%, -50%);
border: 1px solid rgba(23, 33, 43, 0.35);
border-radius: 4px;
padding: 2px 5px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
color: #17212b;
font-size: 10px;
font-weight: 800;
line-height: 1.25;
white-space: nowrap;
}
.lme-measure-label.imported span {
border-color: #15806a;
color: #0c5749;
}
.lme-measure-label.drawing span {
border-color: #d35d0b;
color: #7c2d12;
}
.lme-measure-label.side span {
padding: 1px 4px;
font-size: 9px;
font-weight: 700;
} }
.map-info { .map-info {
position: absolute; position: absolute;
left: 14px; left: 12px;
right: 14px; bottom: 76px;
bottom: 14px; z-index: 820;
z-index: 700; max-width: min(620px, calc(100% - 460px));
max-width: 760px;
border-radius: 8px; border-radius: 8px;
padding: 10px 12px; padding: 9px 11px;
background: rgba(16, 24, 32, 0.86); background: rgba(22, 31, 38, 0.88);
color: #fff; color: #fff;
font-size: 13px; font-size: 12px;
pointer-events: none; pointer-events: none;
} }
footer { .wms-preview[hidden] {
color: #667785; display: none;
}
.wms-preview {
position: absolute;
inset: 0;
z-index: 1400;
display: grid;
place-items: center;
padding: 16px;
background: rgba(15, 22, 27, 0.72);
}
.wms-preview-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(980px, 100%);
height: min(820px, 100%);
overflow: hidden;
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.34);
}
.wms-preview-dialog > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 12px;
border-bottom: 1px solid var(--line);
}
.wms-preview-dialog > header div {
display: grid;
gap: 2px;
}
.wms-preview-dialog > header small {
color: var(--muted);
font-size: 10px;
}
.wms-preview-body {
display: grid;
min-height: 0;
place-items: center;
overflow: auto;
padding: 12px;
background: #eef2f3;
color: var(--muted);
}
.wms-preview-map {
position: relative;
flex: 0 0 auto;
overflow: hidden;
background: #fff;
}
.wms-preview-map[hidden] {
display: none;
}
.wms-preview-map img,
.wms-preview-map svg {
display: block;
width: 100%;
height: 100%;
}
.wms-preview-map svg {
position: absolute;
inset: 0;
pointer-events: none;
}
.wms-preview-parcel {
fill: rgba(251, 191, 36, 0.28);
fill-rule: evenodd;
stroke: #ef4444;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.wms-preview-gps-parcel {
fill: rgba(56, 189, 248, 0.24);
fill-rule: evenodd;
stroke: #1d4ed8;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.bottom-tabs {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 1200;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
height: var(--nav-height);
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid #bcc7cd;
background: rgba(255, 255, 255, 0.98);
}
.bottom-tabs button {
min-width: 0;
min-height: 0;
border-radius: 0;
padding: 0 5px;
background: transparent;
color: #52616c;
font-size: 12px; font-size: 12px;
line-height: 1.35; }
.bottom-tabs button.active {
box-shadow: inset 0 3px 0 var(--accent);
color: var(--accent-strong);
}
footer {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.leaflet-bottom {
bottom: calc(var(--nav-height) + 6px);
} }
#blazor-error-ui { #blazor-error-ui {
background: #fff3cd;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.7rem 1.25rem;
position: fixed; position: fixed;
width: 100%; right: 8px;
z-index: 1000; bottom: calc(var(--nav-height) + 8px);
left: 8px;
z-index: 2000;
display: none;
border: 1px solid #e4be77;
border-radius: 8px;
padding: 10px 42px 10px 12px;
background: #fff3cd;
color: #5f4614;
} }
#blazor-error-ui .dismiss { #blazor-error-ui .dismiss {
cursor: pointer;
position: absolute; position: absolute;
right: 0.75rem; top: 7px;
top: 0.5rem; right: 10px;
cursor: pointer;
} }
.status-bar-safe-area { .status-bar-safe-area {
display: none; display: none;
} }
@media (max-width: 850px) { @media (max-width: 700px) {
.map-app { .map-toolbar {
grid-template-columns: 1fr; top: 8px;
grid-template-rows: 45% 55%; left: 8px;
right: 8px;
} }
.tool-panel { .map-search {
border-right: 0; top: 68px;
border-bottom: 1px solid #d8e0e6; left: 8px;
padding: 12px; right: 8px;
} }
.brand { .map-layer-panel {
align-items: center; top: 68px;
right: 8px;
width: min(320px, calc(100% - 16px));
max-height: calc(100% - 144px);
} }
h1 { .tool-sheet {
font-size: 22px; top: 10%;
} bottom: var(--nav-height);
}
@supports (-webkit-touch-callout: none) {
.status-bar-safe-area {
display: flex;
position: sticky;
top: 0;
height: env(safe-area-inset-top);
background-color: #f6f8f9;
width: 100%; width: 100%;
z-index: 1; border-top: 1px solid var(--line);
border-right: 0;
border-radius: 8px 8px 0 0;
box-shadow: 0 -7px 24px rgba(0, 0, 0, 0.24);
}
.gps-overlay {
top: 68px;
right: 8px;
bottom: auto;
left: 8px;
grid-template-columns: minmax(0, 1fr) auto auto;
max-width: none;
}
.gps-overlay.search-open {
top: 136px;
}
.leaflet-top.leaflet-right {
top: 122px;
right: 0;
}
.map-stage.search-active .leaflet-top.leaflet-right {
top: 190px;
}
.gps-main,
.gps-center {
padding: 0 8px;
font-size: 11px;
}
.map-info {
right: 8px;
bottom: 72px;
left: 8px;
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bottom-tabs button {
padding: 0 2px;
font-size: 10px;
}
.wms-preview {
padding: 8px;
}
}
@media (max-width: 380px) {
.app-identity span {
display: none;
}
.gps-state {
min-width: 0;
}
.gps-center {
display: none;
}
.bottom-tabs button {
font-size: 9px;
} }
} }
+1
View File
@@ -28,6 +28,7 @@
<script src="_framework/blazor.webview.js" autostart="false"></script> <script src="_framework/blazor.webview.js" autostart="false"></script>
<script src="lib/leaflet/leaflet.js"></script> <script src="lib/leaflet/leaflet.js"></script>
<script src="lib/leaflet-draw/leaflet.draw.js"></script> <script src="lib/leaflet-draw/leaflet.draw.js"></script>
<script src="lib/googlemutant/Leaflet.GoogleMutant.js"></script>
<script src="lib/jszip/jszip.min.js"></script> <script src="lib/jszip/jszip.min.js"></script>
<script src="lib/togeojson/togeojson.umd.js"></script> <script src="lib/togeojson/togeojson.umd.js"></script>
<script src="js/lme-map.js"></script> <script src="js/lme-map.js"></script>
+1874 -307
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,787 @@
this.L = this.L || {};
this.L.GridLayer = this.L.GridLayer || {};
this.L.GridLayer.GoogleMutant = (function (leaflet) {
"use strict";
/**
* GoogleMutant by Iván Sánchez Ortega <ivan@sanchezortega.es> https://ivan.sanchezortega.es
* Source and issue tracking: https://gitlab.com/IvanSanchez/Leaflet.GridLayer.GoogleMutant/
*
* Based on techniques from https://github.com/shramov/leaflet-plugins
* and https://avinmathew.com/leaflet-and-google-maps/ , but relying on MutationObserver.
*
* "THE BEER-WARE LICENSE":
* <ivan@sanchezortega.es> wrote this file. As long as you retain this notice you
* can do whatever you want with this stuff. If we meet some day, and you think
* this stuff is worth it, you can buy me a beer in return.
*
* Uses MIT-licensed code from https://github.com/rsms/js-lru/
*/
// This implementation of LRUMap is a copy of https://github.com/rsms/js-lru/ ,
// trivially adapted for ES6 exports.
/*
The MIT License
Copyright (c) 2010-2020 Rasmus Andersson <https://rsms.me/>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
/**
* A doubly linked list-based Least Recently Used (LRU) cache. Will keep most
* recently used items while discarding least recently used items when its limit
* is reached.
*
* Licensed under MIT. Copyright (c) 2010 Rasmus Andersson <http://hunch.se/>
* See README.md for details.
*
* Illustration of the design:
*
* entry entry entry entry
* ______ ______ ______ ______
* | head |.newer => | |.newer => | |.newer => | tail |
* | A | | B | | C | | D |
* |______| <= older.|______| <= older.|______| <= older.|______|
*
* removed <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- added
*/
const NEWER = Symbol("newer");
const OLDER = Symbol("older");
class LRUMap {
constructor(limit, entries) {
if (typeof limit !== "number") {
// called as (entries)
entries = limit;
limit = 0;
}
this.size = 0;
this.limit = limit;
this.oldest = this.newest = undefined;
this._keymap = new Map();
if (entries) {
this.assign(entries);
if (limit < 1) {
this.limit = this.size;
}
}
}
_markEntryAsUsed(entry) {
if (entry === this.newest) {
// Already the most recenlty used entry, so no need to update the list
return;
}
// HEAD--------------TAIL
// <.older .newer>
// <--- add direction --
// A B C <D> E
if (entry[NEWER]) {
if (entry === this.oldest) {
this.oldest = entry[NEWER];
}
entry[NEWER][OLDER] = entry[OLDER]; // C <-- E.
}
if (entry[OLDER]) {
entry[OLDER][NEWER] = entry[NEWER]; // C. --> E
}
entry[NEWER] = undefined; // D --x
entry[OLDER] = this.newest; // D. --> E
if (this.newest) {
this.newest[NEWER] = entry; // E. <-- D
}
this.newest = entry;
}
assign(entries) {
let entry,
limit = this.limit || Number.MAX_VALUE;
this._keymap.clear();
let it = entries[Symbol.iterator]();
for (let itv = it.next(); !itv.done; itv = it.next()) {
let e = new Entry(itv.value[0], itv.value[1]);
this._keymap.set(e.key, e);
if (!entry) {
this.oldest = e;
} else {
entry[NEWER] = e;
e[OLDER] = entry;
}
entry = e;
if (limit-- == 0) {
throw new Error("overflow");
}
}
this.newest = entry;
this.size = this._keymap.size;
}
get(key) {
// First, find our cache entry
var entry = this._keymap.get(key);
if (!entry) return; // Not cached. Sorry.
// As <key> was found in the cache, register it as being requested recently
this._markEntryAsUsed(entry);
return entry.value;
}
set(key, value) {
var entry = this._keymap.get(key);
if (entry) {
// update existing
entry.value = value;
this._markEntryAsUsed(entry);
return this;
}
// new entry
this._keymap.set(key, (entry = new Entry(key, value)));
if (this.newest) {
// link previous tail to the new tail (entry)
this.newest[NEWER] = entry;
entry[OLDER] = this.newest;
} else {
// we're first in -- yay
this.oldest = entry;
}
// add new entry to the end of the linked list -- it's now the freshest entry.
this.newest = entry;
++this.size;
if (this.size > this.limit) {
// we hit the limit -- remove the head
this.shift();
}
return this;
}
shift() {
// todo: handle special case when limit == 1
var entry = this.oldest;
if (entry) {
if (this.oldest[NEWER]) {
// advance the list
this.oldest = this.oldest[NEWER];
this.oldest[OLDER] = undefined;
} else {
// the cache is exhausted
this.oldest = undefined;
this.newest = undefined;
}
// Remove last strong reference to <entry> and remove links from the purged
// entry being returned:
entry[NEWER] = entry[OLDER] = undefined;
this._keymap.delete(entry.key);
--this.size;
return [entry.key, entry.value];
}
}
// -------------------------------------------------------------------------------------
// Following code (until end of class definition) is optional and can be removed without
// breaking the core functionality.
find(key) {
let e = this._keymap.get(key);
return e ? e.value : undefined;
}
has(key) {
return this._keymap.has(key);
}
delete(key) {
var entry = this._keymap.get(key);
if (!entry) return;
this._keymap.delete(entry.key);
if (entry[NEWER] && entry[OLDER]) {
// relink the older entry with the newer entry
entry[OLDER][NEWER] = entry[NEWER];
entry[NEWER][OLDER] = entry[OLDER];
} else if (entry[NEWER]) {
// remove the link to us
entry[NEWER][OLDER] = undefined;
// link the newer entry to head
this.oldest = entry[NEWER];
} else if (entry[OLDER]) {
// remove the link to us
entry[OLDER][NEWER] = undefined;
// link the newer entry to head
this.newest = entry[OLDER];
} else {
// if(entry[OLDER] === undefined && entry.newer === undefined) {
this.oldest = this.newest = undefined;
}
this.size--;
return entry.value;
}
clear() {
// Not clearing links should be safe, as we don't expose live links to user
this.oldest = this.newest = undefined;
this.size = 0;
this._keymap.clear();
}
keys() {
return new KeyIterator(this.oldest);
}
values() {
return new ValueIterator(this.oldest);
}
entries() {
return this;
}
[Symbol.iterator]() {
return new EntryIterator(this.oldest);
}
forEach(fun, thisObj) {
if (typeof thisObj !== "object") {
thisObj = this;
}
let entry = this.oldest;
while (entry) {
fun.call(thisObj, entry.value, entry.key, this);
entry = entry[NEWER];
}
}
/** Returns a JSON (array) representation */
toJSON() {
var s = new Array(this.size),
i = 0,
entry = this.oldest;
while (entry) {
s[i++] = { key: entry.key, value: entry.value };
entry = entry[NEWER];
}
return s;
}
/** Returns a String representation */
toString() {
var s = "",
entry = this.oldest;
while (entry) {
s += String(entry.key) + ":" + entry.value;
entry = entry[NEWER];
if (entry) {
s += " < ";
}
}
return s;
}
}
function Entry(key, value) {
this.key = key;
this.value = value;
this[NEWER] = undefined;
this[OLDER] = undefined;
}
function EntryIterator(oldestEntry) {
this.entry = oldestEntry;
}
EntryIterator.prototype[Symbol.iterator] = function () {
return this;
};
EntryIterator.prototype.next = function () {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: [ent.key, ent.value] };
} else {
return { done: true, value: undefined };
}
};
function KeyIterator(oldestEntry) {
this.entry = oldestEntry;
}
KeyIterator.prototype[Symbol.iterator] = function () {
return this;
};
KeyIterator.prototype.next = function () {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: ent.key };
} else {
return { done: true, value: undefined };
}
};
function ValueIterator(oldestEntry) {
this.entry = oldestEntry;
}
ValueIterator.prototype[Symbol.iterator] = function () {
return this;
};
ValueIterator.prototype.next = function () {
let ent = this.entry;
if (ent) {
this.entry = ent[NEWER];
return { done: false, value: ent.value };
} else {
return { done: true, value: undefined };
}
};
// GoogleMutant by Iván Sánchez Ortega <ivan@sanchezortega.es>
function waitForAPI(callback, context) {
let checkCounter = 0,
intervalId = null;
intervalId = setInterval(function () {
if (checkCounter >= 20) {
clearInterval(intervalId);
throw new Error("window.google not found after 10 seconds");
}
if (!!window.google && !!window.google.maps && !!window.google.maps.Map) {
clearInterval(intervalId);
callback.call(context);
}
++checkCounter;
}, 500);
}
// 🍂class GridLayer.GoogleMutant
// 🍂extends GridLayer
class GoogleMutant extends leaflet.GridLayer {
static {
// Try to set default option values the Leaflet 2 way
if (this.setDefaultOptions) {
this.setDefaultOptions({
maxZoom: 21, // can be 23, but ugly if more than maxNativeZoom
// 🍂option type: String = 'roadmap'
// Google's map type. Valid values are 'roadmap', 'satellite', 'terrain' or 'hybrid'.
type: "roadmap",
maxNativeZoom: 21,
});
} else {
// If Leaflet v2 is not available, fall back.
this.options ??= {};
this.options.maxZoom ??= 21;
this.options.type ??= "roadmap";
this.options.maxNativeZoom ??= 21;
}
}
constructor(options) {
super(options);
// Couple data structures indexed by tile key
this._tileCallbacks = {}; // Callbacks for promises for tiles that are expected
this._lru = new LRUMap(100); // Tile LRU cache
// Before 2024(ish), Google used two tiles for hybrid mode, reusing
// the aerial tiles. This seems to be no longer the case.
// this._imagesPerTile = this.options.type === "hybrid" ? 2 : 1;
this._imagesPerTile = 1;
this._boundOnMutatedImage = this._onMutatedImage.bind(this);
}
onAdd(map) {
super.onAdd(map);
this._initMutantContainer();
// Attribution and logo nodes are not mutated a second time if the
// mutant is removed and re-added to the map, hence they are
// not cleaned up on layer removal, so they can be added here.
if (this._logoContainer) {
map._controlCorners.bottomleft.appendChild(this._logoContainer);
}
if (this._attributionContainer) {
map._controlCorners.bottomright.appendChild(this._attributionContainer);
}
waitForAPI(() => {
if (!this._map) {
return;
}
this._initMutant();
//handle layer being added to a map for which there are no Google tiles at the given zoom
google.maps.event.addListenerOnce(this._mutant, "idle", () => {
if (!this._map) {
return;
}
this._checkZoomLevels();
this._mutantIsReady = true;
});
});
}
onRemove(map) {
super.onRemove(map);
this._observer.disconnect();
map._container.removeChild(this._mutantContainer);
this._logoContainer?.remove();
this._attributionContainer?.remove();
if (this._mutant) {
google.maps.event.clearListeners(this._mutant, "idle");
}
}
// 🍂method addGoogleLayer(name: String, options?: Object): this
// Adds layer with the given name and options to the google Map instance.
// `name`: one of the google maps API layers, with it's constructor available in `google.maps` object.
// currently following values supported: 'TrafficLayer', 'TransitLayer', 'BicyclingLayer'.
// `options`: see https://developers.google.com/maps/documentation/javascript/reference/map
addGoogleLayer(googleLayerName, options) {
if (!this._subLayers) this._subLayers = {};
this.whenReady(() => {
var Constructor = google.maps[googleLayerName];
var googleLayer = new Constructor(options);
googleLayer.setMap(this._mutant);
this._subLayers[googleLayerName] = googleLayer;
});
return this;
}
// 🍂method removeGoogleLayer(name: String): this
// Removes layer with the given name from the google Map instance.
removeGoogleLayer(googleLayerName) {
this.whenReady(() => {
var googleLayer = this._subLayers && this._subLayers[googleLayerName];
if (googleLayer) {
googleLayer.setMap(null);
delete this._subLayers[googleLayerName];
}
});
return this;
}
_initMutantContainer() {
if (!this._mutantContainer) {
this._mutantContainer = leaflet.DomUtil.create(
"div",
"leaflet-google-mutant leaflet-top leaflet-left"
);
this._mutantContainer.id =
"_MutantContainer_" + leaflet.Util.stamp(this._mutantContainer);
this._mutantContainer.style.pointerEvents = "none";
this._mutantContainer.style.visibility = "hidden";
leaflet.DomEvent.off(this._mutantContainer);
}
this._map.getContainer().appendChild(this._mutantContainer);
this.setOpacity(this.options.opacity);
const style = this._mutantContainer.style;
if (this._map.options.zoomSnap < 1) {
// Fractional zoom needs a bigger mutant container in order to load more (smaller) tiles
style.width = "180%";
style.height = "180%";
} else {
style.width = "100%";
style.height = "100%";
}
style.zIndex = -1;
this._attachObserver(this._mutantContainer);
}
_initMutant() {
if (this._mutant) {
return;
}
var options = {
center: { lat: 0, lng: 0 },
zoom: 0,
tilt: 0,
mapTypeId: this.options.type,
disableDefaultUI: true,
keyboardShortcuts: false,
draggable: false,
disableDoubleClickZoom: true,
scrollwheel: false,
styles: this.options.styles || [],
backgroundColor: "transparent",
};
if (this.options.mapId != null) {
options.mapId = this.options.mapId;
}
var map = new google.maps.Map(this._mutantContainer, options);
this._mutant = map;
this._update();
// 🍂event spawned
// Fired when the mutant has been created.
this.fire("spawned", { mapObject: map });
this._waitControls();
this.once("controls_ready", this._setupAttribution);
}
_attachObserver(node) {
if (!this._observer)
this._observer = new MutationObserver(this._onMutations.bind(this));
// pass in the target node, as well as the observer options
this._observer.observe(node, { childList: true, subtree: true });
// if we are reusing an old _mutantContainer, we must manually detect
// all existing tiles in it
Array.prototype.forEach.call(node.querySelectorAll("img"), this._boundOnMutatedImage);
}
_waitControls() {
const id = setInterval(() => {
const layoutManager = this._mutant.__gm.layoutManager;
if (!layoutManager) {
return;
}
clearInterval(id);
let positions;
// iterate through obfuscated key names to find positions set (atm: layoutManager.o)
Object.keys(layoutManager).forEach(function (key) {
const el = layoutManager[key];
if (el.get) {
if (el.get(1) instanceof Node) {
positions = el;
}
}
});
// 🍂event controls_ready
// Fired when controls positions get available (passed in `positions` property).
this.fire("controls_ready", { positions });
}, 50);
}
_setupAttribution(ev) {
if (!this._map) {
return;
}
// https://developers.google.com/maps/documentation/javascript/reference/control#ControlPosition
const pos = google.maps.ControlPosition;
const ctr = (this._attributionContainer = ev.positions.get(pos.BOTTOM_RIGHT));
ctr.classList.add("leaflet-control", "leaflet-control-attribution");
leaflet.DomEvent.disableClickPropagation(ctr);
ctr.style.height = "14px";
this._map._controlCorners.bottomright.appendChild(ctr);
this._logoContainer = ev.positions.get(pos.BOTTOM_LEFT);
this._logoContainer.style.pointerEvents = "auto";
this._map._controlCorners.bottomleft.appendChild(this._logoContainer);
}
_onMutations(mutations) {
for (var i = 0; i < mutations.length; ++i) {
var mutation = mutations[i];
for (var j = 0; j < mutation.addedNodes.length; ++j) {
var node = mutation.addedNodes[j];
if (node instanceof HTMLImageElement) {
this._onMutatedImage(node);
} else if (node instanceof HTMLElement) {
Array.prototype.forEach.call(
node.querySelectorAll("img"),
this._boundOnMutatedImage
);
}
}
}
}
// Only images which 'src' attrib match this will be considered for moving around.
// Looks like some kind of string-based protobuf, maybe??
// Only the roads (and terrain, and vector-based stuff) match this pattern
_roadRegexp = /!1i(\d+)!2i(\d+)!3i(\d+|VinaFnapurmBegrtn)!/;
// On the other hand, raster imagery matches this other pattern
_satRegexp = /x=(\d+)&y=(\d+)&z=(\d+|VinaFnapurmBegrtn)/;
_onMutatedImage(imgNode) {
let coords;
let match = imgNode.src.match(this._roadRegexp);
let sublayer = 0;
if (match) {
coords = {
z: match[1],
x: match[2],
y: match[3],
};
if (this._imagesPerTile > 1) {
imgNode.style.zIndex = 1;
sublayer = 1;
}
} else {
match = imgNode.src.match(this._satRegexp);
if (match) {
coords = {
x: match[1],
y: match[2],
z: match[3],
};
}
// imgNode.style.zIndex = 0;
sublayer = 0;
}
if (coords) {
var tileKey = this._tileCoordsToKey(coords);
imgNode.style.position = "absolute";
var key = tileKey + "/" + sublayer;
// Cache img so it can also be used in subsequent tile requests
this._lru.set(key, imgNode);
if (key in this._tileCallbacks && this._tileCallbacks[key]) {
// Use the tile for *all* pending callbacks. They'll be cloned anyway.
this._tileCallbacks[key].forEach((callback) => callback(imgNode));
delete this._tileCallbacks[key];
}
}
}
createTile(coords, done) {
const key = this._tileCoordsToKey(coords),
tileContainer = leaflet.DomUtil.create("div");
tileContainer.style.textAlign = "left";
tileContainer.dataset.pending = this._imagesPerTile;
done = done.bind(this, null, tileContainer);
for (var i = 0; i < this._imagesPerTile; ++i) {
const key2 = key + "/" + i,
imgNode = this._lru.get(key2);
if (imgNode) {
tileContainer.appendChild(this._clone(imgNode));
--tileContainer.dataset.pending;
} else {
this._tileCallbacks[key2] = this._tileCallbacks[key2] || [];
this._tileCallbacks[key2].push(
function (c /*, k2*/) {
return function (imgNode) {
c.appendChild(this._clone(imgNode));
--c.dataset.pending;
if (!parseInt(c.dataset.pending)) {
done();
}
}.bind(this);
}.bind(this)(tileContainer /*, key2*/)
);
}
}
if (!parseInt(tileContainer.dataset.pending)) {
leaflet.Util.requestAnimFrame(done);
}
return tileContainer;
}
_clone(imgNode) {
const clonedImgNode = imgNode.cloneNode(true);
clonedImgNode.style.visibility = "visible";
return clonedImgNode;
}
_checkZoomLevels() {
//setting the zoom level on the Google map may result in a different zoom level than the one requested
//(it won't go beyond the level for which they have data).
const zoomLevel = this._map.getZoom(),
gMapZoomLevel = this._mutant.getZoom();
if (!zoomLevel || !gMapZoomLevel) return;
if (
gMapZoomLevel !== zoomLevel || //zoom levels are out of sync, Google doesn't have data
gMapZoomLevel > this.options.maxNativeZoom
) {
//at current location, Google does have data (contrary to maxNativeZoom)
//Update maxNativeZoom
this._setMaxNativeZoom(gMapZoomLevel);
}
}
_setMaxNativeZoom(zoomLevel) {
if (zoomLevel !== this.options.maxNativeZoom) {
this.options.maxNativeZoom = zoomLevel;
this._resetView();
}
}
_update(center) {
// zoom level check needs to happen before super's implementation (tile addition/creation)
// otherwise tiles may be missed if maxNativeZoom is not yet correctly determined
if (this._mutant) {
center = center || this._map.getCenter();
const _center = new google.maps.LatLng(center.lat, center.lng),
zoom = Math.round(this._map.getZoom()),
mutantZoom = this._mutant.getZoom();
this._mutant.setCenter(_center);
//ignore fractional zoom levels
if (zoom !== mutantZoom) {
this._mutant.setZoom(zoom);
if (this._mutantIsReady) this._checkZoomLevels();
//else zoom level check will be done later by 'idle' handler
}
}
super._update(center);
}
// @method whenReady(fn: Function, context?: Object): this
// Runs the given function `fn` when the mutant gets initialized, or immediately
// if it's already initialized, optionally passing a function context.
whenReady(callback, context) {
if (this._mutant) {
callback.call(context || this, { target: this });
} else {
this.on("spawned", callback, context);
}
return this;
}
}
// 🍂factory gridLayer.googleMutant(options)
// Returns a new `GridLayer.GoogleMutant` given its options
if (L && L.gridLayer) {
L.gridLayer.googleMutant = function (options) {
return new L.GridLayer.GoogleMutant(options);
};
}
return GoogleMutant;
})(L);
//# sourceMappingURL=Leaflet.GoogleMutant.js.map
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 600 60"
height="60"
width="600"
id="svg4225"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="spritesheet.svg"
inkscape:export-filename="/home/fpuga/development/upstream/icarto.Leaflet.draw/src/images/spritesheet-2x.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<metadata
id="metadata4258">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4256" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1056"
id="namedview4254"
showgrid="false"
inkscape:zoom="1.3101852"
inkscape:cx="237.56928"
inkscape:cy="7.2419621"
inkscape:window-x="1920"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="svg4225" />
<g
id="enabled"
style="fill:#464646;fill-opacity:1">
<g
id="polyline"
style="fill:#464646;fill-opacity:1">
<path
d="m 18,36 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
id="path4229"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 36,18 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
id="path4231"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 23.142,39.145 -2.285,-2.29 16,-15.998 2.285,2.285 z"
id="path4233"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
<path
id="polygon"
d="M 100,24.565 97.904,39.395 83.07,42 76,28.773 86.463,18 Z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="rectangle"
d="m 140,20 20,0 0,20 -20,0 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="circle"
d="m 221,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="marker"
d="m 270,19 c -4.971,0 -9,4.029 -9,9 0,4.971 5.001,12 9,14 4.001,-2 9,-9.029 9,-14 0,-4.971 -4.029,-9 -9,-9 z m 0,12.5 c -2.484,0 -4.5,-2.014 -4.5,-4.5 0,-2.484 2.016,-4.5 4.5,-4.5 2.485,0 4.5,2.016 4.5,4.5 0,2.486 -2.015,4.5 -4.5,4.5 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<g
id="edit"
style="fill:#464646;fill-opacity:1">
<path
d="m 337,30.156 0,0.407 0,5.604 c 0,1.658 -1.344,3 -3,3 l -10,0 c -1.655,0 -3,-1.342 -3,-3 l 0,-10 c 0,-1.657 1.345,-3 3,-3 l 6.345,0 3.19,-3.17 -9.535,0 c -3.313,0 -6,2.687 -6,6 l 0,10 c 0,3.313 2.687,6 6,6 l 10,0 c 3.314,0 6,-2.687 6,-6 l 0,-8.809 -3,2.968"
id="path4240"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 338.72,24.637 -8.892,8.892 -2.828,0 0,-2.829 8.89,-8.89 z"
id="path4242"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 338.697,17.826 4,0 0,4 -4,0 z"
transform="matrix(-0.70698336,-0.70723018,0.70723018,-0.70698336,567.55917,274.78273)"
id="path4244"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
<g
id="remove"
style="fill:#464646;fill-opacity:1">
<path
d="m 381,42 18,0 0,-18 -18,0 0,18 z m 14,-16 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z"
id="path4247"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 395,20 0,-4 -10,0 0,4 -6,0 0,2 22,0 0,-2 -6,0 z m -2,0 -6,0 0,-2 6,0 0,2 z"
id="path4249"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
</g>
<g
id="disabled"
transform="translate(120,0)"
style="fill:#bbbbbb">
<use
xlink:href="#edit"
id="edit-disabled"
x="0"
y="0"
width="100%"
height="100%" />
<use
xlink:href="#remove"
id="remove-disabled"
x="0"
y="0"
width="100%"
height="100%" />
</g>
<path
style="fill:none;stroke:#464646;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle-3"
d="m 581.65725,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
inkscape:connector-curvature="0" />
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB