/** * GIS export — write a list of OL features (with renamed properties) to * GeoJSON / KML / Shapefile, and trigger a browser download. * * Public API: * * exportFeaturesToGis({ * features, // Array
    in EPSG:3857 (map projection) * rename, // Record * format, // 'geojson' | 'shp' | 'kml' * filenameBase, // 'area_analysis' (no extension) * }) → Promise * * The output uses EPSG:4326 (lon/lat) in every format. Source fields whose * rename value is empty are dropped. Geometry is preserved as-is. * * SHP attribute names are truncated/sanitised to 10 chars + alphanumeric/ * underscore (DBF requirement). Collisions are resolved by appending a * numeric suffix. */ import GeoJSONFormat from 'ol/format/GeoJSON.js'; import KMLFormat from 'ol/format/KML.js'; import * as shpwrite from 'shp-write'; const PROJ_MAP = 'EPSG:3857'; const PROJ_WGS84 = 'EPSG:4326'; // --------------------------------------------------------------------------- // Public entry // --------------------------------------------------------------------------- export async function exportFeaturesToGis({ features, rename, format, filenameBase = 'export', }) { if (!Array.isArray(features) || features.length === 0) { throw new Error('No features to export'); } // 1. Apply the rename map → produce GeoJSON Features in EPSG:4326. const geojson = buildRenamedGeoJSON(features, rename); // 2. Format-specific writer + download. switch (format) { case 'geojson': return downloadBlob( new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/geo+json' }), `${filenameBase}.geojson` ); case 'kml': return downloadBlob( new Blob([writeKml(features, rename)], { type: 'application/vnd.google-earth.kml+xml' }), `${filenameBase}.kml` ); case 'shp': return writeShp(geojson, filenameBase); default: throw new Error(`Unknown export format: ${format}`); } } // --------------------------------------------------------------------------- // Shared: apply rename → GeoJSON in WGS84 // --------------------------------------------------------------------------- /** * Produce a GeoJSON FeatureCollection in EPSG:4326 with properties remapped * per the user's rename table. Fields whose rename value is empty/null are * dropped. */ function buildRenamedGeoJSON(features, rename) { const fmt = new GeoJSONFormat(); const out = { type: 'FeatureCollection', features: [] }; for (const f of features) { if (!f.getGeometry()) continue; const geomGeoJson = fmt.writeGeometryObject(f.getGeometry(), { dataProjection: PROJ_WGS84, featureProjection: PROJ_MAP, }); const sourceProps = stripGeometryFromProps(f.getProperties()); const outProps = {}; for (const [src, target] of Object.entries(rename || {})) { if (!target) continue; // user cleared → drop if (Object.prototype.hasOwnProperty.call(sourceProps, src)) { outProps[target] = sourceProps[src]; } } out.features.push({ type: 'Feature', geometry: geomGeoJson, properties: outProps }); } return out; } function stripGeometryFromProps(props) { const out = {}; for (const [k, v] of Object.entries(props || {})) { if (k === 'geometry') continue; out[k] = v; } return out; } // --------------------------------------------------------------------------- // KML — uses OL's writer for proper styling/structure // --------------------------------------------------------------------------- function writeKml(features, rename) { // KML carries arbitrary string properties via ExtendedData; OL's writer // serialises every property. Clone each feature so we can swap its // properties to the renamed set without mutating the source. const clones = features .filter((f) => f.getGeometry()) .map((f) => { const clone = f.clone(); const sourceProps = stripGeometryFromProps(f.getProperties()); const outProps = {}; for (const [src, target] of Object.entries(rename || {})) { if (!target) continue; if (Object.prototype.hasOwnProperty.call(sourceProps, src)) { outProps[target] = sourceProps[src]; } } clone.setProperties(outProps, /* silent */ true); // Use the first renamed value that looks like a label for KML's const labelKey = Object.values(rename || {}).find( (v) => v && outProps[v] != null && outProps[v] !== '' ); if (labelKey) clone.set('name', String(outProps[labelKey])); return clone; }); return new KMLFormat({ extractStyles: false }).writeFeatures(clones, { dataProjection: PROJ_WGS84, featureProjection: PROJ_MAP, }); } // --------------------------------------------------------------------------- // Shapefile — uses shp-write to build a .zip with .shp/.shx/.dbf/.prj // --------------------------------------------------------------------------- /** * shp-write's `download` accepts a GeoJSON FeatureCollection and a config. * It splits geometries by type and writes one shapefile per type inside a * single .zip download. We pre-sanitise field names to honour DBF's 10-char * limit (otherwise shp-write silently truncates, possibly causing collisions). */ async function writeShp(geojson, filenameBase) { const sanitised = sanitiseShpFieldNames(geojson); // shp-write 0.3 emits a .zip via DOM download. There's no Promise return, // but the call is synchronous in practice. Wrap to a Promise so callers // can await uniformly. return new Promise((resolve, reject) => { try { shpwrite.download(sanitised, { folder: filenameBase, outputType: 'blob', compression: 'DEFLATE', types: { point: `${filenameBase}_point`, polygon: `${filenameBase}_polygon`, polyline: `${filenameBase}_line`, }, }); resolve(); } catch (err) { reject(err); } }); } /** * Make every property key valid for DBF: ≤10 chars, alphanumeric/underscore, * unique across the set. Returns a new GeoJSON FeatureCollection. */ function sanitiseShpFieldNames(geojson) { // Collect the union of source keys (sources are already user-renamed). const allKeys = new Set(); for (const f of geojson.features) { for (const k of Object.keys(f.properties || {})) allKeys.add(k); } // Build map sourceKey → safeKey. const used = new Set(); const remap = {}; for (const k of allKeys) { let base = String(k).replace(/[^A-Za-z0-9_]+/g, '_').slice(0, 10) || 'field'; let candidate = base; let i = 1; while (used.has(candidate)) { const suffix = String(i++); candidate = base.slice(0, Math.max(1, 10 - suffix.length)) + suffix; } used.add(candidate); remap[k] = candidate; } return { type: 'FeatureCollection', features: geojson.features.map((f) => { const newProps = {}; for (const [k, v] of Object.entries(f.properties || {})) { newProps[remap[k]] = v; } return { type: 'Feature', geometry: f.geometry, properties: newProps }; }), }; } // --------------------------------------------------------------------------- // Generic download helper // --------------------------------------------------------------------------- function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); }