/** * 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 ? `
${escapeHtml(current.length)} characters — Shapefile will truncate / rename.
` : ''; return ` ${escapeHtml(src)} ${warn} `; }).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 10 characters ' + '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 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, '''); } function escapeAttr(s) { return escapeHtml(s); }