Add stable cascading cadastral search and fullscreen map UI
This commit is contained in:
1 parent
ed8120a7d0
commit
8997f1b728
8 files changed
+8804
-438
No files matched your search
@@ -0,0 +1,339 @@
|
||||
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, cancellationToken: cancellationToken);
|
||||
await group.ReadAsync<long>(latitudeField, latitudes, cancellationToken: cancellationToken);
|
||||
|
||||
foreach (var index in matchingIndexes)
|
||||
{
|
||||
var number = parcelValues[index];
|
||||
if (string.IsNullOrWhiteSpace(number) || parcels.ContainsKey(number))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parcels[number] = new ParcelOption(
|
||||
number,
|
||||
latitudes[index] / 1_000_000d,
|
||||
longitudes[index] / 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user