Compare commits

..
11 Commits
11 changed files with 3380 additions and 180 deletions

No files matched your search

+758 -87
View File
@@ -4,12 +4,16 @@
@inject CadastralCatalogService CatalogService @inject CadastralCatalogService CatalogService
@inject OfficialCadastreService OfficialCadastreService @inject OfficialCadastreService OfficialCadastreService
@inject LocationSearchService LocationSearchService @inject LocationSearchService LocationSearchService
@inject NetworkLocationService NetworkLocationService
@inject AzureMapsService AzureMapsService
@inject ProjectStorageService ProjectStorageService
@inject MapExportService MapExportService
@implements IAsyncDisposable @implements IAsyncDisposable
<PageTitle>LME Mappe Catastali</PageTitle> <PageTitle>LME Mappe Catastali</PageTitle>
<div class="map-app"> <div class="map-app">
<section class="map-stage"> <section class="map-stage @(ActiveTab == "search" ? "search-active" : null)">
<div id="map"></div> <div id="map"></div>
<header class="map-toolbar"> <header class="map-toolbar">
@@ -17,7 +21,16 @@
<strong>LME Catasto</strong> <strong>LME Catasto</strong>
<span>@Status</span> <span>@Status</span>
</div> </div>
<button class="toolbar-button" @onclick='() => SelectTab(ActiveTab == "search" ? "map" : "search")'>Cerca</button> <div class="toolbar-actions">
<button class="toolbar-button layer-button @(LayerPanelOpen ? "is-active" : null)"
title="Layer catastali"
aria-expanded="@LayerPanelOpen"
@onclick="ToggleLayerPanel">
<span class="layer-symbol" aria-hidden="true"></span>
<span>Layer</span>
</button>
<button class="toolbar-button" @onclick='() => SelectTab(ActiveTab == "search" ? "map" : "search")'>Cerca</button>
</div>
</header> </header>
<div class="map-search @(ActiveTab == "search" ? "is-open" : null)"> <div class="map-search @(ActiveTab == "search" ? "is-open" : null)">
@@ -25,6 +38,33 @@
<button @onclick="SearchLocation">Vai</button> <button @onclick="SearchLocation">Vai</button>
</div> </div>
@if (LayerPanelOpen)
{
<section class="map-layer-panel" aria-label="Layer catastali">
<header>
<strong>Layer catastali</strong>
<button class="layer-panel-close" aria-label="Chiudi layer" @onclick="ToggleLayerPanel">×</button>
</header>
@foreach (var layer in CadastralLayers)
{
<label class="map-layer-option">
<input type="checkbox" checked="@layer.Enabled" @onchange="(args) => ToggleCadastreLayer(layer, args)" />
<span>
<strong>@layer.Name</strong>
@if (!string.IsNullOrWhiteSpace(layer.Detail))
{
<small>@layer.Detail</small>
}
</span>
</label>
}
<div class="layer-panel-actions">
<button class="secondary-command" @onclick="ClearSelectedParcels">Rimuovi particelle selezionate</button>
<button class="danger-command" @onclick="ClearMapElements">Pulisci elementi mappa</button>
</div>
</section>
}
<div class="gps-overlay @(ActiveTab == "search" ? "search-open" : null)"> <div class="gps-overlay @(ActiveTab == "search" ? "search-open" : null)">
<div class="gps-state"> <div class="gps-state">
<span id="gpsState">GPS non attivo</span> <span id="gpsState">GPS non attivo</span>
@@ -111,87 +151,210 @@
</div> </div>
</section> </section>
} }
else if (ActiveTab == "data")
{
<section class="panel-content">
<section class="panel-section">
<h2>File GIS</h2>
<label class="file-picker">
<span>Carica KML, KMZ o GeoJSON</span>
<input id="gisFileInput" type="file" accept=".kml,.kmz,.geojson,.json,application/vnd.google-earth.kml+xml,application/vnd.google-earth.kmz,application/geo+json" />
</label>
<label class="toggle-line project-save-option">
<input type="checkbox" checked="@IncludeKmlSheetLayer" @onchange="ToggleKmlSheetLayer" />
<span>KML/KMZ: includi anche il layer del foglio</span>
</label>
<small class="field-help">
@(IncludeKmlSheetLayer
? "Attivato: importa foglio, particelle e altri elementi KML/KMZ."
: "Disattivato: importa solo i poligoni delle particelle riconosciute.")
</small>
<label class="toggle-line project-save-option">
<input type="checkbox" checked="@ShowImportedSheetLayer" @onchange="ToggleImportedSheetLayer" />
<span>Mostra cornice/layer del foglio importato</span>
</label>
<small class="field-help">La cornice grande resta separata dalle particelle e puo essere nascosta anche nei progetti salvati.</small>
<label class="toggle-line project-save-option">
<input type="checkbox" checked="@ShowImportedMeasurements" @onchange="ToggleImportedMeasurements" />
<span>Mostra misure dei file caricati</span>
</label>
<div class="button-row">
<button @onclick="FitImportedLayers">Inquadra dati</button>
<button class="secondary-command" @onclick="ClearImportedLayers">Rimuovi dati</button>
</div>
</section>
<section class="panel-section">
<h2>Disegni e misure</h2>
<label class="toggle-line project-save-option">
<input type="checkbox" checked="@ShowDrawingMeasurements" @onchange="ToggleDrawingMeasurements" />
<span>Mostra superficie, perimetro e lati</span>
</label>
<button class="secondary-command" aria-pressed="@DrawToolsVisible" @onclick="ToggleDrawTools">
@(DrawToolsVisible ? "Nascondi strumenti sulla mappa" : "Mostra strumenti sulla mappa")
</button>
<button class="danger-command" @onclick="ClearDrawings">Elimina disegni e POI</button>
</section>
<section class="panel-section">
<h2>Punti di interesse</h2>
<label>
Nome POI
<input @bind="PoiName" @bind:event="oninput" placeholder="Nome del punto" />
</label>
<button class="primary-command" @onclick="AddProjectMarker">Aggiungi POI al centro mappa</button>
</section>
</section>
}
else if (ActiveTab == "projects") else if (ActiveTab == "projects")
{ {
<section class="panel-content"> <section class="panel-content">
<h2>File GIS</h2> <section class="panel-section">
<label class="file-picker"> <h2>Esporta contenuto mappa</h2>
<span>Carica KML, KMZ o GeoJSON</span> <label>
<input id="gisFileInput" type="file" accept=".kml,.kmz,.geojson,.json,application/vnd.google-earth.kml+xml,application/vnd.google-earth.kmz,application/geo+json" /> Nome file
</label> <input @bind="ExportFileName" @bind:event="oninput" placeholder="Es. Rilievo San Nicola" maxlength="80" />
<div class="button-row"> </label>
<button @onclick="FitImportedLayers">Inquadra</button> <label>
<button class="secondary-command" @onclick="ClearImportedLayers">Pulisci</button> Formato
</div> <select @bind="ExportFormat">
<option value="geojson">GeoJSON</option>
<option value="kml">KML</option>
<option value="kmz">KMZ compresso</option>
</select>
</label>
<div class="export-options" role="group" aria-label="Oggetti da esportare">
<label class="toggle-line project-save-option">
<input type="checkbox" @bind="ExportImported" />
<span>Particelle e dati GIS importati</span>
</label>
<label class="toggle-line project-save-option">
<input type="checkbox" @bind="ExportImportedSheets" />
<span>Cornici, fogli e riquadri KMZ</span>
</label>
<label class="toggle-line project-save-option">
<input type="checkbox" @bind="ExportCadastral" />
<span>Particelle catastali rosse e GPS</span>
</label>
<label class="toggle-line project-save-option">
<input type="checkbox" @bind="ExportDrawings" />
<span>Disegni, aree, linee e cerchi</span>
</label>
<label class="toggle-line project-save-option">
<input type="checkbox" @bind="ExportPoi" />
<span>Punti di interesse</span>
</label>
<label class="toggle-line project-save-option">
<input type="checkbox" @bind="ExportTrail" />
<span>Traccia GPS registrata</span>
</label>
</div>
<button class="primary-command" @onclick="ExportMapFile" disabled="@(ExportBusy || string.IsNullOrWhiteSpace(ExportFileName))">
@(ExportBusy ? "Preparazione file..." : "Esporta e salva file")
</button>
<small class="field-help">KML e KMZ sono compatibili con Google Earth. Gli sfondi satellitari e il WMS non vengono incorporati nel file vettoriale.</small>
</section>
<h2>Progetti e POI</h2> <section class="panel-section">
<label> <h2>Salva progetto</h2>
Nome <label>
<input @bind="ProjectName" @bind:event="oninput" placeholder="Nome progetto o POI" /> Nome progetto
</label> <input @bind="NewProjectName" @bind:event="oninput" placeholder="Es. Rilievo San Nicola" maxlength="80" />
<button class="primary-command" @onclick="AddProjectMarker">Aggiungi POI al centro mappa</button> </label>
<button class="secondary-command" aria-pressed="@DrawToolsVisible" @onclick="ToggleDrawTools"> <label class="toggle-line project-save-option">
@(DrawToolsVisible ? "Nascondi strumenti di disegno" : "Mostra strumenti di disegno") <input type="checkbox" @bind="IncludeCadastralParcels" />
</button> <span>Includi le particelle catastali rosse</span>
<div class="button-row"> </label>
<button @onclick="SaveWorkspace">Salva</button> <button class="primary-command" @onclick="SaveProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(NewProjectName))">Salva progetto</button>
<button @onclick="LoadWorkspace">Carica</button> </section>
</div>
<button class="danger-command" @onclick="ClearDrawings">Elimina disegni e POI</button> <section class="panel-section">
<h2>Apri progetto</h2>
<label>
Progetto salvato
<select value="@SelectedProjectId" @onchange="SelectProject" disabled="@(Projects.Count == 0 || ProjectBusy)">
<option value="">Seleziona un progetto</option>
@foreach (var project in Projects)
{
<option value="@project.Id">@project.Name - @project.UpdatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm")</option>
}
</select>
</label>
<div class="button-row">
<button @onclick="LoadProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">Apri</button>
<button class="danger-command" @onclick="DeleteProject" disabled="@(ProjectBusy || string.IsNullOrWhiteSpace(SelectedProjectId))">Elimina</button>
</div>
<div class="project-archive-state">@ProjectArchiveMessage</div>
</section>
</section> </section>
} }
else if (ActiveTab == "settings") else if (ActiveTab == "settings")
{ {
<section class="panel-content"> <section class="panel-content">
<h2>Visualizzazione</h2> <section class="panel-section">
<label> <h2>Mappa e sfondo</h2>
Sfondo <label>
<select value="@SelectedBaseLayer" @onchange="ChangeBaseLayer"> Sfondo
<option value="satellite">Satellite - Esri World Imagery</option> <select value="@SelectedBaseLayer" @onchange="ChangeBaseLayer">
<option value="osm">Strade - OpenStreetMap</option> <option value="satellite">Satellite - Esri World Imagery</option>
<option value="streets-esri">Strade dettagliate - Esri World Street Map</option> <option value="osm">Strade - OpenStreetMap</option>
<option value="topographic">Topografica - Esri World Topographic</option> <option value="streets-esri">Strade dettagliate - Esri World Street Map</option>
<option value="terrain">Terreno - OpenTopoMap</option> <option value="topographic">Topografica - Esri World Topographic</option>
<option value="carto">Strade chiare - CARTO Voyager</option> <option value="terrain">Terreno - OpenTopoMap</option>
<option value="google-satellite">Satellite - Google Maps</option> <option value="carto">Strade chiare - CARTO Voyager</option>
<option value="google-roadmap">Strade - Google Maps</option> <option value="azure-road">Strade - Azure Maps</option>
</select> <option value="azure-satellite">Satellite - Azure Maps</option>
</label> <option value="google-satellite">Satellite - Google Maps</option>
<label> <option value="google-roadmap">Strade - Google Maps</option>
Chiave Google Maps Platform </select>
<input type="password" @bind="GoogleMapsApiKey" placeholder="API key" autocomplete="off" /> </label>
</label> <label>
<button class="secondary-command" @onclick="SaveGoogleMapsApiKey">Salva chiave Google</button> Trasparenza catasto
<label> <input type="range" min="0" max="1" step="0.05" value="@CadastralOpacity.ToString(System.Globalization.CultureInfo.InvariantCulture)" @onchange="ChangeCadastreOpacity" />
Trasparenza catasto </label>
<input type="range" min="0" max="1" step="0.05" value="0.75" @onchange="ChangeCadastreOpacity" /> </section>
</label>
<div class="layer-toggles">
@foreach (var layer in CadastralLayers)
{
<label>
<input type="checkbox" checked="@layer.Enabled" @onchange="(args) => ToggleCadastreLayer(layer.Key, args)" />
@layer.Name
</label>
}
</div>
<h2>GPS e percorso</h2> <section class="panel-section">
<label class="toggle-line"> <h2>Servizi mappa con chiave</h2>
<input type="checkbox" checked="@TrailEnabled" @onchange="ToggleTrail" /> <label>
Registra la traccia mentre cammini Chiave Google Maps Platform
</label> <input type="password" @bind="GoogleMapsApiKey" placeholder="API key" autocomplete="off" />
</label>
<button class="secondary-command" @onclick="SaveGoogleMapsApiKey">Salva chiave Google</button>
<label>
Chiave sottoscrizione Azure Maps Gen2
<input type="password" @bind="AzureMapsSubscriptionKey" placeholder="Subscription key" autocomplete="off" />
</label>
<button class="secondary-command" @onclick="SaveAzureMapsKey">Salva chiave Azure Maps</button>
<div class="service-note">
<strong>Quota gratuita Azure Maps Gen2</strong>
<p>Include ogni mese 5.000 transazioni per le mappe stradali e 1.000 per le immagini satellitari. Ogni transazione comprende fino a 15 tessere.</p>
</div>
</section>
<h2>Servizi Agenzia delle Entrate</h2> <section class="panel-section">
<div class="service-note protected-service"> <h2>GPS e percorso</h2>
<strong>Download documenti e fogli ufficiali</strong> <label class="toggle-line project-save-option">
<p>Il CAPTCHA o l'accesso SPID/CIE vengono gestiti direttamente dal sito dell'Agenzia. Per sicurezza l'app non tenta di aggirarli.</p> <input type="checkbox" checked="@ShowGpsPosition" @onchange="ToggleGpsPosition" />
<button class="primary-command" @onclick="OpenOfficialGeoportal">Apri Geoportale catastale</button> <span>Mostra punto e cerchio GPS</span>
<button class="secondary-command" @onclick="OpenOfficialReservedArea">Apri Area riservata per download</button> </label>
</div> <label class="toggle-line project-save-option">
<input type="checkbox" checked="@TrailEnabled" @onchange="ToggleTrail" />
<span>Registra la traccia mentre cammini</span>
</label>
</section>
<button class="secondary-command" @onclick="ExportCadastralImage">Apri vista WMS corrente</button> <section class="panel-section">
<footer>Dati catastali: Agenzia delle Entrate, licenza CC BY 4.0. App indipendente.</footer> <h2>Servizi Agenzia delle Entrate</h2>
<div class="service-note protected-service">
<strong>Download documenti e fogli ufficiali</strong>
<p>Il CAPTCHA o l'accesso SPID/CIE vengono gestiti direttamente dal sito dell'Agenzia. Per sicurezza l'app non tenta di aggirarli.</p>
<button class="primary-command" @onclick="OpenOfficialGeoportal">Apri Geoportale catastale</button>
<button class="secondary-command" @onclick="OpenOfficialReservedArea">Apri Area riservata per download</button>
</div>
<button class="secondary-command" @onclick="ExportCadastralImage">Mostra vista WMS corrente</button>
<footer>Dati catastali: Agenzia delle Entrate, licenza CC BY 4.0. App indipendente.</footer>
</section>
</section> </section>
} }
</aside> </aside>
@@ -199,10 +362,30 @@
<div id="mapInfo" class="map-info">Pronto</div> <div id="mapInfo" class="map-info">Pronto</div>
<div id="wmsPreview" class="wms-preview" hidden>
<section class="wms-preview-dialog" role="dialog" aria-modal="true" aria-labelledby="wmsPreviewTitle">
<header>
<div>
<strong id="wmsPreviewTitle">Vista WMS catastale</strong>
<small>Immagine ufficiale AdE con le particelle evidenziate dall'app</small>
</div>
<button id="closeWmsPreview" class="close-button" aria-label="Chiudi vista WMS">×</button>
</header>
<div class="wms-preview-body">
<span id="wmsPreviewLoading">Caricamento...</span>
<div id="wmsPreviewMap" class="wms-preview-map" hidden>
<img id="wmsPreviewImage" alt="Vista WMS catastale dell'area corrente" />
<svg id="wmsPreviewOverlay" aria-hidden="true" preserveAspectRatio="none"></svg>
</div>
</div>
</section>
</div>
<nav class="bottom-tabs" aria-label="Sezioni principali"> <nav class="bottom-tabs" aria-label="Sezioni principali">
<button class="@(ActiveTab == "map" || ActiveTab == "search" ? "active" : null)" @onclick='() => SelectTab("map")'>Mappa</button> <button class="@(ActiveTab == "map" || ActiveTab == "search" ? "active" : null)" @onclick='() => SelectTab("map")'>Mappa</button>
<button class="@(ActiveTab == "cadastre" ? "active" : null)" @onclick='() => SelectTab("cadastre")'>Catasto</button> <button class="@(ActiveTab == "cadastre" ? "active" : null)" @onclick='() => SelectTab("cadastre")'>Catasto</button>
<button class="@(ActiveTab == "projects" ? "active" : null)" @onclick='() => SelectTab("projects")'>Progetti</button> <button class="@(ActiveTab == "data" ? "active" : null)" @onclick='() => SelectTab("data")'>Dati</button>
<button class="@(ActiveTab == "projects" ? "active" : null)" @onclick='() => SelectTab("projects")'>Salvataggio</button>
<button class="@(ActiveTab == "settings" ? "active" : null)" @onclick='() => SelectTab("settings")'>Impostazioni</button> <button class="@(ActiveTab == "settings" ? "active" : null)" @onclick='() => SelectTab("settings")'>Impostazioni</button>
</nav> </nav>
</section> </section>
@@ -211,20 +394,42 @@
@code { @code {
private string ActiveTab { get; set; } = "map"; private string ActiveTab { get; set; } = "map";
private string SearchText { get; set; } = string.Empty; private string SearchText { get; set; } = string.Empty;
private string ProjectName { 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.";
private string Status { get; set; } = "online"; private string Status { get; set; } = "online";
private string SelectedBaseLayer { get; set; } = "satellite"; private string SelectedBaseLayer { get; set; } = "satellite";
private string GoogleMapsApiKey { get; set; } = string.Empty; private string GoogleMapsApiKey { get; set; } = string.Empty;
private string AzureMapsSubscriptionKey { get; set; } = string.Empty;
private string SelectedRegionFile { get; set; } = string.Empty; private string SelectedRegionFile { get; set; } = string.Empty;
private string SelectedMunicipalityCode { get; set; } = string.Empty; private string SelectedMunicipalityCode { get; set; } = string.Empty;
private string SelectedSheet { get; set; } = string.Empty; private string SelectedSheet { get; set; } = string.Empty;
private string SelectedParcelNumber { get; set; } = string.Empty; private string SelectedParcelNumber { get; set; } = string.Empty;
private string CatalogMessage { get; set; } = string.Empty; private string CatalogMessage { get; set; } = string.Empty;
private double? CatalogFraction { get; set; } private double? CatalogFraction { get; set; }
private double CadastralOpacity { get; set; } = 0.75;
private bool CatalogBusy { get; set; } private bool CatalogBusy { get; set; }
private bool GpsActive { get; set; } private bool GpsActive { get; set; }
private bool ShowGpsPosition { get; set; } = true;
private bool DrawToolsVisible { get; set; } private bool DrawToolsVisible { get; set; }
private bool TrailEnabled { get; set; } private bool TrailEnabled { get; set; }
private bool LayerPanelOpen { get; set; }
private bool IncludeCadastralParcels { get; set; } = true;
private bool IncludeKmlSheetLayer { get; set; }
private bool ShowImportedSheetLayer { get; set; }
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? CatalogCancellation { get; set; }
private CancellationTokenSource? GpsLookupCancellation { get; set; } private CancellationTokenSource? GpsLookupCancellation { get; set; }
private long LastGpsLookupAt; private long LastGpsLookupAt;
@@ -234,6 +439,7 @@
private IReadOnlyList<MunicipalityOption> Municipalities { get; set; } = []; private IReadOnlyList<MunicipalityOption> Municipalities { get; set; } = [];
private IReadOnlyList<string> Sheets { get; set; } = []; private IReadOnlyList<string> Sheets { get; set; } = [];
private IReadOnlyList<ParcelOption> Parcels { get; set; } = []; private IReadOnlyList<ParcelOption> Parcels { get; set; } = [];
private IReadOnlyList<MapProjectSummary> Projects { get; set; } = [];
private MunicipalityOption? SelectedMunicipality => private MunicipalityOption? SelectedMunicipality =>
Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode); Municipalities.FirstOrDefault(item => item.Code == SelectedMunicipalityCode);
@@ -244,7 +450,8 @@
private string PanelTitle => ActiveTab switch private string PanelTitle => ActiveTab switch
{ {
"cadastre" => "Ricerca catastale", "cadastre" => "Ricerca catastale",
"projects" => "Progetti e dati", "data" => "Dati e disegni",
"projects" => "Salvataggio ed esportazione",
"settings" => "Impostazioni", "settings" => "Impostazioni",
_ => string.Empty _ => string.Empty
}; };
@@ -252,18 +459,19 @@
private string PanelEyebrow => ActiveTab switch private string PanelEyebrow => ActiveTab switch
{ {
"cadastre" => "Regione, Comune, Foglio, Particella", "cadastre" => "Regione, Comune, Foglio, Particella",
"projects" => "KML, KMZ, GeoJSON e POI", "data" => "File GIS, misure e POI",
"projects" => "Progetti e file GIS",
"settings" => "Mappa, GPS e servizi ufficiali", "settings" => "Mappa, GPS e servizi ufficiali",
_ => string.Empty _ => string.Empty
}; };
private readonly List<CadastralLayer> CadastralLayers = private readonly List<CadastralLayer> CadastralLayers =
[ [
new("CP.CadastralParcel", "Particelle", true), new("CP.CadastralParcel", "Confini particelle", true),
new("codice_plla", "Numeri particelle", true), new("codice_plla", "Numeri particelle", true, "Visibili circa da zoom 17"),
new("CP.CadastralZoning", "Fogli", true), new("CP.CadastralZoning", "Fogli catastali", true),
new("fabbricati", "Fabbricati", true), new("fabbricati", "Fabbricati", true),
new("strade", "Strade", false), new("strade", "Strade catastali", false),
new("acque", "Acque", false) new("acque", "Acque", false)
]; ];
@@ -278,6 +486,15 @@
GoogleMapsApiKey = string.Empty; GoogleMapsApiKey = string.Empty;
} }
try
{
AzureMapsSubscriptionKey = await SecureStorage.Default.GetAsync("azure_maps_subscription_key") ?? string.Empty;
}
catch
{
AzureMapsSubscriptionKey = string.Empty;
}
try try
{ {
Regions = await CatalogService.GetRegionsAsync(); Regions = await CatalogService.GetRegionsAsync();
@@ -286,6 +503,8 @@
{ {
CatalogMessage = $"Catalogo Comuni non disponibile: {error.Message}"; CatalogMessage = $"Catalogo Comuni non disponibile: {error.Message}";
} }
await RefreshProjectsAsync();
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -293,12 +512,148 @@
if (firstRender) if (firstRender)
{ {
await JS.InvokeVoidAsync("lmeMap.init"); await JS.InvokeVoidAsync("lmeMap.init");
await CenterOnInitialLocationAsync();
await MigrateLegacyProjectAsync();
}
}
private async Task ConfigureProjectMapProviderAsync(string snapshot)
{
string? baseLayer = null;
try
{
using var document = System.Text.Json.JsonDocument.Parse(snapshot);
if (document.RootElement.TryGetProperty("displayOptions", out var displayOptions)
&& displayOptions.TryGetProperty("baseLayer", out var baseLayerProperty))
{
baseLayer = baseLayerProperty.GetString();
}
}
catch
{
return;
}
try
{
if (baseLayer?.StartsWith("google-", StringComparison.Ordinal) == true
&& !string.IsNullOrWhiteSpace(GoogleMapsApiKey))
{
await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey.Trim());
}
if (baseLayer?.StartsWith("azure-", StringComparison.Ordinal) == true
&& !string.IsNullOrWhiteSpace(AzureMapsSubscriptionKey))
{
await JS.InvokeVoidAsync("lmeMap.configureAzureMaps", AzureMapsSubscriptionKey.Trim());
}
}
catch
{
// Loading continues with the default map if the saved provider is unavailable.
}
}
private async Task CenterOnInitialLocationAsync()
{
NetworkLocationSnapshot? networkLocation = null;
try
{
using var networkTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
networkLocation = await NetworkLocationService.GetApproximateLocationAsync(networkTimeout.Token);
}
catch
{
// An unavailable IP service must not prevent the GPS fallback.
}
if (networkLocation is not null)
{
await JS.InvokeVoidAsync(
"lmeMap.showInitialPosition",
networkLocation.Latitude,
networkLocation.Longitude,
true,
networkLocation.Label);
return;
}
try
{
using var gpsTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(18));
var location = await LocationService.GetCurrentLocationAsync(gpsTimeout.Token);
if (location is not null)
{
await JS.InvokeVoidAsync(
"lmeMap.showInitialPosition",
location.Latitude,
location.Longitude,
false,
string.Empty);
}
else
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Posizione iniziale non disponibile. La mappa resta utilizzabile normalmente.");
}
}
catch (OperationCanceledException)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Posizione iniziale non disponibile. Usa Avvia GPS per riprovare.");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Posizione iniziale non disponibile: {error.GetBaseException().Message}");
}
}
private async Task MigrateLegacyProjectAsync()
{
try
{
var legacySnapshot = await JS.InvokeAsync<string?>("lmeMap.getLegacyProjectSnapshot");
if (string.IsNullOrWhiteSpace(legacySnapshot))
{
return;
}
var migrated = await ProjectStorageService.SaveAsync(
"Progetto precedente",
legacySnapshot,
false);
await JS.InvokeVoidAsync("lmeMap.clearLegacyProjectSnapshot");
await RefreshProjectsAsync();
SelectedProjectId = migrated.Id;
await InvokeAsync(StateHasChanged);
}
catch
{
// The legacy value remains available and migration can be retried at the next start.
} }
} }
private async Task SelectTab(string tab) private async Task SelectTab(string tab)
{ {
ActiveTab = tab; ActiveTab = tab;
if (tab != "map")
{
LayerPanelOpen = false;
}
if (tab == "projects")
{
await RefreshProjectsAsync();
}
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
}
private async Task ToggleLayerPanel()
{
LayerPanelOpen = !LayerPanelOpen;
if (LayerPanelOpen)
{
ActiveTab = "map";
}
await Task.Delay(30); await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize"); await JS.InvokeVoidAsync("lmeMap.resize");
} }
@@ -474,6 +829,12 @@
} }
} }
private async Task ToggleGpsPosition(ChangeEventArgs args)
{
ShowGpsPosition = (bool?)args.Value == true;
await JS.InvokeVoidAsync("lmeMap.setGpsPositionVisible", ShowGpsPosition);
}
private async Task StartGps() private async Task StartGps()
{ {
GpsActive = true; GpsActive = true;
@@ -580,8 +941,39 @@
} }
private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask(); private Task FitImportedLayers() => JS.InvokeVoidAsync("lmeMap.fitImported").AsTask();
private Task ClearImportedLayers() => JS.InvokeVoidAsync("lmeMap.clearImported").AsTask(); private async Task ClearImportedLayers()
private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", ProjectName).AsTask(); {
ShowImportedSheetLayer = false;
await JS.InvokeVoidAsync("lmeMap.clearImported");
}
private Task AddProjectMarker() => JS.InvokeVoidAsync("lmeMap.addProjectMarker", PoiName).AsTask();
private async Task ToggleKmlSheetLayer(ChangeEventArgs args)
{
IncludeKmlSheetLayer = (bool?)args.Value == true;
ShowImportedSheetLayer = IncludeKmlSheetLayer;
await JS.InvokeVoidAsync("lmeMap.setIncludeKmlSheetLayer", IncludeKmlSheetLayer);
await JS.InvokeVoidAsync("lmeMap.setImportedSheetLayerVisible", ShowImportedSheetLayer);
}
private async Task ToggleImportedSheetLayer(ChangeEventArgs args)
{
ShowImportedSheetLayer = (bool?)args.Value == true;
await JS.InvokeVoidAsync("lmeMap.setImportedSheetLayerVisible", ShowImportedSheetLayer);
}
private async Task ToggleImportedMeasurements(ChangeEventArgs args)
{
ShowImportedMeasurements = (bool?)args.Value == true;
await JS.InvokeVoidAsync("lmeMap.setImportedMeasurementsVisible", ShowImportedMeasurements);
}
private async Task ToggleDrawingMeasurements(ChangeEventArgs args)
{
ShowDrawingMeasurements = (bool?)args.Value == true;
await JS.InvokeVoidAsync("lmeMap.setDrawingMeasurementsVisible", ShowDrawingMeasurements);
}
private async Task ToggleDrawTools() private async Task ToggleDrawTools()
{ {
DrawToolsVisible = !DrawToolsVisible; DrawToolsVisible = !DrawToolsVisible;
@@ -594,8 +986,199 @@
} }
private Task ClearDrawings() => JS.InvokeVoidAsync("lmeMap.clearDrawings").AsTask(); private Task ClearDrawings() => JS.InvokeVoidAsync("lmeMap.clearDrawings").AsTask();
private Task SaveWorkspace() => JS.InvokeVoidAsync("lmeMap.saveWorkspace").AsTask();
private Task LoadWorkspace() => JS.InvokeVoidAsync("lmeMap.loadWorkspace").AsTask(); private async Task RefreshProjectsAsync()
{
try
{
Projects = await ProjectStorageService.GetProjectsAsync();
if (Projects.All(project => project.Id != SelectedProjectId))
{
SelectedProjectId = string.Empty;
}
ProjectArchiveMessage = Projects.Count == 0
? "Nessun progetto salvato."
: $"{Projects.Count} {(Projects.Count == 1 ? "progetto salvato" : "progetti salvati")} nell'archivio interno.";
}
catch (Exception error)
{
ProjectArchiveMessage = $"Archivio non disponibile: {error.GetBaseException().Message}";
}
}
private Task SelectProject(ChangeEventArgs args)
{
SelectedProjectId = args.Value?.ToString() ?? string.Empty;
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<string>("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();
if (string.IsNullOrWhiteSpace(name))
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci il nome del progetto.");
return;
}
var existing = Projects.FirstOrDefault(project =>
string.Equals(project.Name, name, StringComparison.CurrentCultureIgnoreCase));
if (existing is not null
&& !await JS.InvokeAsync<bool>("confirm", $"Il progetto '{existing.Name}' esiste gia. Sovrascriverlo?"))
{
return;
}
ProjectBusy = true;
try
{
var snapshot = await JS.InvokeAsync<string>("lmeMap.createProjectSnapshot", IncludeCadastralParcels);
var saved = await ProjectStorageService.SaveAsync(
name,
snapshot,
IncludeCadastralParcels,
existing?.Id);
await RefreshProjectsAsync();
SelectedProjectId = saved.Id;
NewProjectName = saved.Name;
await JS.InvokeVoidAsync(
"lmeMap.showInfo",
IncludeCadastralParcels
? $"Progetto salvato: {saved.Name}, incluse le particelle rosse."
: $"Progetto salvato: {saved.Name}, senza particelle rosse.");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non salvato: {error.GetBaseException().Message}");
}
finally
{
ProjectBusy = false;
}
}
private async Task LoadProject()
{
var project = Projects.FirstOrDefault(item => item.Id == SelectedProjectId);
if (project is null)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Seleziona il progetto da caricare.");
return;
}
ProjectBusy = true;
try
{
var snapshot = await ProjectStorageService.LoadAsync(project.Id);
await ConfigureProjectMapProviderAsync(snapshot);
var displayOptions = await JS.InvokeAsync<ProjectDisplayOptions>("lmeMap.loadProjectSnapshot", snapshot, project.Name);
ApplyProjectDisplayOptions(displayOptions);
NewProjectName = project.Name;
ExportFileName = project.Name;
IncludeCadastralParcels = project.IncludesCadastralParcels;
ActiveTab = "map";
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non caricato: {error.GetBaseException().Message}");
}
finally
{
ProjectBusy = false;
}
}
private void ApplyProjectDisplayOptions(ProjectDisplayOptions options)
{
SelectedBaseLayer = options.BaseLayer;
CadastralOpacity = options.CadastreOpacity;
IncludeKmlSheetLayer = options.IncludeKmlSheetLayer;
ShowImportedSheetLayer = options.ImportedSheetsVisible;
ShowImportedMeasurements = options.ShowImportedMeasurements;
ShowDrawingMeasurements = options.ShowDrawingMeasurements;
DrawToolsVisible = options.DrawToolsVisible;
ShowGpsPosition = options.GpsPositionVisible;
TrailEnabled = options.TrailEnabled;
var activeLayers = (options.CadastreLayers ?? []).ToHashSet(StringComparer.Ordinal);
foreach (var layer in CadastralLayers)
{
layer.Enabled = activeLayers.Contains(layer.Key);
}
}
private async Task DeleteProject()
{
var project = Projects.FirstOrDefault(item => item.Id == SelectedProjectId);
if (project is null)
{
return;
}
if (!await JS.InvokeAsync<bool>("confirm", $"Eliminare definitivamente il progetto '{project.Name}'?"))
{
return;
}
ProjectBusy = true;
try
{
await ProjectStorageService.DeleteAsync(project.Id);
SelectedProjectId = string.Empty;
await RefreshProjectsAsync();
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto eliminato: {project.Name}.");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto non eliminato: {error.GetBaseException().Message}");
}
finally
{
ProjectBusy = false;
}
}
private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask(); private Task CenterOnGps() => JS.InvokeVoidAsync("lmeMap.centerOnGps").AsTask();
private async Task ToggleTrail(ChangeEventArgs args) private async Task ToggleTrail(ChangeEventArgs args)
{ {
@@ -618,6 +1201,18 @@
await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey.Trim()); await JS.InvokeVoidAsync("lmeMap.configureGoogleMaps", GoogleMapsApiKey.Trim());
} }
if (requestedLayer.StartsWith("azure-", StringComparison.Ordinal))
{
if (string.IsNullOrWhiteSpace(AzureMapsSubscriptionKey))
{
await JS.InvokeVoidAsync("lmeMap.showInfo", "Inserisci e salva la chiave della sottoscrizione Azure Maps.");
return;
}
await AzureMapsService.ValidateKeyAsync(AzureMapsSubscriptionKey);
await JS.InvokeVoidAsync("lmeMap.configureAzureMaps", AzureMapsSubscriptionKey.Trim());
}
await JS.InvokeVoidAsync("lmeMap.setBaseLayer", requestedLayer); await JS.InvokeVoidAsync("lmeMap.setBaseLayer", requestedLayer);
SelectedBaseLayer = requestedLayer; SelectedBaseLayer = requestedLayer;
} }
@@ -648,9 +1243,65 @@
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Chiave Google non salvata: {error.Message}"); await JS.InvokeVoidAsync("lmeMap.showInfo", $"Chiave Google non salvata: {error.Message}");
} }
} }
private Task ChangeCadastreOpacity(ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.setCadastreOpacity", args.Value?.ToString()).AsTask();
private Task ToggleCadastreLayer(string layer, ChangeEventArgs args) => JS.InvokeVoidAsync("lmeMap.toggleCadastreLayer", layer, (bool?)args.Value == true).AsTask(); private async Task SaveAzureMapsKey()
private Task ExportCadastralImage() => JS.InvokeVoidAsync("lmeMap.exportCadastreView").AsTask(); {
try
{
if (string.IsNullOrWhiteSpace(AzureMapsSubscriptionKey))
{
SecureStorage.Default.Remove("azure_maps_subscription_key");
await JS.InvokeVoidAsync("lmeMap.clearAzureMaps");
if (SelectedBaseLayer.StartsWith("azure-", StringComparison.Ordinal))
{
SelectedBaseLayer = "satellite";
}
await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Azure Maps rimossa.");
return;
}
AzureMapsSubscriptionKey = AzureMapsSubscriptionKey.Trim();
await AzureMapsService.ValidateKeyAsync(AzureMapsSubscriptionKey);
await SecureStorage.Default.SetAsync("azure_maps_subscription_key", AzureMapsSubscriptionKey);
await JS.InvokeVoidAsync("lmeMap.configureAzureMaps", AzureMapsSubscriptionKey);
await JS.InvokeVoidAsync("lmeMap.showInfo", "Chiave Azure Maps valida e salvata sul dispositivo.");
}
catch (Exception error)
{
await JS.InvokeVoidAsync("lmeMap.showInfo", $"Chiave Azure Maps non salvata: {error.GetBaseException().Message}");
}
}
private async Task ChangeCadastreOpacity(ChangeEventArgs args)
{
var text = args.Value?.ToString();
if (!double.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value)
&& !double.TryParse(text, out value))
{
return;
}
CadastralOpacity = Math.Clamp(value, 0, 1);
await JS.InvokeVoidAsync("lmeMap.setCadastreOpacity", CadastralOpacity);
}
private async Task ToggleCadastreLayer(CadastralLayer layer, ChangeEventArgs args)
{
layer.Enabled = args.Value is true;
await JS.InvokeVoidAsync("lmeMap.toggleCadastreLayer", layer.Key, layer.Enabled);
}
private async Task ExportCadastralImage()
{
ActiveTab = "map";
LayerPanelOpen = false;
await Task.Delay(30);
await JS.InvokeVoidAsync("lmeMap.resize");
await JS.InvokeVoidAsync("lmeMap.exportCadastreView");
}
private Task ClearSelectedParcels() => JS.InvokeVoidAsync("lmeMap.clearSelectedParcels").AsTask();
private Task ClearMapElements() => JS.InvokeVoidAsync("lmeMap.clearMapElements").AsTask();
private Task OpenOfficialGeoportal() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync( private Task OpenOfficialGeoportal() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync(
new Uri("https://geoportale.cartografia.agenziaentrate.gov.it/")); new Uri("https://geoportale.cartografia.agenziaentrate.gov.it/"));
@@ -658,7 +1309,27 @@
private Task OpenOfficialReservedArea() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync( private Task OpenOfficialReservedArea() => Microsoft.Maui.ApplicationModel.Launcher.Default.OpenAsync(
new Uri("https://www.agenziaentrate.gov.it/portale/area-riservata")); new Uri("https://www.agenziaentrate.gov.it/portale/area-riservata"));
private sealed record CadastralLayer(string Key, string Name, bool Enabled); private sealed class CadastralLayer(string key, string name, bool enabled, string detail = "")
{
public string Key { get; } = key;
public string Name { get; } = name;
public bool Enabled { get; set; } = enabled;
public string Detail { get; } = detail;
}
private sealed class ProjectDisplayOptions
{
public string BaseLayer { get; init; } = "satellite";
public double CadastreOpacity { get; init; } = 0.75;
public string[] CadastreLayers { get; init; } = [];
public bool IncludeKmlSheetLayer { get; init; }
public bool ImportedSheetsVisible { get; init; }
public bool ShowImportedMeasurements { get; init; }
public bool ShowDrawingMeasurements { get; init; } = true;
public bool DrawToolsVisible { get; init; }
public bool GpsPositionVisible { get; init; } = true;
public bool TrailEnabled { get; init; }
}
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
+2 -2
View File
@@ -28,8 +28,8 @@
<ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId> <ApplicationId>com.companyname.lmemappecatastaliapp</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>0.2.2</ApplicationDisplayVersion> <ApplicationDisplayVersion>0.2.13</ApplicationDisplayVersion>
<ApplicationVersion>6</ApplicationVersion> <ApplicationVersion>17</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>
+4
View File
@@ -20,6 +20,10 @@ public static class MauiProgram
builder.Services.AddSingleton<CadastralCatalogService>(); builder.Services.AddSingleton<CadastralCatalogService>();
builder.Services.AddSingleton<OfficialCadastreService>(); builder.Services.AddSingleton<OfficialCadastreService>();
builder.Services.AddSingleton<LocationSearchService>(); 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();
+34 -4
View File
@@ -4,26 +4,41 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata pe
## Funzioni gia implementate ## Funzioni gia implementate
- Mappa interattiva Leaflet con sfondi Esri, OpenStreetMap, OpenTopoMap, CARTO Voyager e Google Maps configurabile tramite API key. - 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.
- 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. - Toolbar di disegno completa di icone, modifica, eliminazione singola e pulizia di tutti i disegni/POI.
- POI/progetti salvabili in locale con `localStorage`. - 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. - 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.
- Mappa a tutto schermo con tab Catasto, 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". - Ingrandimento oltre lo zoom satellitare nativo senza tessere "Map data not yet available".
- Tendine concatenate Regione, Comune, Foglio e Particella. - Tendine concatenate Regione, Comune, Foglio e Particella.
- Ricerca di localita e indirizzi eseguita nativamente con fallback Esri e OpenStreetMap, senza richieste CORS dalla WebView. - 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. - 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. - 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 puo' essere mostrata o nascosta dal tab Progetti. - 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. - 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
@@ -38,6 +53,21 @@ Questo passaggio e' implementato nel tab `Catasto`. Regione e Comune sono dispon
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. 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
Apri `LMEMappeCatastaliApp.csproj` con Visual Studio 2022/2026 e seleziona: Apri `LMEMappeCatastaliApp.csproj` con Visual Studio 2022/2026 e seleziona:
+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.13");
_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;
}
}
+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.13");
}
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);
+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);
+304 -1
View File
@@ -124,6 +124,28 @@ input[type="checkbox"] {
color: var(--ink); 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 { .map-search {
position: absolute; position: absolute;
top: 72px; top: 72px;
@@ -150,6 +172,73 @@ input[type="checkbox"] {
transform: translateY(0); 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 { .gps-overlay {
position: absolute; position: absolute;
top: 72px; top: 72px;
@@ -203,6 +292,20 @@ input[type="checkbox"] {
color: var(--ink); 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 { .tool-sheet {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -269,6 +372,29 @@ h2 {
padding: 15px 16px 24px; padding: 15px 16px 24px;
} }
.panel-section {
display: grid;
gap: 12px;
padding: 0 0 16px;
border-bottom: 1px solid var(--line);
}
.panel-section:last-child {
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;
@@ -370,6 +496,69 @@ label {
height: 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;
}
.measurement-mode-grid button {
min-width: 0;
padding: 0 8px;
}
.lme-measure-label {
width: 0 !important;
height: 0 !important;
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: 12px; left: 12px;
@@ -384,6 +573,100 @@ label {
pointer-events: none; pointer-events: none;
} }
.wms-preview[hidden] {
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 { .bottom-tabs {
position: absolute; position: absolute;
right: 0; right: 0;
@@ -391,7 +674,7 @@ label {
left: 0; left: 0;
z-index: 1200; z-index: 1200;
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
height: var(--nav-height); height: var(--nav-height);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid #bcc7cd; border-top: 1px solid #bcc7cd;
@@ -462,6 +745,13 @@ footer {
right: 8px; right: 8px;
} }
.map-layer-panel {
top: 68px;
right: 8px;
width: min(320px, calc(100% - 16px));
max-height: calc(100% - 144px);
}
.tool-sheet { .tool-sheet {
top: 10%; top: 10%;
bottom: var(--nav-height); bottom: var(--nav-height);
@@ -485,6 +775,15 @@ footer {
top: 136px; top: 136px;
} }
.leaflet-top.leaflet-right {
top: 122px;
right: 0;
}
.map-stage.search-active .leaflet-top.leaflet-right {
top: 190px;
}
.gps-main, .gps-main,
.gps-center { .gps-center {
padding: 0 8px; padding: 0 8px;
@@ -504,6 +803,10 @@ footer {
.bottom-tabs button { .bottom-tabs button {
font-size: 11px; font-size: 11px;
} }
.wms-preview {
padding: 8px;
}
} }
@media (max-width: 380px) { @media (max-width: 380px) {
+1677 -78
View File
File diff suppressed because it is too large. Load diff