/**
* 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);
}