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>
207 lines
7.0 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
function escapeAttr(s) { return escapeHtml(s); }
|