pwaLUPMIS2/src/export-gis-modal.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

207 lines
7.0 KiB
JavaScript

/**
* GIS export modal controller.
*
* openExportGisModal({ title, kind, parcelFeatures, zoneFeatures,
* otherByLayer, clipGeometry })
*
* Opens the modal seeded with:
* - a default filename ('area_analysis' / 'circle_analysis'),
* - the union of all source-attribute keys across the intersecting
* features, each pre-filled with its own name in the rename column.
*
* On Export: gathers the renamed map and calls exportFeaturesToGis().
*
* Symmetric to src/import-modal.js — same Bootstrap modal pattern.
*/
import { Modal } from 'bootstrap';
import { exportFeaturesToGis } from './gis-export.js';
const els = {};
let modal = null;
let state = null;
function cacheEls() {
if (els.root) return;
els.root = document.getElementById('exportGisModal');
els.summary = document.getElementById('export-gis-summary');
els.filename = document.getElementById('export-gis-filename');
els.tbody = document.getElementById('export-gis-fields-tbody');
els.fmtHint = document.getElementById('export-gis-format-hint');
els.btnGo = document.getElementById('export-gis-go');
els.fmtInputs = Array.from(document.querySelectorAll('input[name="export-gis-format"]'));
// Wire format-change handler so the SHP warning + filename suggestion react.
if (!els.root.dataset.wired) {
els.root.dataset.wired = '1';
els.fmtInputs.forEach((r) => r.addEventListener('change', onFormatChange));
els.btnGo.addEventListener('click', onExportClick);
}
}
// ---------------------------------------------------------------------------
// State helpers
// ---------------------------------------------------------------------------
function collectFeatures(ctx) {
const out = [];
for (const f of ctx.parcelFeatures || []) out.push(tagWithSource(f, 'Parcels'));
for (const f of ctx.zoneFeatures || []) out.push(tagWithSource(f, 'Zones'));
for (const [layerTitle, arr] of Object.entries(ctx.otherByLayer || {})) {
for (const f of arr) out.push(tagWithSource(f, layerTitle));
}
return out;
}
/**
* Add a `_source` property to a cloned feature so the export carries layer
* provenance. We clone so the source feature is never mutated.
*/
function tagWithSource(feature, sourceLabel) {
const clone = feature.clone();
clone.set('_source', sourceLabel);
return clone;
}
/** Union of source-attribute keys across all features (skipping internals). */
function unionAttributeKeys(features) {
const skip = new Set(['geometry', '_layerType']);
const seen = new Map();
for (const f of features) {
for (const k of Object.keys(f.getProperties() || {})) {
if (skip.has(k)) continue;
if (!seen.has(k)) seen.set(k, true);
}
}
// Ensure _source is always last so it shows up at the bottom of the table.
seen.delete('_source');
const keys = Array.from(seen.keys());
keys.push('_source');
return keys;
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
function renderFieldsTable() {
const fmt = currentFormat();
els.tbody.innerHTML = state.keys.map((src) => {
const current = state.rename[src] ?? src;
const overLen = fmt === 'shp' && current.length > 10;
const warn = overLen
? `<div class="form-text text-danger mt-1">
${escapeHtml(current.length)} characters — Shapefile will
truncate / rename.
</div>`
: '';
return `
<tr>
<td><code>${escapeHtml(src)}</code></td>
<td>
<input type="text" class="form-control form-control-sm export-field-rename"
data-src="${escapeAttr(src)}"
value="${escapeAttr(current)}">
${warn}
</td>
</tr>
`;
}).join('');
els.tbody.querySelectorAll('.export-field-rename').forEach((inp) => {
inp.addEventListener('input', (e) => {
const src = e.target.dataset.src;
state.rename[src] = e.target.value;
// Re-render only when SHP is active so the over-length warning toggles.
if (currentFormat() === 'shp') renderFieldsTable();
});
});
}
function onFormatChange() {
const fmt = currentFormat();
els.fmtHint.innerHTML = {
geojson: 'GeoJSON keeps all attributes as-is and is the safest default.',
shp: 'Shapefile attribute names are limited to <strong>10 characters</strong> ' +
'and alphanumeric/underscore only. Over-length names will be truncated ' +
'(collisions are auto-numbered). One file per geometry type is written, ' +
'all zipped into one download.',
kml: 'KML preserves attribute names; the first non-empty renamed field is used ' +
'as each feature\'s <name> in Google Earth.',
}[fmt];
renderFieldsTable();
}
function currentFormat() {
return els.fmtInputs.find((r) => r.checked)?.value || 'geojson';
}
// ---------------------------------------------------------------------------
// Export action
// ---------------------------------------------------------------------------
async function onExportClick() {
const format = currentFormat();
const filenameBase = (els.filename.value || 'export').replace(/[^A-Za-z0-9_\-]+/g, '_');
els.btnGo.disabled = true;
try {
await exportFeaturesToGis({
features: state.features,
rename: state.rename,
format,
filenameBase,
});
modal.hide();
} catch (err) {
console.error('[ExportGIS] failed:', err);
alert('Export failed: ' + err.message);
} finally {
els.btnGo.disabled = false;
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function openExportGisModal(ctx) {
cacheEls();
if (!els.root) {
console.warn('[ExportGIS] Modal missing from DOM');
return;
}
const features = collectFeatures(ctx);
if (features.length === 0) {
alert('No intersecting features to export.');
return;
}
const keys = unionAttributeKeys(features);
const rename = Object.fromEntries(keys.map((k) => [k, k]));
state = { features, keys, rename };
els.summary.textContent =
`${features.length} feature${features.length === 1 ? '' : 's'} ` +
`intersecting the ${ctx.kind === 'circle' ? 'circle' : 'area'}`;
els.filename.value = (ctx.kind === 'circle' ? 'circle_analysis' : 'area_analysis');
// Reset format to GeoJSON default and render.
const def = document.getElementById('export-gis-fmt-geojson');
if (def) def.checked = true;
onFormatChange();
modal = Modal.getOrCreateInstance(els.root);
modal.show();
}
// ---------------------------------------------------------------------------
// HTML-escape helpers
// ---------------------------------------------------------------------------
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(s) { return escapeHtml(s); }