UNPKG

geo-toolkits

Version:

A comprehensive TypeScript library for geospatial operations including geofencing, polygon containment, area calculations, and coordinate processing. Supports GeoJSON, KML, and KMZ file formats with both local file and remote URL loading capabilities.

606 lines (605 loc) 24.4 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.UniversalParser = void 0; const jszip_1 = __importDefault(require("jszip")); const fast_xml_parser_1 = require("fast-xml-parser"); const errors_1 = require("../utils/errors"); class UniversalParser { /** * Parse any supported format and convert to uniform GeoJSON structure */ static parseToGeoJSON(content, format) { return __awaiter(this, void 0, void 0, function* () { switch (format) { case "geojson": return this.parseGeoJSON(content); case "kml": return this.parseKMLToGeoJSON(content); case "kmz": return this.parseKMZToGeoJSON(content); default: throw new errors_1.ParseError(`Unsupported format: ${format}`, "UNSUPPORTED_FORMAT"); } }); } /** * Parse GeoJSON and normalize to internal format */ static parseGeoJSON(content) { return __awaiter(this, void 0, void 0, function* () { try { const jsonString = content.toString("utf8"); const geoJson = JSON.parse(jsonString); this.validateGeoJSON(geoJson); const features = []; const stats = { polygonCount: 0, multiPolygonCount: 0, skippedFeatures: 0, }; if (geoJson.features && Array.isArray(geoJson.features)) { geoJson.features.forEach((rawFeature, index) => { try { const feature = this.convertToSpatialFeature(rawFeature, index); if (feature) { features.push(feature); if (feature.type === "Polygon") { stats.polygonCount++; } else if (feature.type === "MultiPolygon") { stats.multiPolygonCount++; } } else { stats.skippedFeatures++; } } catch (error) { stats.skippedFeatures++; } }); } return { features, stats }; } catch (error) { throw new errors_1.ParseError(`Failed to parse GeoJSON: ${error.message}`, "INVALID_GEOJSON"); } }); } /** * Parse KML and convert to GeoJSON format */ static parseKMLToGeoJSON(content) { return __awaiter(this, void 0, void 0, function* () { try { const xmlString = content.toString("utf8"); const parser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", textNodeName: "#text", }); const kmlData = parser.parse(xmlString); // Extract placemarks from KML structure const placemarks = this.extractKMLPlacemarks(kmlData); const features = []; const stats = { polygonCount: 0, multiPolygonCount: 0, skippedFeatures: 0, }; placemarks.forEach((placemark, index) => { try { const feature = this.convertKMLPlacemarkToSpatialFeature(placemark, index); if (feature) { features.push(feature); if (feature.type === "Polygon") { stats.polygonCount++; } else if (feature.type === "MultiPolygon") { stats.multiPolygonCount++; } } else { stats.skippedFeatures++; } } catch (error) { stats.skippedFeatures++; } }); return { features, stats }; } catch (error) { throw new errors_1.ParseError(`Failed to parse KML: ${error.message}`, "INVALID_KML"); } }); } /** * Parse KMZ (extract KML and convert) */ static parseKMZToGeoJSON(content) { return __awaiter(this, void 0, void 0, function* () { try { const zip = yield jszip_1.default.loadAsync(content); // Find KML file in the archive const kmlFile = Object.keys(zip.files).find((filename) => filename.toLowerCase().endsWith(".kml")); if (!kmlFile) { throw new errors_1.ParseError("No KML file found in KMZ archive", "NO_KML_IN_KMZ"); } console.log(`📄 Extracting KML file: ${kmlFile}`); // Extract KML content const kmlContent = yield zip.files[kmlFile].async("nodebuffer"); // Parse extracted KML return this.parseKMLToGeoJSON(kmlContent); } catch (error) { if (error instanceof errors_1.ParseError) { throw error; } throw new errors_1.ParseError(`Failed to parse KMZ: ${error.message}`, "INVALID_KMZ"); } }); } // Helper methods static validateGeoJSON(geoJson) { if (!geoJson || typeof geoJson !== "object") { throw new errors_1.ParseError("Invalid GeoJSON: not a valid object"); } if (geoJson.type !== "FeatureCollection") { throw new errors_1.ParseError("Invalid GeoJSON: expected FeatureCollection"); } if (!Array.isArray(geoJson.features)) { throw new errors_1.ParseError("Invalid GeoJSON: features must be an array"); } } static convertToSpatialFeature(rawFeature, index) { if (!rawFeature || rawFeature.type !== "Feature") { throw new errors_1.ParseError(`Feature at index ${index} is not a valid Feature object`); } if (!rawFeature.geometry || !rawFeature.geometry.type) { throw new errors_1.ParseError(`Feature at index ${index} has invalid geometry`); } const geometryType = rawFeature.geometry.type; // Only accept Polygon and MultiPolygon if (geometryType !== "Polygon" && geometryType !== "MultiPolygon") { return null; // Skip silently } const coordinates = rawFeature.geometry.coordinates; if (!coordinates || !Array.isArray(coordinates)) { throw new errors_1.ParseError(`Feature at index ${index} has invalid coordinates`); } // Calculate bounding box const boundingBox = this.calculateBoundingBox(coordinates, geometryType); // Create spatial feature const spatialFeature = { type: geometryType, coordinates, properties: rawFeature.properties || {}, boundingBox, featureIndex: index, }; // Add polygon bounding boxes for MultiPolygon if (geometryType === "MultiPolygon") { spatialFeature.polygonBoundingBoxes = coordinates.map((polygonCoords) => this.calculateBoundingBox(polygonCoords, "Polygon")); } return spatialFeature; } static extractKMLPlacemarks(kmlData) { const placemarks = []; // Navigate KML structure to find placemarks const traverse = (obj) => { if (obj && typeof obj === "object") { if (obj.Placemark) { if (Array.isArray(obj.Placemark)) { placemarks.push(...obj.Placemark); } else { placemarks.push(obj.Placemark); } } // Recurse through nested objects Object.values(obj).forEach((value) => { if (value && typeof value === "object") { traverse(value); } }); } }; traverse(kmlData); return placemarks; } /** * Convert KML Placemark to SpatialFeature */ static convertKMLPlacemarkToSpatialFeature(placemark, index) { try { // Extract properties from placemark const properties = this.extractKMLProperties(placemark); // Handle different geometry types let geoJsonGeometry = null; if (placemark.Polygon) { // Single Polygon geoJsonGeometry = this.convertKMLPolygonToGeoJSON(placemark.Polygon); if (!geoJsonGeometry) return null; } else if (placemark.MultiGeometry) { // MultiGeometry - could contain multiple polygons geoJsonGeometry = this.convertKMLMultiGeometryToGeoJSON(placemark.MultiGeometry); if (!geoJsonGeometry) return null; } else { // No supported geometry found return null; } // Calculate bounding box const boundingBox = this.calculateBoundingBox(geoJsonGeometry.coordinates, geoJsonGeometry.type); // Create spatial feature const spatialFeature = { type: geoJsonGeometry.type, coordinates: geoJsonGeometry.coordinates, properties, boundingBox, featureIndex: index, }; // Add polygon bounding boxes for MultiPolygon if (geoJsonGeometry.type === "MultiPolygon") { spatialFeature.polygonBoundingBoxes = geoJsonGeometry.coordinates.map((polygonCoords) => this.calculateBoundingBox(polygonCoords, "Polygon")); } return spatialFeature; } catch (error) { console.warn(`⚠️ Failed to convert KML placemark at index ${index}: ${error.message}`); return null; } } /** * Extract properties from KML placemark */ static extractKMLProperties(placemark) { const properties = {}; // Basic placemark properties if (placemark.name) { properties.name = this.extractTextContent(placemark.name); } if (placemark.description) { properties.description = this.extractTextContent(placemark.description); } // Extended data if (placemark.ExtendedData) { const extendedData = this.extractKMLExtendedData(placemark.ExtendedData); Object.assign(properties, extendedData); } // Style information if (placemark.styleUrl) { properties.styleUrl = this.extractTextContent(placemark.styleUrl); } // Address information if (placemark.address) { properties.address = this.extractTextContent(placemark.address); } if (placemark.phoneNumber) { properties.phoneNumber = this.extractTextContent(placemark.phoneNumber); } // Additional KML-specific properties if (placemark.visibility !== undefined) { properties.visibility = placemark.visibility; } if (placemark.open !== undefined) { properties.open = placemark.open; } return properties; } /** * Convert KML Polygon to GeoJSON Polygon */ static convertKMLPolygonToGeoJSON(kmlPolygon) { try { const coordinates = []; // Extract outer boundary (exterior ring) if (kmlPolygon.outerBoundaryIs || kmlPolygon.OuterBoundaryIs) { const outerBoundary = kmlPolygon.outerBoundaryIs || kmlPolygon.OuterBoundaryIs; const outerRing = this.extractKMLLinearRing(outerBoundary); if (outerRing) { coordinates.push(outerRing); } else { return null; // Invalid polygon without exterior ring } } else { return null; // No outer boundary found } // Extract inner boundaries (holes) const innerBoundaries = kmlPolygon.innerBoundaryIs || kmlPolygon.InnerBoundaryIs; if (innerBoundaries) { const innerBoundaryArray = Array.isArray(innerBoundaries) ? innerBoundaries : [innerBoundaries]; for (const innerBoundary of innerBoundaryArray) { const innerRing = this.extractKMLLinearRing(innerBoundary); if (innerRing) { coordinates.push(innerRing); } } } return { type: "Polygon", coordinates, }; } catch (error) { console.warn(`⚠️ Failed to convert KML Polygon: ${error.message}`); return null; } } /** * Convert KML MultiGeometry to GeoJSON (Polygon or MultiPolygon) */ static convertKMLMultiGeometryToGeoJSON(multiGeometry) { try { const polygons = []; // MultiGeometry can contain various geometry types, we only want Polygons const geometries = Array.isArray(multiGeometry.Polygon) ? multiGeometry.Polygon : multiGeometry.Polygon ? [multiGeometry.Polygon] : []; for (const kmlPolygon of geometries) { const geoJsonPolygon = this.convertKMLPolygonToGeoJSON(kmlPolygon); if (geoJsonPolygon) { polygons.push(geoJsonPolygon.coordinates); } } if (polygons.length === 0) { return null; // No valid polygons found } else if (polygons.length === 1) { // Single polygon return { type: "Polygon", coordinates: polygons[0], }; } else { // Multiple polygons return { type: "MultiPolygon", coordinates: polygons, }; } } catch (error) { console.warn(`⚠️ Failed to convert KML MultiGeometry: ${error.message}`); return null; } } /** * Extract LinearRing coordinates from KML boundary */ static extractKMLLinearRing(boundary) { try { // Navigate to LinearRing coordinates const linearRing = boundary.LinearRing || boundary.linearRing; if (!linearRing) { return null; } const coordinates = linearRing.coordinates || linearRing.Coordinates; if (!coordinates) { return null; } // Extract coordinate string const coordString = this.extractTextContent(coordinates); if (!coordString) { return null; } // Parse KML coordinates return this.parseKMLCoordinateString(coordString); } catch (error) { console.warn(`⚠️ Failed to extract LinearRing: ${error.message}`); return null; } } /** * Parse KML coordinate string to GeoJSON coordinate array * KML format: "lng,lat,alt lng,lat,alt ..." (space or newline separated) * GeoJSON format: [[lng,lat], [lng,lat], ...] */ static parseKMLCoordinateString(coordString) { try { // Clean up the coordinate string const cleanCoords = coordString.trim().replace(/\s+/g, " "); if (!cleanCoords) { return null; } // Split by spaces or newlines const coordPairs = cleanCoords .split(/[\s\n]+/) .filter((pair) => pair.trim()); const coordinates = []; for (const coordPair of coordPairs) { const parts = coordPair.split(","); if (parts.length >= 2) { const lng = parseFloat(parts[0]); const lat = parseFloat(parts[1]); // Ignore altitude (parts[2]) for 2D polygons if (!isNaN(lng) && !isNaN(lat)) { // Validate coordinate ranges if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) { coordinates.push([lng, lat]); } else { console.warn(`⚠️ Invalid coordinate: ${lat}, ${lng}`); } } } } // Ensure ring is closed (first and last points are the same) if (coordinates.length > 0) { const first = coordinates[0]; const last = coordinates[coordinates.length - 1]; if (first[0] !== last[0] || first[1] !== last[1]) { coordinates.push([first[0], first[1]]); // Close the ring } } return coordinates.length >= 4 ? coordinates : null; // Need at least 4 points for a valid polygon } catch (error) { console.warn(`⚠️ Failed to parse KML coordinates: ${error.message}`); return null; } } /** * Extract ExtendedData from KML */ static extractKMLExtendedData(extendedData) { const properties = {}; try { // Handle Data elements if (extendedData.Data) { const dataArray = Array.isArray(extendedData.Data) ? extendedData.Data : [extendedData.Data]; for (const dataElement of dataArray) { if (dataElement["@_name"] && dataElement.value) { const name = dataElement["@_name"]; const value = this.extractTextContent(dataElement.value); properties[name] = value; } } } // Handle SchemaData if (extendedData.SchemaData) { const schemaData = extendedData.SchemaData; if (schemaData.SimpleData) { const simpleDataArray = Array.isArray(schemaData.SimpleData) ? schemaData.SimpleData : [schemaData.SimpleData]; for (const simpleData of simpleDataArray) { if (simpleData["@_name"] && simpleData["#text"]) { const name = simpleData["@_name"]; const value = simpleData["#text"]; properties[name] = value; } } } } } catch (error) { console.warn(`⚠️ Failed to extract ExtendedData: ${error.message}`); } return properties; } /** * Extract text content from XML element (handles different XML parser formats) */ static extractTextContent(element) { if (typeof element === "string") { return element.trim(); } if (element && typeof element === "object") { // Check for common text properties from different XML parsers if (element["#text"]) { return String(element["#text"]).trim(); } if (element._text) { return String(element._text).trim(); } if (element.value) { return String(element.value).trim(); } if (element.$t) { return String(element.$t).trim(); } // If it's a simple object with a single property, try that const keys = Object.keys(element); if (keys.length === 1 && typeof element[keys[0]] === "string") { return element[keys[0]].trim(); } } return ""; } /** * Validate converted geometry */ static validateGeoJSONGeometry(geometry) { if (!geometry || !geometry.type || !geometry.coordinates) { return false; } if (geometry.type === "Polygon") { return (Array.isArray(geometry.coordinates) && geometry.coordinates.length > 0 && Array.isArray(geometry.coordinates[0]) && geometry.coordinates[0].length >= 4); } if (geometry.type === "MultiPolygon") { return (Array.isArray(geometry.coordinates) && geometry.coordinates.length > 0 && geometry.coordinates.every((polygon) => Array.isArray(polygon) && polygon.length > 0 && Array.isArray(polygon[0]) && polygon[0].length >= 4)); } return false; } static calculateBoundingBox(coordinates, geometryType) { let minLat = Infinity; let maxLat = -Infinity; let minLng = Infinity; let maxLng = -Infinity; const processCoordinate = (coord) => { if (Array.isArray(coord) && coord.length >= 2) { const [lng, lat] = coord; if (typeof lng === "number" && typeof lat === "number") { minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng); minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat); } } }; const processCoordinateArray = (coordArray) => { if (Array.isArray(coordArray)) { coordArray.forEach((item) => { if (Array.isArray(item)) { if (typeof item[0] === "number") { processCoordinate(item); } else { processCoordinateArray(item); } } }); } }; try { processCoordinateArray(coordinates); if (!isFinite(minLat) || !isFinite(maxLat) || !isFinite(minLng) || !isFinite(maxLng)) { throw new Error("Could not calculate valid bounding box"); } return { minLat, maxLat, minLng, maxLng }; } catch (error) { throw new errors_1.ParseError(`Failed to calculate bounding box for ${geometryType}`); } } } exports.UniversalParser = UniversalParser;