feat(parcel-sync): background sync + download from DB
- New POST /api/eterra/sync-background: fire-and-forget server-side processing Starts sync + optional enrichment in background, returns 202 immediately. Progress tracked via existing /api/eterra/progress polling. Work continues in Node.js event loop even if browser is closed. Progress persists 1 hour for background jobs (vs 60s for normal). - Enhanced POST /api/eterra/export-local: base/magic mode support mode=base: ZIP with terenuri.gpkg + cladiri.gpkg from local DB mode=magic: adds terenuri_magic.gpkg (enrichment merged, includes no-geom), terenuri_complet.csv, raport_calitate.txt, export_report.json All from PostgreSQL — zero eTerra API calls, instant download. - UI: background sync section in Export tab 'Sync fundal Baza/Magic' buttons: start background processing 'Descarc─â din DB Baza/Magic' buttons: instant download from local DB Background job progress card with indigo theme (distinct from export) localStorage job recovery: resume polling after page refresh 'Descarc─â din DB' button shown on completion
This commit is contained in:
@@ -1,16 +1,23 @@
|
||||
/**
|
||||
* POST /api/eterra/export-local
|
||||
*
|
||||
* Export features from local PostgreSQL database as GPKG.
|
||||
* Export features from local PostgreSQL database as GPKG / ZIP.
|
||||
* No eTerra connection needed — serves from previously synced data.
|
||||
*
|
||||
* Body: { siruta, layerIds?: string[], allLayers?: boolean }
|
||||
* Modes:
|
||||
* - base: ZIP with terenuri.gpkg + cladiri.gpkg
|
||||
* - magic: ZIP with terenuri.gpkg + cladiri.gpkg + terenuri_magic.gpkg
|
||||
* + terenuri_complet.csv + raport_calitate.txt + export_report.json
|
||||
* - layer: single layer GPKG (legacy, layerIds/allLayers)
|
||||
*
|
||||
* Body: { siruta, mode?: "base"|"magic", layerIds?: string[], allLayers?: boolean }
|
||||
*/
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
|
||||
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
|
||||
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
|
||||
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
|
||||
import type { FeatureEnrichment } from "@/modules/parcel-sync/services/enrich-service";
|
||||
import JSZip from "jszip";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -18,28 +25,35 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
type Body = {
|
||||
siruta?: string;
|
||||
mode?: "base" | "magic";
|
||||
layerIds?: string[];
|
||||
allLayers?: boolean;
|
||||
};
|
||||
|
||||
const csvEscape = (val: unknown) => {
|
||||
const s = String(val ?? "").replace(/"/g, '""');
|
||||
return `"${s}"`;
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as Body;
|
||||
const siruta = String(body.siruta ?? "").trim();
|
||||
|
||||
if (!siruta || !/^\d+$/.test(siruta)) {
|
||||
return new Response(JSON.stringify({ error: "SIRUTA invalid" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return Response.json({ error: "SIRUTA invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Determine which layers to export
|
||||
// ── New: "base" or "magic" mode → full ZIP from DB ──
|
||||
if (body.mode === "base" || body.mode === "magic") {
|
||||
return buildFullZip(siruta, body.mode);
|
||||
}
|
||||
|
||||
// ── Legacy: single/multi layer GPKG ──
|
||||
let layerIds: string[];
|
||||
if (body.layerIds?.length) {
|
||||
layerIds = body.layerIds;
|
||||
} else if (body.allLayers) {
|
||||
// Find all layers that have data for this siruta
|
||||
const layerGroups = await prisma.gisFeature.groupBy({
|
||||
by: ["layerId"],
|
||||
where: { siruta },
|
||||
@@ -49,22 +63,19 @@ export async function POST(req: Request) {
|
||||
.filter((g) => g._count.id > 0)
|
||||
.map((g) => g.layerId);
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Specifică layerIds sau allLayers=true" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
return Response.json(
|
||||
{ error: "Specifică mode, layerIds, sau allLayers=true" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (layerIds.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Niciun layer sincronizat în baza de date pentru acest UAT",
|
||||
}),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
return Response.json(
|
||||
{ error: "Niciun layer sincronizat în baza de date pentru acest UAT" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// If single layer, return GPKG directly. If multiple, ZIP them.
|
||||
if (layerIds.length === 1) {
|
||||
const layerId = layerIds[0]!;
|
||||
const gpkg = await buildLayerGpkg(siruta, layerId);
|
||||
@@ -78,33 +89,388 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
// Multiple layers — ZIP
|
||||
const zip = new JSZip();
|
||||
for (const layerId of layerIds) {
|
||||
const gpkg = await buildLayerGpkg(siruta, layerId);
|
||||
const layer = findLayerById(layerId);
|
||||
const name = layer?.name ?? layerId;
|
||||
zip.file(`${name}.gpkg`, gpkg);
|
||||
zip.file(`${layer?.name ?? layerId}.gpkg`, gpkg);
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({ type: "uint8array" });
|
||||
const filename = `eterra_local_${siruta}_${layerIds.length}layers.zip`;
|
||||
return new Response(Buffer.from(zipBuffer), {
|
||||
const zipBuf = await zip.generateAsync({ type: "nodebuffer" });
|
||||
return new Response(new Uint8Array(zipBuf), {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Disposition": `attachment; filename="eterra_local_${siruta}.zip"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a GPKG from local DB features for one layer+siruta */
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
/* Full ZIP export from DB (base / magic) */
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
|
||||
async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
||||
const srsWkt = getEpsg3844Wkt();
|
||||
|
||||
// Load from DB
|
||||
const dbTerenuri = await prisma.gisFeature.findMany({
|
||||
where: { layerId: "TERENURI_ACTIVE", siruta },
|
||||
select: {
|
||||
attributes: true,
|
||||
geometry: true,
|
||||
enrichment: true,
|
||||
geometrySource: true,
|
||||
},
|
||||
});
|
||||
const dbCladiri = await prisma.gisFeature.findMany({
|
||||
where: { layerId: "CLADIRI_ACTIVE", siruta },
|
||||
select: { attributes: true, geometry: true },
|
||||
});
|
||||
|
||||
if (dbTerenuri.length === 0 && dbCladiri.length === 0) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
"Baza de date este goală pentru acest UAT. Rulează sincronizarea mai întâi.",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const toGeoFeatures = (
|
||||
records: { attributes: unknown; geometry: unknown }[],
|
||||
): GeoJsonFeature[] =>
|
||||
records
|
||||
.filter((r) => r.geometry != null)
|
||||
.map((r) => ({
|
||||
type: "Feature" as const,
|
||||
geometry: r.geometry as GeoJsonFeature["geometry"],
|
||||
properties: r.attributes as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
const terenuriGeo = toGeoFeatures(dbTerenuri);
|
||||
const cladiriGeo = toGeoFeatures(dbCladiri);
|
||||
|
||||
const terenuriFields =
|
||||
terenuriGeo.length > 0 ? Object.keys(terenuriGeo[0]!.properties) : [];
|
||||
const cladiriFields =
|
||||
cladiriGeo.length > 0 ? Object.keys(cladiriGeo[0]!.properties) : [];
|
||||
|
||||
const terenuriGpkg = await buildGpkg({
|
||||
srsId: 3844,
|
||||
srsWkt,
|
||||
layers: [
|
||||
{
|
||||
name: "TERENURI_ACTIVE",
|
||||
fields: terenuriFields,
|
||||
features: terenuriGeo,
|
||||
},
|
||||
],
|
||||
});
|
||||
const cladiriGpkg = await buildGpkg({
|
||||
srsId: 3844,
|
||||
srsWkt,
|
||||
layers: [
|
||||
{ name: "CLADIRI_ACTIVE", fields: cladiriFields, features: cladiriGeo },
|
||||
],
|
||||
});
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||
|
||||
if (mode === "magic") {
|
||||
// ── Magic: enrichment-merged GPKG + CSV + quality report ──
|
||||
const headers = [
|
||||
"OBJECTID",
|
||||
"IMMOVABLE_ID",
|
||||
"APPLICATION_ID",
|
||||
"NATIONAL_CADASTRAL_REFERENCE",
|
||||
"NR_CAD",
|
||||
"AREA_VALUE",
|
||||
"NR_CF",
|
||||
"NR_CF_VECHI",
|
||||
"NR_TOPO",
|
||||
"ADRESA",
|
||||
"PROPRIETARI",
|
||||
"PROPRIETARI_VECHI",
|
||||
"SUPRAFATA_2D",
|
||||
"SUPRAFATA_R",
|
||||
"SOLICITANT",
|
||||
"INTRAVILAN",
|
||||
"CATEGORIE_FOLOSINTA",
|
||||
"HAS_BUILDING",
|
||||
"BUILD_LEGAL",
|
||||
"HAS_GEOMETRY",
|
||||
];
|
||||
const csvRows: string[] = [headers.map(csvEscape).join(",")];
|
||||
|
||||
const magicFeatures: GeoJsonFeature[] = [];
|
||||
const magicFields = Array.from(
|
||||
new Set([
|
||||
...terenuriFields,
|
||||
"NR_CAD",
|
||||
"NR_CF",
|
||||
"NR_CF_VECHI",
|
||||
"NR_TOPO",
|
||||
"ADRESA",
|
||||
"PROPRIETARI",
|
||||
"PROPRIETARI_VECHI",
|
||||
"SUPRAFATA_2D",
|
||||
"SUPRAFATA_R",
|
||||
"SOLICITANT",
|
||||
"INTRAVILAN",
|
||||
"CATEGORIE_FOLOSINTA",
|
||||
"HAS_BUILDING",
|
||||
"BUILD_LEGAL",
|
||||
]),
|
||||
);
|
||||
|
||||
let hasBuildingCount = 0;
|
||||
let legalBuildingCount = 0;
|
||||
|
||||
for (const record of dbTerenuri) {
|
||||
const attrs = record.attributes as Record<string, unknown>;
|
||||
const enrichment =
|
||||
(record.enrichment as FeatureEnrichment | null) ??
|
||||
({} as Partial<FeatureEnrichment>);
|
||||
const geom = record.geometry as GeoJsonFeature["geometry"];
|
||||
const geomSource = (
|
||||
record as unknown as { geometrySource: string | null }
|
||||
).geometrySource;
|
||||
const hasGeometry = geom != null && geomSource !== "NO_GEOMETRY" ? 1 : 0;
|
||||
|
||||
const e = enrichment as Partial<FeatureEnrichment>;
|
||||
if (Number(e.HAS_BUILDING ?? 0)) hasBuildingCount += 1;
|
||||
if (Number(e.BUILD_LEGAL ?? 0)) legalBuildingCount += 1;
|
||||
|
||||
csvRows.push(
|
||||
[
|
||||
attrs.OBJECTID ?? "",
|
||||
attrs.IMMOVABLE_ID ?? "",
|
||||
attrs.APPLICATION_ID ?? "",
|
||||
attrs.NATIONAL_CADASTRAL_REFERENCE ?? "",
|
||||
e.NR_CAD ?? "",
|
||||
attrs.AREA_VALUE ?? "",
|
||||
e.NR_CF ?? "",
|
||||
e.NR_CF_VECHI ?? "",
|
||||
e.NR_TOPO ?? "",
|
||||
e.ADRESA ?? "",
|
||||
e.PROPRIETARI ?? "",
|
||||
e.PROPRIETARI_VECHI ?? "",
|
||||
e.SUPRAFATA_2D ?? "",
|
||||
e.SUPRAFATA_R ?? "",
|
||||
e.SOLICITANT ?? "",
|
||||
e.INTRAVILAN ?? "",
|
||||
e.CATEGORIE_FOLOSINTA ?? "",
|
||||
e.HAS_BUILDING ?? 0,
|
||||
e.BUILD_LEGAL ?? 0,
|
||||
hasGeometry,
|
||||
]
|
||||
.map(csvEscape)
|
||||
.join(","),
|
||||
);
|
||||
|
||||
magicFeatures.push({
|
||||
type: "Feature",
|
||||
geometry: geom,
|
||||
properties: { ...attrs, ...e, HAS_GEOMETRY: hasGeometry },
|
||||
});
|
||||
}
|
||||
|
||||
const magicGpkg = await buildGpkg({
|
||||
srsId: 3844,
|
||||
srsWkt,
|
||||
layers: [
|
||||
{
|
||||
name: "TERENURI_MAGIC",
|
||||
fields: [...magicFields, "HAS_GEOMETRY"],
|
||||
features: magicFeatures,
|
||||
includeNullGeometry: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||
zip.file("terenuri_complet.csv", csvRows.join("\n"));
|
||||
|
||||
// ── Quality analysis ──
|
||||
const withGeomRecords = dbTerenuri.filter(
|
||||
(r) =>
|
||||
(r as unknown as { geometrySource: string | null }).geometrySource !==
|
||||
"NO_GEOMETRY",
|
||||
);
|
||||
const noGeomRecords = dbTerenuri.filter(
|
||||
(r) =>
|
||||
(r as unknown as { geometrySource: string | null }).geometrySource ===
|
||||
"NO_GEOMETRY",
|
||||
);
|
||||
|
||||
const analyzeRecords = (records: typeof dbTerenuri) => {
|
||||
let enriched = 0,
|
||||
withOwners = 0,
|
||||
withOldOwners = 0,
|
||||
withCF = 0;
|
||||
let withAddress = 0,
|
||||
withArea = 0,
|
||||
withCategory = 0,
|
||||
withBuilding = 0;
|
||||
let complete = 0,
|
||||
partial = 0,
|
||||
empty = 0;
|
||||
for (const r of records) {
|
||||
const en = r.enrichment as Record<string, unknown> | null;
|
||||
if (!en) {
|
||||
empty++;
|
||||
continue;
|
||||
}
|
||||
enriched++;
|
||||
const ho = !!en.PROPRIETARI && en.PROPRIETARI !== "-";
|
||||
const hoo =
|
||||
!!en.PROPRIETARI_VECHI && String(en.PROPRIETARI_VECHI).trim() !== "";
|
||||
const hc = !!en.NR_CF && en.NR_CF !== "-";
|
||||
const ha = !!en.ADRESA && en.ADRESA !== "-";
|
||||
const harea =
|
||||
en.SUPRAFATA_2D != null &&
|
||||
en.SUPRAFATA_2D !== "" &&
|
||||
Number(en.SUPRAFATA_2D) > 0;
|
||||
const hcat = !!en.CATEGORIE_FOLOSINTA && en.CATEGORIE_FOLOSINTA !== "-";
|
||||
const hb = Number(en.HAS_BUILDING ?? 0) === 1;
|
||||
if (ho) withOwners++;
|
||||
if (hoo) withOldOwners++;
|
||||
if (hc) withCF++;
|
||||
if (ha) withAddress++;
|
||||
if (harea) withArea++;
|
||||
if (hcat) withCategory++;
|
||||
if (hb) withBuilding++;
|
||||
if (ho && hc && harea) complete++;
|
||||
else if (ho || hc || ha || harea || hcat) partial++;
|
||||
else empty++;
|
||||
}
|
||||
return {
|
||||
total: records.length,
|
||||
enriched,
|
||||
withOwners,
|
||||
withOldOwners,
|
||||
withCF,
|
||||
withAddress,
|
||||
withArea,
|
||||
withCategory,
|
||||
withBuilding,
|
||||
complete,
|
||||
partial,
|
||||
empty,
|
||||
};
|
||||
};
|
||||
|
||||
const qAll = analyzeRecords(dbTerenuri);
|
||||
const qGeo = analyzeRecords(withGeomRecords);
|
||||
const qNoGeo = analyzeRecords(noGeomRecords);
|
||||
|
||||
// Quality report
|
||||
const pct = (n: number, t: number) =>
|
||||
t > 0 ? `${((n / t) * 100).toFixed(1)}%` : "0%";
|
||||
const fmt = (n: number) => n.toLocaleString("ro-RO");
|
||||
const lines: string[] = [
|
||||
`══════════════════════════════════════════════════════════`,
|
||||
` RAPORT CALITATE DATE — UAT SIRUTA ${siruta}`,
|
||||
` Generat: ${new Date().toISOString().replace("T", " ").slice(0, 19)}`,
|
||||
` Sursă: bază de date locală (fără conexiune eTerra)`,
|
||||
`══════════════════════════════════════════════════════════`,
|
||||
``,
|
||||
`STARE BAZĂ DE DATE`,
|
||||
`─────────────────────────────────────────────────────────`,
|
||||
` Total parcele: ${fmt(dbTerenuri.length)}`,
|
||||
` • Cu geometrie (contur GIS): ${fmt(withGeomRecords.length)}`,
|
||||
` • Fără geometrie (doar date): ${fmt(noGeomRecords.length)}`,
|
||||
` Clădiri: ${fmt(cladiriGeo.length)}`,
|
||||
``,
|
||||
`CALITATE ÎMBOGĂȚIRE — TOATE PARCELELE (${fmt(qAll.total)})`,
|
||||
`─────────────────────────────────────────────────────────`,
|
||||
` Îmbogățite: ${fmt(qAll.enriched)} (${pct(qAll.enriched, qAll.total)})`,
|
||||
` Cu proprietari: ${fmt(qAll.withOwners)} (${pct(qAll.withOwners, qAll.total)})`,
|
||||
` Cu prop. vechi: ${fmt(qAll.withOldOwners)} (${pct(qAll.withOldOwners, qAll.total)})`,
|
||||
` Cu nr. CF: ${fmt(qAll.withCF)} (${pct(qAll.withCF, qAll.total)})`,
|
||||
` Cu adresă: ${fmt(qAll.withAddress)} (${pct(qAll.withAddress, qAll.total)})`,
|
||||
` Cu suprafață: ${fmt(qAll.withArea)} (${pct(qAll.withArea, qAll.total)})`,
|
||||
` Cu categorie fol.: ${fmt(qAll.withCategory)} (${pct(qAll.withCategory, qAll.total)})`,
|
||||
` Cu clădire: ${fmt(qAll.withBuilding)} (${pct(qAll.withBuilding, qAll.total)})`,
|
||||
` ────────────────`,
|
||||
` Complete (prop+CF+sup): ${fmt(qAll.complete)} (${pct(qAll.complete, qAll.total)})`,
|
||||
` Parțiale: ${fmt(qAll.partial)} (${pct(qAll.partial, qAll.total)})`,
|
||||
` Goale (fără date): ${fmt(qAll.empty)} (${pct(qAll.empty, qAll.total)})`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (withGeomRecords.length > 0) {
|
||||
lines.push(
|
||||
`PARCELE CU GEOMETRIE (${fmt(qGeo.total)})`,
|
||||
`─────────────────────────────────────────────────────────`,
|
||||
` Complete: ${fmt(qGeo.complete)} Parțiale: ${fmt(qGeo.partial)} Goale: ${fmt(qGeo.empty)}`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
if (noGeomRecords.length > 0) {
|
||||
lines.push(
|
||||
`PARCELE FĂRĂ GEOMETRIE (${fmt(qNoGeo.total)})`,
|
||||
`─────────────────────────────────────────────────────────`,
|
||||
` Complete: ${fmt(qNoGeo.complete)} Parțiale: ${fmt(qNoGeo.partial)} Goale: ${fmt(qNoGeo.empty)}`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`NOTE`,
|
||||
`─────────────────────────────────────────────────────────`,
|
||||
` • Export din baza de date locală — fără conexiune eTerra`,
|
||||
` • "Complete" = are proprietari + nr. CF + suprafață`,
|
||||
`══════════════════════════════════════════════════════════`,
|
||||
);
|
||||
zip.file("raport_calitate.txt", lines.join("\n"));
|
||||
|
||||
const report = {
|
||||
siruta,
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: "local-db (descărcare din DB)",
|
||||
terenuri: {
|
||||
total: dbTerenuri.length,
|
||||
withGeom: withGeomRecords.length,
|
||||
noGeom: noGeomRecords.length,
|
||||
},
|
||||
cladiri: { count: cladiriGeo.length },
|
||||
magic: {
|
||||
csvRows: csvRows.length - 1,
|
||||
hasBuildingCount,
|
||||
legalBuildingCount,
|
||||
},
|
||||
quality: { all: qAll, withGeom: qGeo, noGeom: qNoGeo },
|
||||
};
|
||||
zip.file("export_report.json", JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
const zipBuf = await zip.generateAsync({
|
||||
type: "nodebuffer",
|
||||
compression: "STORE",
|
||||
});
|
||||
const filename =
|
||||
mode === "magic"
|
||||
? `eterra_uat_${siruta}_magic_local.zip`
|
||||
: `eterra_uat_${siruta}_local.zip`;
|
||||
|
||||
return new Response(new Uint8Array(zipBuf), {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
/* Layer GPKG builder */
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
|
||||
async function buildLayerGpkg(siruta: string, layerId: string) {
|
||||
const features = await prisma.gisFeature.findMany({
|
||||
where: { layerId, siruta },
|
||||
@@ -115,7 +481,6 @@ async function buildLayerGpkg(siruta: string, layerId: string) {
|
||||
throw new Error(`Niciun feature local pentru ${layerId} / ${siruta}`);
|
||||
}
|
||||
|
||||
// Reconstruct GeoJSON features from DB records
|
||||
const geoFeatures: GeoJsonFeature[] = features
|
||||
.filter((f) => f.geometry != null)
|
||||
.map((f) => ({
|
||||
@@ -124,9 +489,7 @@ async function buildLayerGpkg(siruta: string, layerId: string) {
|
||||
properties: f.attributes as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
// Collect field names from first feature
|
||||
const fields = Object.keys(geoFeatures[0]?.properties ?? {});
|
||||
|
||||
const layer = findLayerById(layerId);
|
||||
const name = layer?.name ?? layerId;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user