feat: add parcel-sync module (eTerra ANCPI integration with PostGIS)
- 31 eTerra layer catalog (terenuri, cladiri, documentatii, administrativ) - Incremental sync engine (OBJECTID comparison, only downloads new features) - PostGIS-ready Prisma schema (GisFeature, GisSyncRun, GisUat models) - 7 API routes (/api/eterra/login, count, sync, features, layers/summary, progress, sync-status) - Full UI with 3 tabs (Sincronizare, Parcele, Istoric) - Env var auth (ETERRA_USERNAME / ETERRA_PASSWORD) - Real-time sync progress tracking with polling
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* ESRI → GeoJSON conversion for eTerra features.
|
||||
*/
|
||||
|
||||
import type { EsriFeature } from "./eterra-client";
|
||||
|
||||
export type GeoJsonPolygon = { type: "Polygon"; coordinates: number[][][] };
|
||||
export type GeoJsonMultiPolygon = {
|
||||
type: "MultiPolygon";
|
||||
coordinates: number[][][][];
|
||||
};
|
||||
|
||||
export type GeoJsonFeature = {
|
||||
type: "Feature";
|
||||
properties: Record<string, unknown>;
|
||||
geometry: GeoJsonPolygon | GeoJsonMultiPolygon;
|
||||
};
|
||||
|
||||
export type GeoJsonFeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: GeoJsonFeature[];
|
||||
};
|
||||
|
||||
const ringArea = (ring: number[][]) => {
|
||||
let area = 0;
|
||||
for (let i = 0; i < ring.length - 1; i++) {
|
||||
const curr = ring[i]!;
|
||||
const next = ring[i + 1]!;
|
||||
area += curr[0]! * next[1]! - next[0]! * curr[1]!;
|
||||
}
|
||||
return area / 2;
|
||||
};
|
||||
|
||||
const isClockwise = (ring: number[][]) => ringArea(ring) < 0;
|
||||
|
||||
const closeRing = (ring: number[][]) => {
|
||||
if (ring.length === 0) return ring;
|
||||
const first = ring[0]!;
|
||||
const last = ring[ring.length - 1]!;
|
||||
return first[0] !== last[0] || first[1] !== last[1]
|
||||
? [...ring, [first[0]!, first[1]!]]
|
||||
: ring;
|
||||
};
|
||||
|
||||
const ringsToPolygons = (rings: number[][][]) => {
|
||||
const polygons: number[][][][] = [];
|
||||
let current: number[][][] | null = null;
|
||||
for (const ring of rings) {
|
||||
const closed = closeRing(ring);
|
||||
if (closed.length < 4) continue;
|
||||
if (isClockwise(closed) || !current) {
|
||||
if (current) polygons.push(current);
|
||||
current = [closed];
|
||||
} else {
|
||||
current.push(closed);
|
||||
}
|
||||
}
|
||||
if (current) polygons.push(current);
|
||||
return polygons;
|
||||
};
|
||||
|
||||
export const esriToGeojson = (
|
||||
features: EsriFeature[],
|
||||
): GeoJsonFeatureCollection => {
|
||||
const geoFeatures: GeoJsonFeature[] = [];
|
||||
for (const feature of features) {
|
||||
const rings = feature.geometry?.rings;
|
||||
if (!rings?.length) continue;
|
||||
const polygons = ringsToPolygons(rings);
|
||||
if (!polygons.length) continue;
|
||||
const geometry: GeoJsonPolygon | GeoJsonMultiPolygon =
|
||||
polygons.length === 1
|
||||
? { type: "Polygon", coordinates: polygons[0]! }
|
||||
: { type: "MultiPolygon", coordinates: polygons };
|
||||
geoFeatures.push({
|
||||
type: "Feature",
|
||||
properties: feature.attributes ?? {},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
return { type: "FeatureCollection", features: geoFeatures };
|
||||
};
|
||||
Reference in New Issue
Block a user