pwaLUPMIS2/src/gis-export.js
ekke 26d4f6235f UPN-grid layer, external imports/staged upload, GIS export, SW v10
UPN-grid layer:
- src/database.js — new upn_grid SQLocal table (id, districtid, upn_prefix,
  geometry_wkt) + saveUpnGrid / getLocalUpnGrid; cache-once-per-district.
- src/remotedb.js — getUpnGrid → get_upn_grid_per_district.php.
- main.js loadUpnGrid + upnGridToGeoJSON in the Administration group, with
  a zoom-aware style: white casing under a bolder violet dashed stroke
  (visible against parcels) and upn_prefix labels rendered only when
  resolution ≤ 7 m/px (≈ scale ≤ 1:25,000).
- main.js click handler: single click on a UPN-grid cell opens an info
  popup showing the upn_prefix.

External-dataset import → staging → upload (client-side complete):
- src/database.js — external_imports + external_import_features tables,
  plus createExternalImport / addExternalImportFeatures /
  updateExternalImport / getExternalImport / getExternalImportFeatures /
  listExternalImports / remapImportedFeatureProperties /
  deleteExternalImport. Status enum: imported/mapped/other/uploading/
  submitted/migrated/failed (aligned with the database team's staged-
  upload model — lu_parcels_upload_tmp + supervisor review).
- src/import-detect.js — pure helpers: detectTargetType(),
  autoMapFields(), applyFieldMapping(), listSourceFields() + TARGET_TYPES
  / TARGET_FIELDS registries.
- src/import-modal.js — Bootstrap mapping modal: target dropdown,
  field-rename table, three actions (Cancel / Save / Save + Upload now).
- main.js — stageImport hooked into addImportedGeoJSON (the single
  convergence point for shp/GeoJSON/KML drops); handleImportModalResult
  applies the mapping in one transaction; runUpload builds the real
  payload (district_id + api_token from remotePost, user_id_upload from
  SSO session, per-feature client_uuid/geom/props) and currently logs +
  toasts — the upload_<target>.php endpoints are not yet live.
- index.html — #importMappingModal markup.
- MapView._decorateLayerListItem — import-state chip (Upload N /
  spinner / ✓ submitted / ✓ live / N errors) dispatching
  lupmis:import-chip-click; src/styles/layerswitcher.css — chip variants.

GIS export from Area / Circle Analysis popups:
- MapView._showAnalysisPopup now accepts an exportContext (clipGeometry +
  parcelFeatures + zoneFeatures + otherByLayer) and renders an "Export
  GIS" button next to "Export PDF". Click dispatches lupmis:export-gis.
- index.html — #exportGisModal markup.
- src/export-gis-modal.js — Bootstrap modal: format toggle (GeoJSON
  default / Shapefile / KML), filename, field-rename table with SHP
  10-char DBF warning.
- src/gis-export.js — writers: GeoJSON via Blob, KML via OL KMLFormat,
  Shapefile via shp-write (with DBF-safe name sanitiser).
- Adds shp-write@0.3.2 dependency.

MapView style options:
- addGeoJSONLayer now accepts strokeDash for line-dash patterns (used by
  the UPN-grid layer and available for any future contextual overlay).

Service Worker v9 → v10 to evict the stale shell/module caches on the
next deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 11:02:41 +02:00

234 lines
7.6 KiB
JavaScript

/**
* 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<ol/Feature> in EPSG:3857 (map projection)
* rename, // Record<sourceField, exportFieldOrEmpty>
* format, // 'geojson' | 'shp' | 'kml'
* filenameBase, // 'area_analysis' (no extension)
* }) → Promise<void>
*
* 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 <name>
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);
}