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>
234 lines
7.6 KiB
JavaScript
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);
|
|
}
|