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>
This commit is contained in:
parent
933bfcf4c0
commit
26d4f6235f
BIN
LUPMIS2_Import_Upload_Design.docx
Normal file
BIN
LUPMIS2_Import_Upload_Design.docx
Normal file
Binary file not shown.
904
dist/assets/index-DR_U08k-.js
vendored
Normal file
904
dist/assets/index-DR_U08k-.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-DR_U08k-.js.map
vendored
Normal file
1
dist/assets/index-DR_U08k-.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
803
dist/assets/index-YjHYbDyk.js
vendored
803
dist/assets/index-YjHYbDyk.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-YjHYbDyk.js.map
vendored
1
dist/assets/index-YjHYbDyk.js.map
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,2 +1,2 @@
|
||||
import{E as f,a as b}from"./jspdf-Dzj2Osmy.js";import"./openlayers-CvK8xBSr.js";b(f);let s=null;async function x(){if(s)return s;try{const e=new Image;e.crossOrigin="anonymous",await new Promise((r,i)=>{e.onload=r,e.onerror=i,e.src="./app-icons/luspa-pdf.jpg"});const n=document.createElement("canvas");n.width=e.naturalWidth,n.height=e.naturalHeight;const t=n.getContext("2d");return t.fillStyle="#ffffff",t.fillRect(0,0,n.width,n.height),t.drawImage(e,0,0),s=n.toDataURL("image/jpeg",.92),s}catch(e){return console.warn("[PDF] Could not load logo:",e),null}}async function v({title:e,rows:n}){const t=new f({orientation:"portrait",unit:"mm",format:"a4"}),r=t.internal.pageSize.getWidth(),i=[30,26,75],g=await x(),c=28,a=14;let o=14;g&&t.addImage(g,"JPEG",a,o,c,c);const m=a+c+6;t.setFont("helvetica","bold"),t.setFontSize(18),t.setTextColor(...i),t.text("LUPMIS",m,o+11),t.setFont("helvetica","normal"),t.setFontSize(12),t.text(e,m,o+19);const d=new Date,h=d.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"}),p=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${p}`,r-a,o+11,{align:"right"}),o+=c+6,t.setDrawColor(...i),t.setLineWidth(.5),t.line(a,o,r-a,o),o+=6;const y=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:y,margin:{left:a,right:a},styles:{font:"helvetica",fontSize:10,cellPadding:4},headStyles:{fillColor:i,textColor:[255,255,255],fontStyle:"bold"},alternateRowStyles:{fillColor:[245,245,250]},columnStyles:{0:{fontStyle:"bold",cellWidth:50}}});const S=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,S);const w=t.output("blob"),u=URL.createObjectURL(w);if(!window.open(u,"_blank")){const l=document.createElement("a");l.href=u,l.download=`${e.replace(/\s+/g,"_")}_${d.toISOString().slice(0,10)}.pdf`,document.body.appendChild(l),l.click(),document.body.removeChild(l)}}export{v as exportAnalysisPDF};
|
||||
//# sourceMappingURL=pdf-export-vzOHm8wb.js.map
|
||||
import{E as f,a as b}from"./jspdf-BTK_8o8D.js";import"./openlayers-D8ReJJOp.js";b(f);let s=null;async function x(){if(s)return s;try{const e=new Image;e.crossOrigin="anonymous",await new Promise((r,i)=>{e.onload=r,e.onerror=i,e.src="./app-icons/luspa-pdf.jpg"});const n=document.createElement("canvas");n.width=e.naturalWidth,n.height=e.naturalHeight;const t=n.getContext("2d");return t.fillStyle="#ffffff",t.fillRect(0,0,n.width,n.height),t.drawImage(e,0,0),s=n.toDataURL("image/jpeg",.92),s}catch(e){return console.warn("[PDF] Could not load logo:",e),null}}async function v({title:e,rows:n}){const t=new f({orientation:"portrait",unit:"mm",format:"a4"}),r=t.internal.pageSize.getWidth(),i=[30,26,75],g=await x(),c=28,a=14;let o=14;g&&t.addImage(g,"JPEG",a,o,c,c);const m=a+c+6;t.setFont("helvetica","bold"),t.setFontSize(18),t.setTextColor(...i),t.text("LUPMIS",m,o+11),t.setFont("helvetica","normal"),t.setFontSize(12),t.text(e,m,o+19);const d=new Date,h=d.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"}),p=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${p}`,r-a,o+11,{align:"right"}),o+=c+6,t.setDrawColor(...i),t.setLineWidth(.5),t.line(a,o,r-a,o),o+=6;const y=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:y,margin:{left:a,right:a},styles:{font:"helvetica",fontSize:10,cellPadding:4},headStyles:{fillColor:i,textColor:[255,255,255],fontStyle:"bold"},alternateRowStyles:{fillColor:[245,245,250]},columnStyles:{0:{fontStyle:"bold",cellWidth:50}}});const S=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,S);const w=t.output("blob"),u=URL.createObjectURL(w);if(!window.open(u,"_blank")){const l=document.createElement("a");l.href=u,l.download=`${e.replace(/\s+/g,"_")}_${d.toISOString().slice(0,10)}.pdf`,document.body.appendChild(l),l.click(),document.body.removeChild(l)}}export{v as exportAnalysisPDF};
|
||||
//# sourceMappingURL=pdf-export-BG6jqfsR.js.map
|
||||
File diff suppressed because one or more lines are too long
5
dist/assets/shpjs-CNrRgkgn.js
vendored
5
dist/assets/shpjs-CNrRgkgn.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/shpjs-CNrRgkgn.js.map
vendored
1
dist/assets/shpjs-CNrRgkgn.js.map
vendored
File diff suppressed because one or more lines are too long
6
dist/assets/shpjs-iyObTF9J.js
vendored
Normal file
6
dist/assets/shpjs-iyObTF9J.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/shpjs-iyObTF9J.js.map
vendored
Normal file
1
dist/assets/shpjs-iyObTF9J.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
177
dist/index.html
vendored
177
dist/index.html
vendored
@ -1598,14 +1598,15 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-YjHYbDyk.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-CvK8xBSr.js">
|
||||
<script type="module" crossorigin src="/assets/index-DR_U08k-.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-D8ReJJOp.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-BR0zF6aa.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/shpjs-iyObTF9J.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/ol-ext-BgKrOIxx.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BxlvFVPW.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dp-9_Fz_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
@ -1780,6 +1781,174 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GIS export modal — opened from the Export GIS button on the Area /
|
||||
Circle Analysis popup. Lets the user pick a format and rename the
|
||||
output fields before downloading. Wired up in src/export-gis-modal.js. -->
|
||||
<div class="modal fade" id="exportGisModal" tabindex="-1"
|
||||
aria-labelledby="exportGisModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary">
|
||||
<h5 class="modal-title" id="exportGisModalLabel">
|
||||
<i class="bi bi-globe me-2"></i>Export intersecting features
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white"
|
||||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Summary -->
|
||||
<div class="mb-3">
|
||||
<strong id="export-gis-summary"></strong>
|
||||
</div>
|
||||
|
||||
<!-- Format -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold d-block">Format</label>
|
||||
<div class="btn-group" role="group" aria-label="Export format">
|
||||
<input type="radio" class="btn-check" name="export-gis-format"
|
||||
id="export-gis-fmt-geojson" value="geojson" checked>
|
||||
<label class="btn btn-outline-primary" for="export-gis-fmt-geojson">
|
||||
GeoJSON
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="export-gis-format"
|
||||
id="export-gis-fmt-shp" value="shp">
|
||||
<label class="btn btn-outline-primary" for="export-gis-fmt-shp">
|
||||
Shapefile
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="export-gis-format"
|
||||
id="export-gis-fmt-kml" value="kml">
|
||||
<label class="btn btn-outline-primary" for="export-gis-fmt-kml">
|
||||
KML
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text" id="export-gis-format-hint">
|
||||
GeoJSON keeps all attributes as-is and is the safest default.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filename -->
|
||||
<div class="mb-3">
|
||||
<label for="export-gis-filename" class="form-label fw-bold">
|
||||
Filename (without extension)
|
||||
</label>
|
||||
<input type="text" class="form-control" id="export-gis-filename"
|
||||
value="area_analysis">
|
||||
</div>
|
||||
|
||||
<!-- Field rename table -->
|
||||
<div>
|
||||
<label class="form-label fw-bold mb-1">Field names</label>
|
||||
<div class="form-text mb-2">
|
||||
Each source attribute on the left; rename it on the right
|
||||
(or clear the input to drop the field from the export).
|
||||
Shapefiles cap field names at 10 characters — the table flags
|
||||
any over-length names when SHP is selected.
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height:340px;">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light" style="position:sticky;top:0;z-index:1;">
|
||||
<tr>
|
||||
<th style="width:42%">Source field</th>
|
||||
<th>Export as</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="export-gis-fields-tbody">
|
||||
<!-- Populated at runtime -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link text-muted me-auto"
|
||||
data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="export-gis-go">
|
||||
<i class="bi bi-download me-1"></i>Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import-mapping modal — shown after a file is dropped on the map.
|
||||
Wired up in src/import-modal.js. Lets the user pick a target type
|
||||
and map source fields to LUPMIS2 columns; on save the staging row
|
||||
is updated (status: 'other' | 'mapped'). See
|
||||
LUPMIS2_Import_Upload_Design.docx §3. -->
|
||||
<div class="modal fade" id="importMappingModal" tabindex="-1"
|
||||
aria-labelledby="importMappingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary">
|
||||
<h5 class="modal-title" id="importMappingModalLabel">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Imported dataset
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white"
|
||||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Summary -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-baseline">
|
||||
<strong id="import-modal-filename" class="text-truncate"></strong>
|
||||
<span class="text-muted" id="import-modal-summary"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target-type dropdown -->
|
||||
<div class="mb-3">
|
||||
<label for="import-modal-target" class="form-label fw-bold">
|
||||
Target type
|
||||
</label>
|
||||
<select class="form-select" id="import-modal-target">
|
||||
<!-- Populated from TARGET_TYPES at runtime -->
|
||||
</select>
|
||||
<div class="form-text" id="import-modal-target-hint">
|
||||
Choose <em>Other (view only)</em> if this dataset is for display
|
||||
only and should not be uploaded to the database.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field-mapping table — only shown when a real target type is chosen -->
|
||||
<div id="import-modal-fields-wrap">
|
||||
<label class="form-label fw-bold mb-1">Field mapping</label>
|
||||
<div class="form-text mb-2">
|
||||
Each LUPMIS2 column is matched to a source field where possible.
|
||||
Choose <em>(none)</em> to leave a column unfilled.
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height:340px;">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light" style="position:sticky;top:0;z-index:1;">
|
||||
<tr>
|
||||
<th style="width:42%">LUPMIS2 column</th>
|
||||
<th>Source field</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="import-modal-fields-tbody">
|
||||
<!-- Populated at runtime -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link text-muted me-auto"
|
||||
id="import-modal-cancel" data-bs-dismiss="modal">
|
||||
Cancel (keep as view-only)
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
id="import-modal-save">
|
||||
<i class="bi bi-check2 me-1"></i>Save mapping
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
id="import-modal-save-upload">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i>Save + Upload now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left Offcanvas -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasLeft" aria-labelledby="offcanvasLeftLabel">
|
||||
<div class="offcanvas-header">
|
||||
|
||||
8
dist/sw.js
vendored
8
dist/sw.js
vendored
@ -33,7 +33,13 @@
|
||||
// for the permitting integration; lu_parcels structural refactor in the
|
||||
// local DB; production access guard + no-district overlay; LayerSwitcher
|
||||
// ordering fix. New shell + hashed bundle.
|
||||
const CACHE_VERSION = 'v9';
|
||||
// v10: UPN-grid layer (cache + click popup + dashed-violet style + 1:25,000
|
||||
// label threshold); external-dataset import → staging → upload pipeline
|
||||
// (external_imports/_features tables, mapping modal, LayerSwitcher chip,
|
||||
// staged-upload payload with user_id_upload from SSO); GIS export from
|
||||
// Area/Circle Analysis popups (GeoJSON / SHP via shp-write / KML, with
|
||||
// field-rename modal). New hashed bundle + shp-write chunk.
|
||||
const CACHE_VERSION = 'v10';
|
||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||
|
||||
168
index.html
168
index.html
@ -1772,6 +1772,174 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GIS export modal — opened from the Export GIS button on the Area /
|
||||
Circle Analysis popup. Lets the user pick a format and rename the
|
||||
output fields before downloading. Wired up in src/export-gis-modal.js. -->
|
||||
<div class="modal fade" id="exportGisModal" tabindex="-1"
|
||||
aria-labelledby="exportGisModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary">
|
||||
<h5 class="modal-title" id="exportGisModalLabel">
|
||||
<i class="bi bi-globe me-2"></i>Export intersecting features
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white"
|
||||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Summary -->
|
||||
<div class="mb-3">
|
||||
<strong id="export-gis-summary"></strong>
|
||||
</div>
|
||||
|
||||
<!-- Format -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold d-block">Format</label>
|
||||
<div class="btn-group" role="group" aria-label="Export format">
|
||||
<input type="radio" class="btn-check" name="export-gis-format"
|
||||
id="export-gis-fmt-geojson" value="geojson" checked>
|
||||
<label class="btn btn-outline-primary" for="export-gis-fmt-geojson">
|
||||
GeoJSON
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="export-gis-format"
|
||||
id="export-gis-fmt-shp" value="shp">
|
||||
<label class="btn btn-outline-primary" for="export-gis-fmt-shp">
|
||||
Shapefile
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="export-gis-format"
|
||||
id="export-gis-fmt-kml" value="kml">
|
||||
<label class="btn btn-outline-primary" for="export-gis-fmt-kml">
|
||||
KML
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text" id="export-gis-format-hint">
|
||||
GeoJSON keeps all attributes as-is and is the safest default.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filename -->
|
||||
<div class="mb-3">
|
||||
<label for="export-gis-filename" class="form-label fw-bold">
|
||||
Filename (without extension)
|
||||
</label>
|
||||
<input type="text" class="form-control" id="export-gis-filename"
|
||||
value="area_analysis">
|
||||
</div>
|
||||
|
||||
<!-- Field rename table -->
|
||||
<div>
|
||||
<label class="form-label fw-bold mb-1">Field names</label>
|
||||
<div class="form-text mb-2">
|
||||
Each source attribute on the left; rename it on the right
|
||||
(or clear the input to drop the field from the export).
|
||||
Shapefiles cap field names at 10 characters — the table flags
|
||||
any over-length names when SHP is selected.
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height:340px;">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light" style="position:sticky;top:0;z-index:1;">
|
||||
<tr>
|
||||
<th style="width:42%">Source field</th>
|
||||
<th>Export as</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="export-gis-fields-tbody">
|
||||
<!-- Populated at runtime -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link text-muted me-auto"
|
||||
data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="export-gis-go">
|
||||
<i class="bi bi-download me-1"></i>Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import-mapping modal — shown after a file is dropped on the map.
|
||||
Wired up in src/import-modal.js. Lets the user pick a target type
|
||||
and map source fields to LUPMIS2 columns; on save the staging row
|
||||
is updated (status: 'other' | 'mapped'). See
|
||||
LUPMIS2_Import_Upload_Design.docx §3. -->
|
||||
<div class="modal fade" id="importMappingModal" tabindex="-1"
|
||||
aria-labelledby="importMappingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary">
|
||||
<h5 class="modal-title" id="importMappingModalLabel">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Imported dataset
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white"
|
||||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Summary -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-baseline">
|
||||
<strong id="import-modal-filename" class="text-truncate"></strong>
|
||||
<span class="text-muted" id="import-modal-summary"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target-type dropdown -->
|
||||
<div class="mb-3">
|
||||
<label for="import-modal-target" class="form-label fw-bold">
|
||||
Target type
|
||||
</label>
|
||||
<select class="form-select" id="import-modal-target">
|
||||
<!-- Populated from TARGET_TYPES at runtime -->
|
||||
</select>
|
||||
<div class="form-text" id="import-modal-target-hint">
|
||||
Choose <em>Other (view only)</em> if this dataset is for display
|
||||
only and should not be uploaded to the database.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field-mapping table — only shown when a real target type is chosen -->
|
||||
<div id="import-modal-fields-wrap">
|
||||
<label class="form-label fw-bold mb-1">Field mapping</label>
|
||||
<div class="form-text mb-2">
|
||||
Each LUPMIS2 column is matched to a source field where possible.
|
||||
Choose <em>(none)</em> to leave a column unfilled.
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height:340px;">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light" style="position:sticky;top:0;z-index:1;">
|
||||
<tr>
|
||||
<th style="width:42%">LUPMIS2 column</th>
|
||||
<th>Source field</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="import-modal-fields-tbody">
|
||||
<!-- Populated at runtime -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link text-muted me-auto"
|
||||
id="import-modal-cancel" data-bs-dismiss="modal">
|
||||
Cancel (keep as view-only)
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
id="import-modal-save">
|
||||
<i class="bi bi-check2 me-1"></i>Save mapping
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
id="import-modal-save-upload">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i>Save + Upload now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left Offcanvas -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasLeft" aria-labelledby="offcanvasLeftLabel">
|
||||
<div class="offcanvas-header">
|
||||
|
||||
408
main.js
408
main.js
@ -30,6 +30,14 @@ import {
|
||||
getRemoteData,
|
||||
saveCollectorZones,
|
||||
getLocalCollectorZones,
|
||||
saveUpnGrid,
|
||||
getLocalUpnGrid,
|
||||
createExternalImport,
|
||||
addExternalImportFeatures,
|
||||
updateExternalImport,
|
||||
getExternalImport,
|
||||
getExternalImportFeatures,
|
||||
remapImportedFeatureProperties,
|
||||
saveParcels,
|
||||
getLocalParcels,
|
||||
updateParcel,
|
||||
@ -56,6 +64,7 @@ import WKT from 'ol/format/WKT';
|
||||
|
||||
// OpenLayers KML format (for KML file import)
|
||||
import KML from 'ol/format/KML';
|
||||
import { Style, Stroke, Fill, Text as OlText } from 'ol/style';
|
||||
|
||||
// Shapefile parser (reads .zip containing .shp/.dbf/.shx/.prj)
|
||||
// Lazy-loaded — only fetched the first time the user imports a shapefile.
|
||||
@ -82,7 +91,7 @@ import {
|
||||
} from './src/offlineTiles.js';
|
||||
|
||||
// Remote database API (PostgreSQL backend)
|
||||
import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints, getContoursHillshade, getOSMRoads, getSession } from './src/remotedb.js';
|
||||
import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints, getContoursHillshade, getOSMRoads, getUpnGrid, getSession } from './src/remotedb.js';
|
||||
|
||||
// GPS live-position + trail recording (reusable engine + LUPMIS wiring)
|
||||
import { geoTracker } from './src/geotracker-lupmis.js';
|
||||
@ -91,6 +100,13 @@ import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './
|
||||
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
|
||||
import { createEmbedBridge } from './src/embed-bridge.js';
|
||||
|
||||
// External-dataset import → staging → upload (see LUPMIS2_Import_Upload_Design.docx)
|
||||
import { openImportMappingModal } from './src/import-modal.js';
|
||||
import { applyFieldMapping } from './src/import-detect.js';
|
||||
|
||||
// GIS export from the analysis popups (Area / Circle)
|
||||
import { openExportGisModal } from './src/export-gis-modal.js';
|
||||
|
||||
// Map instance (global for access across functions)
|
||||
let mapView = null;
|
||||
let mapTools = null;
|
||||
@ -256,6 +272,26 @@ async function initApp() {
|
||||
return;
|
||||
}
|
||||
|
||||
// UPN-grid cell click: show a popup with the upn_prefix. This runs in
|
||||
// ANY non-draw mode and is checked AFTER the parcel branch so that a
|
||||
// parcel sitting inside a grid cell wins (parcels are the specific
|
||||
// object, the grid is contextual).
|
||||
let upnGridFeature = null;
|
||||
mapView.getMap().forEachFeatureAtPixel(evt.pixel, (f) => {
|
||||
if (f.get('_layerType') === 'upn_grid') {
|
||||
upnGridFeature = f;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (upnGridFeature) {
|
||||
console.log('[MapClick] Clicked on UPN-grid cell → Info popup');
|
||||
mapView.showInfoPopup(upnGridFeature, evt.coordinate, {
|
||||
title: 'UPN Grid Cell',
|
||||
color: '#7c3aed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-parcel clicks (markers, empty space) only in addLocation mode
|
||||
if (currentMode !== 'addLocation') {
|
||||
return;
|
||||
@ -414,6 +450,7 @@ async function initApp() {
|
||||
mapView?.initEditBar();
|
||||
|
||||
loadDistrictBoundary();
|
||||
loadUpnGrid();
|
||||
loadCollectorZones();
|
||||
loadParcels();
|
||||
// In embed permit mode the parcels layer is the user's working surface,
|
||||
@ -1342,6 +1379,149 @@ async function loadDistrictBoundary() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UPN-grid rows (either from the API or the local cache) into a
|
||||
* GeoJSON FeatureCollection. Each feature carries only `_layerType` and
|
||||
* `upn_prefix` as properties so the click popup shows nothing but the
|
||||
* prefix (no fetched_at, no internal IDs).
|
||||
*/
|
||||
function upnGridToGeoJSON(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||
const features = [];
|
||||
for (const r of rows) {
|
||||
const wkt = r.polygon || r.geometry_wkt || r.geom;
|
||||
const geometry = parseWKT(wkt);
|
||||
if (!geometry) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { _layerType: 'upn_grid', upn_prefix: r.upn_prefix ?? null },
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
if (features.length === 0) return null;
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the UPN-grid (district sub-division) layer into the Administration
|
||||
* group. The grid never changes, so it is fetched once per district and
|
||||
* served from the local cache thereafter; a fetch only happens when no
|
||||
* cells are cached for the current district (e.g. first load, or the user
|
||||
* now belongs to a different district).
|
||||
*
|
||||
* The "UPN Grid" layer is added to the Administration LayerGroup (id 1),
|
||||
* initially not visible — the user toggles it in the LayerSwitcher.
|
||||
*/
|
||||
async function loadUpnGrid() {
|
||||
const ADMIN_GROUP_ID = 1;
|
||||
// Base style passed to addGeoJSONLayer keeps the LayerSwitcher subtitle
|
||||
// ("Vector / Polygon") and a sensible initial render. We override the
|
||||
// visual style below via setStyle() with a zoom-aware function.
|
||||
const upnGridStyle = {
|
||||
strokeColor: '#5b21b6',
|
||||
strokeWidth: 1.5,
|
||||
fillColor: 'rgba(124,58,237,0.04)',
|
||||
typeDescription: 'Vector / Polygon',
|
||||
};
|
||||
|
||||
const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
|
||||
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
|
||||
const upnGridLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'UPN Grid', upnGridStyle, adminGroup);
|
||||
if (!upnGridLayer) {
|
||||
console.warn('[App] Could not create UPN Grid layer');
|
||||
return;
|
||||
}
|
||||
upnGridLayer.setVisible(false);
|
||||
|
||||
// Custom style function:
|
||||
// - White casing under a bolder violet dashed line so the grid stays
|
||||
// visible on top of (or under) the sky-blue Parcels layer.
|
||||
// - Label: render the feature's upn_prefix at scales ≥ 1:25,000.
|
||||
// Using the OGC convention scale = resolution / 0.00028, that is
|
||||
// resolution ≤ 7 m/px in Web Mercator.
|
||||
const UPN_LABEL_MAX_RESOLUTION = 7; // ≈ 1:25,000
|
||||
upnGridLayer.setStyle((feature, resolution) => {
|
||||
const styles = [
|
||||
// 1) White halo / casing — drawn first, so it sits underneath.
|
||||
new Style({
|
||||
stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3.5 }),
|
||||
}),
|
||||
// 2) Violet dashed stroke + faint fill.
|
||||
new Style({
|
||||
stroke: new Stroke({ color: '#5b21b6', width: 1.5, lineDash: [7, 4] }),
|
||||
fill: new Fill({ color: 'rgba(124,58,237,0.04)' }),
|
||||
}),
|
||||
];
|
||||
// 3) Label, only when sufficiently zoomed in.
|
||||
if (resolution <= UPN_LABEL_MAX_RESOLUTION) {
|
||||
const prefix = feature.get('upn_prefix');
|
||||
if (prefix != null && String(prefix).length > 0) {
|
||||
styles.push(new Style({
|
||||
text: new OlText({
|
||||
text: String(prefix),
|
||||
font: '600 12px Arial, sans-serif',
|
||||
fill: new Fill({ color: '#3b0764' }),
|
||||
stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3 }),
|
||||
overflow: true, // allow drawing in tight cells
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
return styles;
|
||||
});
|
||||
|
||||
upnGridLayer.on('change:visible', () => {
|
||||
if (upnGridLayer.getVisible() && upnGridLayer.getSource().getFeatures().length === 0) {
|
||||
showError('No UPN grid available locally. Connect to the internet to download it.');
|
||||
}
|
||||
});
|
||||
|
||||
function setFeatures(geojson) {
|
||||
const newFeatures = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' });
|
||||
upnGridLayer.getSource().clear();
|
||||
upnGridLayer.getSource().addFeatures(newFeatures);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = getSession();
|
||||
const districtId = session?.district_id ?? null;
|
||||
|
||||
// 1) Local cache for the CURRENT district → use it; skip the API call.
|
||||
// The cache is keyed by districtid so a cache from another district
|
||||
// (e.g. dev fallback) won't satisfy this lookup and we'll re-fetch.
|
||||
const cached = await getLocalUpnGrid(districtId);
|
||||
if (cached) {
|
||||
const geojson = upnGridToGeoJSON(cached);
|
||||
if (geojson) setFeatures(geojson);
|
||||
console.log('[App] UPN grid from cache:', cached.length, 'cells (district', districtId, ')');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Not cached for this district → fetch, save, render. If offline,
|
||||
// leave the layer empty; the toggle handler above will explain.
|
||||
if (!isOnline() || !isServerReachable()) {
|
||||
console.log('[App] UPN grid not available — offline and no cache for district', districtId);
|
||||
return;
|
||||
}
|
||||
console.log('[App] Fetching UPN grid from API (district', districtId, ')...');
|
||||
const apiResponse = await getUpnGrid();
|
||||
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
|
||||
console.warn('[App] getUpnGrid invalid response:', apiResponse);
|
||||
return;
|
||||
}
|
||||
const rows = apiResponse.data;
|
||||
console.log('[App] UPN grid from API:', rows.length, 'cells');
|
||||
|
||||
await saveUpnGrid(rows, districtId);
|
||||
|
||||
const geojson = upnGridToGeoJSON(rows);
|
||||
if (geojson) setFeatures(geojson);
|
||||
console.log('[App] UPN grid loaded:', geojson?.features.length ?? 0, 'cells rendered');
|
||||
} catch (error) {
|
||||
console.error('[App] Failed to load UPN grid:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load collector zones with local-first strategy:
|
||||
* 1. Read from local collector_zones table → convert to GeoJSON → display
|
||||
@ -2153,6 +2333,14 @@ function addImportedGeoJSON(geojsonInput, fallbackName, tag) {
|
||||
layer.set('typeTag', 'GEO');
|
||||
importedFileLayers.push(layer);
|
||||
totalFeatures += fc.features.length;
|
||||
|
||||
// Stage the import into external_imports + external_import_features
|
||||
// (LUPMIS2_Import_Upload_Design.docx §3 + §4). The mapping modal opens
|
||||
// automatically; the layer gets tagged with its import_id so the
|
||||
// LayerSwitcher chip can find the row.
|
||||
stageImport(fc, layerName, layer).catch((err) =>
|
||||
console.warn('[FileImport] Staging failed (layer remains view-only):', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2173,6 +2361,224 @@ function addImportedGeoJSON(geojsonInput, fallbackName, tag) {
|
||||
refreshImportedLayersCard();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// External-dataset import staging (see LUPMIS2_Import_Upload_Design.docx)
|
||||
// ===========================================================================
|
||||
|
||||
const wktFormat4326 = new WKT();
|
||||
|
||||
/**
|
||||
* Convert an OL geometry (Map projection EPSG:3857) to a WKT string in
|
||||
* EPSG:4326 — the format the server expects.
|
||||
*/
|
||||
function geometryToWkt4326(geometry) {
|
||||
return wktFormat4326.writeGeometry(geometry, {
|
||||
dataProjection: 'EPSG:4326',
|
||||
featureProjection: 'EPSG:3857',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage an imported FeatureCollection into external_imports +
|
||||
* external_import_features, then open the mapping modal.
|
||||
*
|
||||
* 1. Create the external_imports row (target_type='other', status='other').
|
||||
* 2. Insert one external_import_features row per feature with the raw
|
||||
* properties (mapping is applied later, when the user confirms).
|
||||
* 3. Tag the OL layer with _externalImportId so the LayerSwitcher chip
|
||||
* can render its status.
|
||||
* 4. Open the mapping modal; on Save the row is updated with the chosen
|
||||
* target type and mapping, the staged-feature properties are remapped,
|
||||
* and (if requested) an upload is kicked off.
|
||||
*/
|
||||
async function stageImport(fc, displayName, layer) {
|
||||
const featureCount = fc?.features?.length ?? 0;
|
||||
if (featureCount === 0) return;
|
||||
|
||||
// ── 1. Create the staging row + features ────────────────────────────────
|
||||
const { id: importId } = await createExternalImport({
|
||||
filename: displayName || 'imported dataset',
|
||||
targetType: 'other',
|
||||
featureCount,
|
||||
});
|
||||
layer.set('_externalImportId', importId);
|
||||
|
||||
// Convert each OL feature to (WKT 4326 + raw source properties).
|
||||
const olFeatures = layer.getSource().getFeatures();
|
||||
const stagedRows = olFeatures.map((f) => {
|
||||
const geom = f.getGeometry();
|
||||
return {
|
||||
geometry_wkt: geom ? geometryToWkt4326(geom) : '',
|
||||
properties: stripGeometryFromProps(f.getProperties()),
|
||||
};
|
||||
});
|
||||
await addExternalImportFeatures(importId, stagedRows);
|
||||
|
||||
// ── 2. Open the mapping modal ───────────────────────────────────────────
|
||||
openImportMappingModal({
|
||||
importId,
|
||||
filename: displayName,
|
||||
fc,
|
||||
onResult: async (result) => {
|
||||
try {
|
||||
await handleImportModalResult(importId, layer, result);
|
||||
} catch (err) {
|
||||
console.error('[FileImport] Failed to apply mapping result:', err);
|
||||
showError('Could not save the import mapping: ' + err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** OL Feature#getProperties() includes the geometry; we don't want it as JSON. */
|
||||
function stripGeometryFromProps(props) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (k === 'geometry') continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the user's choice from the mapping modal:
|
||||
* cancel → keep as 'other' (view-only); no further action.
|
||||
* save → set target_type + mapping, status='mapped', remap staged props.
|
||||
* upload → same as save, then run the upload stub.
|
||||
*/
|
||||
async function handleImportModalResult(importId, layer, result) {
|
||||
if (!result || result.action === 'cancel') {
|
||||
layer?.set('_externalImportStatus', 'other');
|
||||
refreshLayerSwitcherChip(layer);
|
||||
return;
|
||||
}
|
||||
|
||||
const { action, targetType, mapping } = result;
|
||||
if (!targetType || targetType === 'other') {
|
||||
await updateExternalImport(importId, { targetType: 'other', mapping: null, status: 'other' });
|
||||
layer?.set('_externalImportStatus', 'other');
|
||||
refreshLayerSwitcherChip(layer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remap each staged feature's properties to LUPMIS2 column names. The
|
||||
// helper wraps every UPDATE in a single transaction.
|
||||
await remapImportedFeatureProperties(importId, (props) =>
|
||||
applyFieldMapping(props, mapping)
|
||||
);
|
||||
|
||||
await updateExternalImport(importId, { targetType, mapping, status: 'mapped' });
|
||||
layer?.set('_externalImportStatus', 'mapped');
|
||||
layer?.set('_externalImportTargetType', targetType);
|
||||
refreshLayerSwitcherChip(layer);
|
||||
|
||||
if (action === 'upload') {
|
||||
await runUpload(importId, layer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload stub. The server endpoints (upload_parcels.php, …) don't exist yet
|
||||
* (LUPMIS2_Import_Upload_Design.docx §5 is the proposal sent to the database
|
||||
* team). Until they're live we mark the row 'uploading' briefly so the chip
|
||||
* can flash a spinner, log the would-be payload for verification, then
|
||||
* revert to 'mapped' with a clear info toast — nothing is lost; the user
|
||||
* can retry once the endpoint exists.
|
||||
*/
|
||||
async function runUpload(importId, layer) {
|
||||
layer?.set('_externalImportStatus', 'uploading');
|
||||
refreshLayerSwitcherChip(layer);
|
||||
try {
|
||||
await updateExternalImport(importId, { status: 'uploading' });
|
||||
|
||||
const imp = await getExternalImport(importId);
|
||||
const features = await getExternalImportFeatures(importId);
|
||||
const session = getSession();
|
||||
|
||||
// Build the request body exactly as the server will receive it once
|
||||
// upload_<target_type>.php is live. district_id + api_token are merged
|
||||
// in by remotePost from API_CREDENTIALS; user_id_upload comes from the
|
||||
// SSO session (server may also derive it server-side — we send it for
|
||||
// logging/audit completeness as agreed with the database team).
|
||||
const body = {
|
||||
user_id_upload: session?.user_id ?? null,
|
||||
import: {
|
||||
client_import_id: imp.client_import_id,
|
||||
filename: imp.filename,
|
||||
feature_count: features.length,
|
||||
},
|
||||
features: features.map((f) => ({
|
||||
client_uuid: f.client_uuid,
|
||||
geom: f.geometry_wkt,
|
||||
props: f.properties,
|
||||
})),
|
||||
};
|
||||
|
||||
// ── TODO when the database team ships upload_<target_type>.php ────────
|
||||
// const apiResponse = await remotePost(`upload_${imp.target_type}.php`, body);
|
||||
// then walk apiResponse.results, update each feature's upload_status,
|
||||
// and flip the import row to 'submitted' (or 'failed' if any rows
|
||||
// failed). For now we just log the payload and roll back.
|
||||
console.log('[Upload]', {
|
||||
endpoint: `upload_${imp.target_type}.php (not yet available on the server)`,
|
||||
target_type: imp.target_type,
|
||||
body,
|
||||
});
|
||||
|
||||
await updateExternalImport(importId, {
|
||||
status: 'mapped',
|
||||
lastUploadedAt: new Date().toISOString(),
|
||||
});
|
||||
layer?.set('_externalImportStatus', 'mapped');
|
||||
refreshLayerSwitcherChip(layer);
|
||||
|
||||
showWarning(
|
||||
'The server upload endpoint is not yet available. ' +
|
||||
'The data stays staged locally — you can upload again later.'
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Upload] Stub failed:', err);
|
||||
layer?.set('_externalImportStatus', 'mapped');
|
||||
refreshLayerSwitcherChip(layer);
|
||||
showError('Upload preparation failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Export GIS button on the Area / Circle Analysis popup — MapView dispatches
|
||||
// this CustomEvent so main.js can own the format/rename modal + writers.
|
||||
window.addEventListener('lupmis:export-gis', (e) => {
|
||||
openExportGisModal(e.detail || {});
|
||||
});
|
||||
|
||||
// LayerSwitcher chip click — dispatched as a window CustomEvent by MapView's
|
||||
// _decorateLayerListItem. We only act on the 'mapped' state today (upload);
|
||||
// 'failed' will open an error-review modal once the server endpoints exist.
|
||||
window.addEventListener('lupmis:import-chip-click', (e) => {
|
||||
const { importId, status, layer } = e.detail || {};
|
||||
if (status === 'mapped') {
|
||||
runUpload(importId, layer).catch((err) =>
|
||||
console.error('[FileImport] runUpload failed:', err)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Force the LayerSwitcher row for this layer to re-render. ol-ext rebuilds
|
||||
* the panel on every render() call; the drawlist hook will read the new
|
||||
* _externalImportStatus property and render the appropriate chip.
|
||||
*/
|
||||
function refreshLayerSwitcherChip(layer) {
|
||||
if (!layer || !mapView) return;
|
||||
const switcher = mapView.getMap()
|
||||
?.getControls()
|
||||
?.getArray()
|
||||
?.find((c) => c?.constructor?.name === 'LayerSwitcher'
|
||||
|| c?.element?.classList?.contains('ol-layerswitcher'));
|
||||
if (switcher && typeof switcher.drawPanel === 'function') {
|
||||
switcher.drawPanel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the imported-layers info card in the left panel.
|
||||
*/
|
||||
|
||||
41
package-lock.json
generated
41
package-lock.json
generated
@ -15,6 +15,7 @@
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"ol": "^10.3.0",
|
||||
"ol-ext": "^4.0.24",
|
||||
"shp-write": "^0.3.2",
|
||||
"shpjs": "^6.2.0",
|
||||
"sqlocal": "^0.16.0"
|
||||
},
|
||||
@ -953,6 +954,15 @@
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dbf": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/dbf/-/dbf-0.1.4.tgz",
|
||||
"integrity": "sha512-7tQ8w5NB74PL1f0Z/NQ6Y+URjBFhtEsFxzEQSzot2+VpLwWfrNnxFVhzWm6dJyEtFq0WkYWcGEMDf39fy8JFaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"jdataview": "~2.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
@ -1105,6 +1115,11 @@
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jdataview": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/jdataview/-/jdataview-2.5.0.tgz",
|
||||
"integrity": "sha512-ZJop3D5nyDcWPBPv4NPnhCvx3HgQNsCXMfw8gpNKY16BobgxmVF+kJ08aHuqk6bJQVeL2mkf6nDCcZPMompalw=="
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||
@ -1131,6 +1146,21 @@
|
||||
"jspdf": "^2 || ^3 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-2.5.0.tgz",
|
||||
"integrity": "sha512-IRoyf8JSYY3nx+uyh5xPc0qdy8pUDTp2UkHOWYNF/IO/3D8nx7899UlSAjD8rf8wUgOmm0lACWx/GbW3EaxIXQ==",
|
||||
"license": "MIT or GPLv3",
|
||||
"dependencies": {
|
||||
"pako": "~0.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||
@ -1407,6 +1437,17 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/shp-write": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/shp-write/-/shp-write-0.3.2.tgz",
|
||||
"integrity": "sha512-RNmfm+qzIwgwGMiV21lCxfEAtgP/owAd+sHLr6Qu+aDR1bbrCZ42H89nA9FQWUqfL+WHJy3n8+cTZxJrL/ZKWA==",
|
||||
"deprecated": "This package has moved under the @mapbox organization, and can be found here: https://www.npmjs.com/package/@mapbox/shp-write",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dbf": "0.1.4",
|
||||
"jszip": "2.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shpjs": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"ol": "^10.3.0",
|
||||
"ol-ext": "^4.0.24",
|
||||
"shp-write": "^0.3.2",
|
||||
"shpjs": "^6.2.0",
|
||||
"sqlocal": "^0.16.0"
|
||||
},
|
||||
|
||||
@ -33,7 +33,13 @@
|
||||
// for the permitting integration; lu_parcels structural refactor in the
|
||||
// local DB; production access guard + no-district overlay; LayerSwitcher
|
||||
// ordering fix. New shell + hashed bundle.
|
||||
const CACHE_VERSION = 'v9';
|
||||
// v10: UPN-grid layer (cache + click popup + dashed-violet style + 1:25,000
|
||||
// label threshold); external-dataset import → staging → upload pipeline
|
||||
// (external_imports/_features tables, mapping modal, LayerSwitcher chip,
|
||||
// staged-upload payload with user_id_upload from SSO); GIS export from
|
||||
// Area/Circle Analysis popups (GeoJSON / SHP via shp-write / KML, with
|
||||
// field-rename modal). New hashed bundle + shp-write chunk.
|
||||
const CACHE_VERSION = 'v10';
|
||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||
|
||||
@ -1281,7 +1281,11 @@ export class MapView {
|
||||
${tableRows}
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding:2px 8px 8px;text-align:right;flex-shrink:0;border-top:1px solid var(--border, #1e1a4b1f);">
|
||||
<div style="padding:2px 8px 8px;display:flex;justify-content:flex-end;gap:6px;flex-shrink:0;border-top:1px solid var(--border, #1e1a4b1f);">
|
||||
<button id="info-popup-export-gis"
|
||||
style="background:var(--brand-navy,#1e1a4b);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
|
||||
🗺️ Export GIS
|
||||
</button>
|
||||
<button id="info-popup-export-pdf"
|
||||
style="background:var(--brand-navy,#1e1a4b);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
|
||||
📄 Export PDF
|
||||
@ -1290,9 +1294,20 @@ export class MapView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the analysis popup, attach close + PDF export handlers.
|
||||
* Show the analysis popup, attach close + PDF / GIS export handlers.
|
||||
*
|
||||
* @param {string} emoji
|
||||
* @param {string} title - e.g. "Area Analysis" / "Circle Analysis"
|
||||
* @param {Array} dataRows - summary rows (PDF uses these)
|
||||
* @param {number[]} coordinate - popup anchor
|
||||
* @param {Object} [exportContext] - features + clip geometry for GIS export.
|
||||
* When omitted, the Export GIS button is
|
||||
* disabled (no features to export).
|
||||
* { kind: 'area' | 'circle',
|
||||
* clipGeometry,
|
||||
* parcelFeatures, zoneFeatures, otherByLayer }
|
||||
*/
|
||||
_showAnalysisPopup(emoji, title, dataRows, coordinate) {
|
||||
_showAnalysisPopup(emoji, title, dataRows, coordinate, exportContext = null) {
|
||||
this.infoPopupElement.innerHTML = this._buildAnalysisPopupHtml(emoji, title, dataRows);
|
||||
this.infoPopup.setPosition(coordinate);
|
||||
|
||||
@ -1313,6 +1328,29 @@ export class MapView {
|
||||
console.error('[MapView] PDF export failed:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// GIS export — dispatches a window-level CustomEvent so main.js can open
|
||||
// the format/field-rename modal without MapView pulling in the writers.
|
||||
const gisBtn = this.infoPopupElement.querySelector('#info-popup-export-gis');
|
||||
if (gisBtn) {
|
||||
const total = exportContext
|
||||
? exportContext.parcelFeatures.length
|
||||
+ exportContext.zoneFeatures.length
|
||||
+ Object.values(exportContext.otherByLayer).reduce((s, arr) => s + arr.length, 0)
|
||||
: 0;
|
||||
if (!exportContext || total === 0) {
|
||||
gisBtn.disabled = true;
|
||||
gisBtn.style.opacity = '0.5';
|
||||
gisBtn.style.cursor = 'not-allowed';
|
||||
gisBtn.title = 'No intersecting features to export';
|
||||
} else {
|
||||
gisBtn.addEventListener('click', () => {
|
||||
window.dispatchEvent(new CustomEvent('lupmis:export-gis', {
|
||||
detail: { title, ...exportContext },
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showCircleIntersectionPopup(circleFeature, coordinate) {
|
||||
@ -1387,7 +1425,13 @@ export class MapView {
|
||||
...this._collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer),
|
||||
];
|
||||
|
||||
this._showAnalysisPopup('⭕', 'Circle Analysis', dataRows, coordinate);
|
||||
this._showAnalysisPopup('⭕', 'Circle Analysis', dataRows, coordinate, {
|
||||
kind: 'circle',
|
||||
clipGeometry: circlePoly,
|
||||
parcelFeatures,
|
||||
zoneFeatures,
|
||||
otherByLayer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1470,7 +1514,13 @@ export class MapView {
|
||||
...this._collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer),
|
||||
];
|
||||
|
||||
this._showAnalysisPopup('📐', 'Area Analysis', dataRows, coordinate);
|
||||
this._showAnalysisPopup('📐', 'Area Analysis', dataRows, coordinate, {
|
||||
kind: 'area',
|
||||
clipGeometry: polyGeom,
|
||||
parcelFeatures,
|
||||
zoneFeatures,
|
||||
otherByLayer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -3128,6 +3178,10 @@ export class MapView {
|
||||
* @param {string} [styleOptions.strokeColor='#3b82f6'] - Stroke color
|
||||
* @param {number} [styleOptions.strokeWidth=2] - Stroke width
|
||||
* @param {string} [styleOptions.fillColor='rgba(59,130,246,0.1)'] - Fill color
|
||||
* @param {number[]} [styleOptions.strokeDash] - Dash pattern for the stroke
|
||||
* (passed straight to ol/style/Stroke#lineDash, e.g. [4,4]). Useful for
|
||||
* contextual overlays (grids, draft outlines) so they read differently
|
||||
* from solid property boundaries.
|
||||
* @param {LayerGroup} [targetGroup] - Optional group to add the layer to
|
||||
* @returns {VectorLayer} The created layer
|
||||
*/
|
||||
@ -3135,6 +3189,7 @@ export class MapView {
|
||||
const {
|
||||
strokeColor = '#3b82f6',
|
||||
strokeWidth = 2,
|
||||
strokeDash = null,
|
||||
fillColor = 'rgba(59,130,246,0.1)',
|
||||
// Optional line "casing": a thicker darker stroke drawn UNDERNEATH the
|
||||
// main stroke. Used for road-like layers to make light-colored lines
|
||||
@ -3170,6 +3225,12 @@ export class MapView {
|
||||
// feature: the casing renders first (underneath), then the inner stroke.
|
||||
// For polygons the casing also outlines them; for points the casing has
|
||||
// no effect (Point geometries only render `image`).
|
||||
const mainStroke = new Stroke({
|
||||
color: strokeColor,
|
||||
width: strokeWidth,
|
||||
...(strokeDash ? { lineDash: strokeDash } : {}),
|
||||
});
|
||||
|
||||
let layerStyle;
|
||||
if (lineCasingColor) {
|
||||
const casingW = lineCasingWidth != null ? lineCasingWidth : strokeWidth + 2;
|
||||
@ -3178,14 +3239,14 @@ export class MapView {
|
||||
stroke: new Stroke({ color: lineCasingColor, width: casingW }),
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({ color: strokeColor, width: strokeWidth }),
|
||||
stroke: mainStroke,
|
||||
fill: fillStyle,
|
||||
image: pointStyle,
|
||||
}),
|
||||
];
|
||||
} else {
|
||||
layerStyle = new Style({
|
||||
stroke: new Stroke({ color: strokeColor, width: strokeWidth }),
|
||||
stroke: mainStroke,
|
||||
fill: fillStyle,
|
||||
image: pointStyle,
|
||||
});
|
||||
@ -3779,6 +3840,82 @@ export class MapView {
|
||||
btnBar.appendChild(removeBtn);
|
||||
}
|
||||
|
||||
// 5b. Import-state chip on layers staged via the external-dataset import
|
||||
// flow (LUPMIS2_Import_Upload_Design.docx §3.2). The chip's text and
|
||||
// behaviour depend on `_externalImportStatus`:
|
||||
// 'mapped' → "Upload N" (click → upload)
|
||||
// 'uploading' → spinner (disabled)
|
||||
// 'submitted' → "✓ submitted" (in upload_tmp, awaiting review)
|
||||
// 'migrated' → "✓ live" (supervisor promoted to lu_parcels)
|
||||
// 'failed' → "N errors — fix?"
|
||||
// 'other'/null→ no chip
|
||||
// Clicks dispatch a window-level CustomEvent so main.js can react
|
||||
// without MapView knowing anything about staging.
|
||||
const importId = layer.get('_externalImportId');
|
||||
if (importId != null) {
|
||||
const labelSpan = li.querySelector(':scope > .li-content > label > span');
|
||||
let chip = labelSpan ? labelSpan.querySelector(':scope > .ls-import-chip') : null;
|
||||
const status = layer.get('_externalImportStatus') || 'mapped';
|
||||
const featureCount = layer.getSource()?.getFeatures().length ?? 0;
|
||||
const errorCount = layer.get('_externalImportErrorCount') ?? 0;
|
||||
|
||||
// Decide chip appearance.
|
||||
const chipSpec = (() => {
|
||||
switch (status) {
|
||||
case 'mapped':
|
||||
return { text: `Upload ${featureCount}`, cls: 'ls-import-chip-mapped',
|
||||
title: 'Upload this dataset to the database', clickable: true };
|
||||
case 'uploading':
|
||||
return { text: '…', cls: 'ls-import-chip-uploading',
|
||||
title: 'Uploading…', clickable: false };
|
||||
case 'submitted':
|
||||
return { text: '✓ submitted', cls: 'ls-import-chip-submitted',
|
||||
title: 'Uploaded — awaiting supervisor review', clickable: false };
|
||||
case 'migrated':
|
||||
return { text: '✓ live', cls: 'ls-import-chip-migrated',
|
||||
title: 'Approved by supervisor and live on the server', clickable: false };
|
||||
case 'failed':
|
||||
return { text: `${errorCount} errors — fix?`, cls: 'ls-import-chip-failed',
|
||||
title: 'Some rows failed; click to review', clickable: true };
|
||||
case 'other':
|
||||
case null:
|
||||
case undefined:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!chipSpec) {
|
||||
if (chip) chip.remove();
|
||||
} else if (labelSpan) {
|
||||
if (!chip) {
|
||||
chip = document.createElement('span');
|
||||
chip.className = 'ls-import-chip';
|
||||
labelSpan.appendChild(chip);
|
||||
}
|
||||
chip.className = `ls-import-chip ${chipSpec.cls}`;
|
||||
chip.textContent = chipSpec.text;
|
||||
chip.title = chipSpec.title;
|
||||
chip.style.cursor = chipSpec.clickable ? 'pointer' : 'default';
|
||||
chip.style.opacity = chipSpec.clickable ? '1' : '0.85';
|
||||
|
||||
// Replace any prior listener by cloning the node.
|
||||
const fresh = chip.cloneNode(true);
|
||||
chip.replaceWith(fresh);
|
||||
chip = fresh;
|
||||
|
||||
if (chipSpec.clickable) {
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent('lupmis:import-chip-click', {
|
||||
detail: { importId, status, layer },
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. "+" button on the External Source group
|
||||
const groupTitle = (layer.get('title') || '').toLowerCase();
|
||||
if (groupTitle.includes('external')) {
|
||||
|
||||
350
src/database.js
350
src/database.js
@ -238,6 +238,21 @@ export async function initSchema() {
|
||||
)
|
||||
`;
|
||||
|
||||
// UPN-grid table — district sub-division grid (one cell per UPN prefix).
|
||||
// The grid is static per district, so the cache is populated once and only
|
||||
// refreshed when the user is associated with a different district. The
|
||||
// districtid column lets getLocalUpnGrid() detect that mismatch.
|
||||
console.log('[Database] Creating upn_grid table...');
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS upn_grid (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
districtid INTEGER,
|
||||
upn_prefix TEXT,
|
||||
geometry_wkt TEXT,
|
||||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
// ── GPS trails ──────────────────────────────────────────────────────
|
||||
// Recorded field-movement tracks. These are the local store for the
|
||||
// reusable GeoTracker module (src/geotracker/). `client_uuid` lets the
|
||||
@ -279,11 +294,60 @@ export async function initSchema() {
|
||||
)
|
||||
`;
|
||||
|
||||
// ── External imports ────────────────────────────────────────────────
|
||||
// Staging for shapefile / GeoJSON / KML imports before they are uploaded
|
||||
// to the server. See LUPMIS2_Import_Upload_Design.docx §4.
|
||||
// external_imports — one row per imported file (the dataset)
|
||||
// external_import_features — staged features (rows pending upload)
|
||||
//
|
||||
// Status state machine (aligns with the database team's staged-upload
|
||||
// model — uploads land in spatial.lu_parcels_upload_tmp first, then a
|
||||
// supervisor promotes them to the live spatial.lu_parcels via the
|
||||
// migrated flag):
|
||||
//
|
||||
// 'imported' → 'mapped' → 'uploading' → 'submitted' → 'migrated'
|
||||
// ↘ 'failed'
|
||||
//
|
||||
// 'other' is a terminal display-only state (user chose "Other / view only").
|
||||
// 'submitted' = row exists in upload_tmp, awaiting supervisor review.
|
||||
// 'migrated' = supervisor approved & promoted to the live table.
|
||||
console.log('[Database] Creating external_imports table...');
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS external_imports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL DEFAULT 'other',
|
||||
mapping_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'imported',
|
||||
feature_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_count INTEGER NOT NULL DEFAULT 0,
|
||||
client_import_id TEXT UNIQUE,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_uploaded_at TEXT
|
||||
)
|
||||
`;
|
||||
|
||||
console.log('[Database] Creating external_import_features table...');
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS external_import_features (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
import_id INTEGER NOT NULL REFERENCES external_imports(id) ON DELETE CASCADE,
|
||||
client_uuid TEXT UNIQUE,
|
||||
geometry_wkt TEXT NOT NULL,
|
||||
properties_json TEXT,
|
||||
upload_status TEXT NOT NULL DEFAULT 'pending',
|
||||
server_id INTEGER,
|
||||
error_message TEXT
|
||||
)
|
||||
`;
|
||||
|
||||
// Create indexes
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_gps_trails_synced ON gps_trails(synced, status)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_gps_trail_points_trail ON gps_trail_points(trail_id, seq)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_external_imports_status ON external_imports(status)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_external_import_features_import ON external_import_features(import_id, upload_status)`;
|
||||
|
||||
// Final verification
|
||||
const allTables = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
|
||||
@ -613,6 +677,279 @@ export async function getLocalCollectorZones() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPN Grid
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Save the UPN-grid for one district to the local table. Replaces any
|
||||
* previously cached grid (even if it was for a different district) — the
|
||||
* grid is static per district, so we only ever keep the current district's
|
||||
* cells locally.
|
||||
*
|
||||
* @param {Array} rows - Grid rows from the API
|
||||
* (each: { polygon, districtid, upn_prefix })
|
||||
* @param {number|string} districtId - The current district id
|
||||
*/
|
||||
export async function saveUpnGrid(rows, districtId) {
|
||||
try {
|
||||
await sql`BEGIN`;
|
||||
await sql`DELETE FROM upn_grid`;
|
||||
let saved = 0;
|
||||
for (const r of rows) {
|
||||
const wkt = r.polygon || r.geometry_wkt || r.geom || '';
|
||||
await sql`
|
||||
INSERT INTO upn_grid (districtid, upn_prefix, geometry_wkt, fetched_at)
|
||||
VALUES (${numOrNull(districtId)}, ${r.upn_prefix ?? null}, ${wkt}, CURRENT_TIMESTAMP)
|
||||
`;
|
||||
saved++;
|
||||
}
|
||||
await sql`COMMIT`;
|
||||
console.log('[Database] ✓ Saved', saved, 'UPN-grid cells (district', districtId, ')');
|
||||
} catch (error) {
|
||||
try { await sql`ROLLBACK`; } catch { /* no active txn */ }
|
||||
console.error('[Database] ✗ Failed to save UPN grid:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the UPN-grid rows that belong to the given district. Returns null
|
||||
* if no cells are cached for that district — the caller should then fetch
|
||||
* from the API and call saveUpnGrid().
|
||||
*
|
||||
* @param {number|string} districtId
|
||||
* @returns {Promise<Array|null>}
|
||||
*/
|
||||
export async function getLocalUpnGrid(districtId) {
|
||||
try {
|
||||
const rows = await sql`
|
||||
SELECT id, districtid, upn_prefix, geometry_wkt
|
||||
FROM upn_grid
|
||||
WHERE districtid = ${numOrNull(districtId)}
|
||||
ORDER BY id
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to read UPN grid:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External imports (staging for shapefile / GeoJSON / KML uploads)
|
||||
// See LUPMIS2_Import_Upload_Design.docx §3 & §4.
|
||||
// ============================================================================
|
||||
|
||||
/** Tiny RFC-4122-ish UUIDv4 (uses crypto.randomUUID when available). */
|
||||
function uuidv4() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for older browsers
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new staging row for an imported dataset. Returns the import_id.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.filename — original filename
|
||||
* @param {string} [opts.targetType] — initial target ('other' by default)
|
||||
* @param {number} [opts.featureCount] — number of features being staged
|
||||
* @returns {Promise<{ id: number, client_import_id: string }>}
|
||||
*/
|
||||
export async function createExternalImport(opts) {
|
||||
const { filename, targetType = 'other', featureCount = 0 } = opts;
|
||||
const clientImportId = uuidv4();
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO external_imports
|
||||
(filename, target_type, status, feature_count, client_import_id)
|
||||
VALUES
|
||||
(${filename}, ${targetType},
|
||||
${targetType === 'other' ? 'other' : 'imported'},
|
||||
${featureCount}, ${clientImportId})
|
||||
`;
|
||||
const idRow = await sql`SELECT last_insert_rowid() AS id`;
|
||||
const id = idRow[0]?.id;
|
||||
broadcastChange('external_imports', 'INSERT', id);
|
||||
return { id, client_import_id: clientImportId };
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to create external import:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-insert staging features for an import. Wraps the inserts in a single
|
||||
* transaction (the upload payload is typically dozens to thousands of rows).
|
||||
*
|
||||
* @param {number} importId
|
||||
* @param {Array<{ geometry_wkt: string, properties: Object }>} features
|
||||
*/
|
||||
export async function addExternalImportFeatures(importId, features) {
|
||||
if (!Array.isArray(features) || features.length === 0) return 0;
|
||||
try {
|
||||
await sql`BEGIN`;
|
||||
let inserted = 0;
|
||||
for (const f of features) {
|
||||
const wkt = f.geometry_wkt || '';
|
||||
if (!wkt) continue;
|
||||
const propsJson = JSON.stringify(f.properties ?? {});
|
||||
const cuuid = f.client_uuid || uuidv4();
|
||||
await sql`
|
||||
INSERT INTO external_import_features
|
||||
(import_id, client_uuid, geometry_wkt, properties_json, upload_status)
|
||||
VALUES
|
||||
(${importId}, ${cuuid}, ${wkt}, ${propsJson}, 'pending')
|
||||
`;
|
||||
inserted++;
|
||||
}
|
||||
await sql`COMMIT`;
|
||||
broadcastChange('external_import_features', 'INSERT', importId);
|
||||
return inserted;
|
||||
} catch (error) {
|
||||
try { await sql`ROLLBACK`; } catch { /* no active txn */ }
|
||||
console.error('[Database] ✗ Failed to add import features:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update target type + mapping + status for a staged import. Pass any
|
||||
* subset of fields. Useful targets:
|
||||
* updateExternalImport(id, { targetType: 'parcels', mapping: {...},
|
||||
* status: 'mapped' })
|
||||
*/
|
||||
export async function updateExternalImport(importId, patch = {}) {
|
||||
try {
|
||||
const cur = await sql`SELECT * FROM external_imports WHERE id = ${importId}`;
|
||||
if (cur.length === 0) throw new Error(`Import ${importId} not found`);
|
||||
const row = cur[0];
|
||||
|
||||
const targetType = patch.targetType ?? row.target_type;
|
||||
const mappingJson = patch.mapping !== undefined
|
||||
? (patch.mapping ? JSON.stringify(patch.mapping) : null)
|
||||
: row.mapping_json;
|
||||
const status = patch.status ?? row.status;
|
||||
const errorCount = patch.errorCount ?? row.error_count;
|
||||
const lastUploadedAt = patch.lastUploadedAt ?? row.last_uploaded_at;
|
||||
|
||||
await sql`
|
||||
UPDATE external_imports SET
|
||||
target_type = ${targetType},
|
||||
mapping_json = ${mappingJson},
|
||||
status = ${status},
|
||||
error_count = ${errorCount},
|
||||
last_uploaded_at = ${lastUploadedAt}
|
||||
WHERE id = ${importId}
|
||||
`;
|
||||
broadcastChange('external_imports', 'UPDATE', importId);
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to update external import:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get an external_imports row by id (parsed mapping_json). */
|
||||
export async function getExternalImport(importId) {
|
||||
try {
|
||||
const rows = await sql`SELECT * FROM external_imports WHERE id = ${importId}`;
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
return { ...row, mapping: row.mapping_json ? JSON.parse(row.mapping_json) : null };
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to read external import:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** List every external import, newest first. */
|
||||
export async function listExternalImports() {
|
||||
try {
|
||||
return await sql`SELECT * FROM external_imports ORDER BY id DESC`;
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to list external imports:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the staging features for an import (parsed properties_json). */
|
||||
export async function getExternalImportFeatures(importId) {
|
||||
try {
|
||||
const rows = await sql`
|
||||
SELECT id, client_uuid, geometry_wkt, properties_json,
|
||||
upload_status, server_id, error_message
|
||||
FROM external_import_features
|
||||
WHERE import_id = ${importId}
|
||||
ORDER BY id
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
properties: r.properties_json ? JSON.parse(r.properties_json) : {},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to read import features:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-rewrite the properties_json of every staged feature for an import.
|
||||
* Used after the user confirms a field-mapping: the caller passes a function
|
||||
* that receives each row's parsed properties and returns the remapped ones.
|
||||
* All updates run in a single transaction.
|
||||
*
|
||||
* @param {number} importId
|
||||
* @param {(props: Object) => Object} remap
|
||||
*/
|
||||
export async function remapImportedFeatureProperties(importId, remap) {
|
||||
try {
|
||||
const rows = await sql`
|
||||
SELECT id, properties_json
|
||||
FROM external_import_features
|
||||
WHERE import_id = ${importId}
|
||||
`;
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
await sql`BEGIN`;
|
||||
let n = 0;
|
||||
for (const r of rows) {
|
||||
const before = r.properties_json ? JSON.parse(r.properties_json) : {};
|
||||
const after = remap(before) ?? {};
|
||||
await sql`
|
||||
UPDATE external_import_features
|
||||
SET properties_json = ${JSON.stringify(after)}
|
||||
WHERE id = ${r.id}
|
||||
`;
|
||||
n++;
|
||||
}
|
||||
await sql`COMMIT`;
|
||||
broadcastChange('external_import_features', 'UPDATE', importId);
|
||||
return n;
|
||||
} catch (error) {
|
||||
try { await sql`ROLLBACK`; } catch { /* */ }
|
||||
console.error('[Database] ✗ Failed to remap import features:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a staged import (and its features via CASCADE). */
|
||||
export async function deleteExternalImport(importId) {
|
||||
try {
|
||||
await sql`DELETE FROM external_imports WHERE id = ${importId}`;
|
||||
broadcastChange('external_imports', 'DELETE', importId);
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to delete external import:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parcels
|
||||
// ============================================================================
|
||||
@ -999,6 +1336,7 @@ export const CACHED_LAYER_TABLES = Object.freeze([
|
||||
'building_footprints',
|
||||
'osm_roads',
|
||||
'collector_zones',
|
||||
'upn_grid',
|
||||
'remote_data',
|
||||
]);
|
||||
|
||||
@ -1042,7 +1380,7 @@ export async function clearAllCachedLayers() {
|
||||
const existing = await sql`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name IN (
|
||||
'parcels', 'building_footprints', 'osm_roads', 'collector_zones', 'remote_data'
|
||||
'parcels', 'building_footprints', 'osm_roads', 'collector_zones', 'upn_grid', 'remote_data'
|
||||
)
|
||||
`;
|
||||
const existingNames = new Set(existing.map((r) => r.name));
|
||||
@ -1284,6 +1622,16 @@ export default {
|
||||
getRemoteData,
|
||||
saveCollectorZones,
|
||||
getLocalCollectorZones,
|
||||
saveUpnGrid,
|
||||
getLocalUpnGrid,
|
||||
createExternalImport,
|
||||
addExternalImportFeatures,
|
||||
updateExternalImport,
|
||||
getExternalImport,
|
||||
listExternalImports,
|
||||
getExternalImportFeatures,
|
||||
remapImportedFeatureProperties,
|
||||
deleteExternalImport,
|
||||
saveParcels,
|
||||
getLocalParcels,
|
||||
updateParcel,
|
||||
|
||||
206
src/export-gis-modal.js
Normal file
206
src/export-gis-modal.js
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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); }
|
||||
233
src/gis-export.js
Normal file
233
src/gis-export.js
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
250
src/import-detect.js
Normal file
250
src/import-detect.js
Normal file
@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Target-type auto-detect + field auto-mapping for imported FeatureCollections.
|
||||
*
|
||||
* Pure helpers — no DOM, no map, no DB. The mapping modal calls these once
|
||||
* after the file has been parsed to seed its initial state, and re-uses them
|
||||
* whenever the user changes target type so the field map updates accordingly.
|
||||
*
|
||||
* Contract per LUPMIS2_Import_Upload_Design.docx §3.
|
||||
*
|
||||
* detectTargetType(fc) → 'parcels'|'collector_zones'|'osm_roads'
|
||||
* |'building_footprints'|'other'
|
||||
* autoMapFields(fc, targetType) → { [lupmisColumn]: sourceField | null }
|
||||
* TARGET_TYPES → metadata used by the modal dropdown
|
||||
* TARGET_FIELDS[targetType] → ordered list of LUPMIS2 columns the
|
||||
* upload payload must populate
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target-type registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TARGET_TYPES = [
|
||||
{ key: 'parcels', label: 'Parcels', geometryFamily: 'polygon' },
|
||||
{ key: 'collector_zones', label: 'Collector Zones', geometryFamily: 'polygon' },
|
||||
{ key: 'building_footprints', label: 'Building Footprints', geometryFamily: 'polygon' },
|
||||
{ key: 'osm_roads', label: 'OSM Roads', geometryFamily: 'line' },
|
||||
{ key: 'other', label: 'Other (view only)', geometryFamily: 'any' },
|
||||
];
|
||||
|
||||
// LUPMIS2 column lists per target type, in the order the mapping modal
|
||||
// should display them. Subset of the typed-table schemas the user actually
|
||||
// edits — system columns (id, created_at, updated_at, fetched_at, status)
|
||||
// are NOT user-mapped.
|
||||
export const TARGET_FIELDS = {
|
||||
parcels: [
|
||||
'upn', 'landuse', 'zone_code', 'zone_name', 'sector', 'block',
|
||||
'parcel_no', 'prop_no', 'st_name', 'prop_add', 'fac_name',
|
||||
'min_height', 'max_height', 'eff_date', 'lp_name', 'locality',
|
||||
'mmda', 'last_update', 'remarks',
|
||||
],
|
||||
collector_zones: [
|
||||
'zone_name',
|
||||
],
|
||||
building_footprints: [
|
||||
// The remote table is a thin schema today; we leave room for the
|
||||
// database team to add columns later (the modal will accept more).
|
||||
],
|
||||
osm_roads: [
|
||||
'osm_id', 'name', 'highway',
|
||||
],
|
||||
other: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-detect signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inspect every feature's geometry type. We return the dominant family
|
||||
* because shapefiles can sometimes mix (rare but possible).
|
||||
*
|
||||
* @returns {'polygon'|'line'|'point'|'mixed'|'none'}
|
||||
*/
|
||||
function dominantGeometryFamily(fc) {
|
||||
if (!fc?.features?.length) return 'none';
|
||||
let polys = 0, lines = 0, points = 0;
|
||||
for (const f of fc.features) {
|
||||
const t = f?.geometry?.type;
|
||||
if (!t) continue;
|
||||
if (t === 'Polygon' || t === 'MultiPolygon') polys++;
|
||||
else if (t === 'LineString' || t === 'MultiLineString') lines++;
|
||||
else if (t === 'Point' || t === 'MultiPoint') points++;
|
||||
}
|
||||
const total = polys + lines + points;
|
||||
if (total === 0) return 'none';
|
||||
const max = Math.max(polys, lines, points);
|
||||
if (max < total * 0.85) return 'mixed';
|
||||
if (polys === max) return 'polygon';
|
||||
if (lines === max) return 'line';
|
||||
return 'point';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a normalised attribute-name set across the first N features (so
|
||||
* inconsistent rows don't skew detection).
|
||||
*/
|
||||
function attributeFingerprint(fc, sampleSize = 50) {
|
||||
const set = new Set();
|
||||
const n = Math.min(sampleSize, fc.features.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const props = fc.features[i]?.properties;
|
||||
if (!props || typeof props !== 'object') continue;
|
||||
for (const key of Object.keys(props)) {
|
||||
set.add(String(key).toLowerCase());
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/** Does the fingerprint contain at least one of the given candidates? */
|
||||
function has(fp, ...candidates) {
|
||||
for (const c of candidates) if (fp.has(c)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a target type based on geometry family + attribute fingerprint.
|
||||
* Conservative: returns 'other' whenever the signal isn't strong enough,
|
||||
* leaving the user to choose explicitly.
|
||||
*/
|
||||
export function detectTargetType(fc) {
|
||||
const family = dominantGeometryFamily(fc);
|
||||
if (family === 'none' || family === 'mixed') return 'other';
|
||||
const fp = attributeFingerprint(fc);
|
||||
|
||||
if (family === 'line') {
|
||||
if (has(fp, 'osm_id', 'highway')) return 'osm_roads';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
if (family === 'polygon') {
|
||||
// Parcels are the most attribute-rich polygon: UPN is the strongest tell.
|
||||
if (has(fp, 'upn', 'parcel_no', 'landuse', 'lu_code', 'zone_code') &&
|
||||
// a single "zone_name" without UPN is more likely a zone overlay
|
||||
has(fp, 'upn', 'parcel_no', 'landuse', 'lu_code')) {
|
||||
return 'parcels';
|
||||
}
|
||||
if (has(fp, 'zone_name', 'colzonename', 'colzonenr')) return 'collector_zones';
|
||||
if (has(fp, 'building', 'building:levels', 'building_levels', 'height',
|
||||
'min_height', 'max_height') &&
|
||||
!has(fp, 'upn', 'parcel_no')) {
|
||||
return 'building_footprints';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Points have no upload target in the current design.
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field auto-mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-LUPMIS2-column candidate source field names. Lower-case, ordered by
|
||||
* preference. Matching is case-insensitive and ignores spaces / underscores
|
||||
* / hyphens.
|
||||
*/
|
||||
const FIELD_CANDIDATES = {
|
||||
// Parcels (lu_parcels)
|
||||
upn: ['upn', 'unique_parcel_no', 'parcel_id', 'pid'],
|
||||
landuse: ['landuse', 'land_use', 'lu', 'lu_code'],
|
||||
zone_code: ['zone_code', 'zonecode', 'zone'],
|
||||
zone_name: ['zone_name', 'zonename', 'colzonename'],
|
||||
sector: ['sector', 'sec'],
|
||||
block: ['block', 'blk'],
|
||||
parcel_no: ['parcel_no', 'parcelno', 'plot_no', 'plotno'],
|
||||
prop_no: ['prop_no', 'propertyno', 'property_no'],
|
||||
st_name: ['st_name', 'street', 'street_name', 'road'],
|
||||
prop_add: ['prop_add', 'address', 'addr'],
|
||||
fac_name: ['fac_name', 'facility', 'facilityname'],
|
||||
min_height: ['min_height', 'minheight', 'h_min'],
|
||||
max_height: ['max_height', 'maxheight', 'h_max', 'height'],
|
||||
eff_date: ['eff_date', 'effectivedate', 'effdate'],
|
||||
lp_name: ['lp_name', 'lpname', 'localplan'],
|
||||
locality: ['locality', 'town', 'settlement'],
|
||||
mmda: ['mmda', 'district', 'assembly'],
|
||||
last_update:['last_update', 'lastupdate', 'updated'],
|
||||
remarks: ['remarks', 'notes', 'comments'],
|
||||
|
||||
// OSM roads
|
||||
osm_id: ['osm_id', 'osmid', 'id'],
|
||||
name: ['name', 'street_name', 'st_name'],
|
||||
highway: ['highway', 'road_class', 'class'],
|
||||
};
|
||||
|
||||
/** Normalise a field name for matching (lowercase, strip _ - and spaces). */
|
||||
function normaliseKey(s) {
|
||||
return String(s).toLowerCase().replace(/[\s_\-]+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a source-field → LUPMIS2-column mapping for the given target type.
|
||||
* Unmapped columns get null so the modal can show "(none)" and let the user
|
||||
* pick from a dropdown of available source fields.
|
||||
*
|
||||
* @param {Object} fc — parsed FeatureCollection
|
||||
* @param {string} targetType — one of TARGET_TYPES keys
|
||||
* @returns {Object<string, string|null>} { [lupmisColumn]: sourceField|null }
|
||||
*/
|
||||
export function autoMapFields(fc, targetType) {
|
||||
const columns = TARGET_FIELDS[targetType] || [];
|
||||
if (columns.length === 0) return {};
|
||||
|
||||
const fp = attributeFingerprint(fc, 50);
|
||||
// Build normalised → original lookup for available source fields
|
||||
const sourceByNorm = new Map();
|
||||
for (const key of fp) {
|
||||
sourceByNorm.set(normaliseKey(key), key);
|
||||
}
|
||||
|
||||
const mapping = {};
|
||||
for (const col of columns) {
|
||||
const candidates = FIELD_CANDIDATES[col] || [col];
|
||||
let matched = null;
|
||||
for (const cand of candidates) {
|
||||
const n = normaliseKey(cand);
|
||||
if (sourceByNorm.has(n)) {
|
||||
matched = sourceByNorm.get(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
mapping[col] = matched;
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a mapping to one source-feature properties object and return the
|
||||
* LUPMIS2-shaped properties (column-name keys). Source fields not in the
|
||||
* mapping are dropped. Used during the staging-row insert.
|
||||
*/
|
||||
export function applyFieldMapping(sourceProps, mapping) {
|
||||
const out = {};
|
||||
for (const [targetCol, sourceKey] of Object.entries(mapping || {})) {
|
||||
if (sourceKey == null) continue;
|
||||
if (sourceProps && Object.prototype.hasOwnProperty.call(sourceProps, sourceKey)) {
|
||||
out[targetCol] = sourceProps[sourceKey];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of distinct source-property names (the union across all
|
||||
* features), preserving discovery order — used to populate the per-column
|
||||
* dropdown in the mapping modal.
|
||||
*/
|
||||
export function listSourceFields(fc) {
|
||||
const seen = new Map();
|
||||
for (const f of fc.features || []) {
|
||||
const props = f?.properties;
|
||||
if (!props || typeof props !== 'object') continue;
|
||||
for (const k of Object.keys(props)) {
|
||||
if (!seen.has(k)) seen.set(k, true);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.keys());
|
||||
}
|
||||
221
src/import-modal.js
Normal file
221
src/import-modal.js
Normal file
@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Import-mapping modal controller.
|
||||
*
|
||||
* openImportMappingModal({ importId, filename, fc, onResult })
|
||||
*
|
||||
* Populates the modal from the parsed FeatureCollection, lets the user pick
|
||||
* a target type and adjust the field map, then calls onResult with one of:
|
||||
*
|
||||
* { action: 'cancel' } — keep as Other / view only
|
||||
* { action: 'save', targetType, mapping }
|
||||
* { action: 'upload', targetType, mapping }
|
||||
*
|
||||
* The caller is responsible for updating external_imports + the staged
|
||||
* features (this module knows nothing about the DB or the map).
|
||||
*
|
||||
* See LUPMIS2_Import_Upload_Design.docx §3.1.
|
||||
*/
|
||||
|
||||
import { Modal } from 'bootstrap';
|
||||
import {
|
||||
TARGET_TYPES,
|
||||
TARGET_FIELDS,
|
||||
detectTargetType,
|
||||
autoMapFields,
|
||||
listSourceFields,
|
||||
} from './import-detect.js';
|
||||
|
||||
const els = {}; // cached DOM lookups
|
||||
let modal = null;
|
||||
let state = null; // { importId, filename, fc, sourceFields, mapping, targetType, onResult }
|
||||
|
||||
function cacheEls() {
|
||||
if (els.root) return;
|
||||
els.root = document.getElementById('importMappingModal');
|
||||
els.filename = document.getElementById('import-modal-filename');
|
||||
els.summary = document.getElementById('import-modal-summary');
|
||||
els.target = document.getElementById('import-modal-target');
|
||||
els.targetHint = document.getElementById('import-modal-target-hint');
|
||||
els.fieldsWrap = document.getElementById('import-modal-fields-wrap');
|
||||
els.tbody = document.getElementById('import-modal-fields-tbody');
|
||||
els.btnSave = document.getElementById('import-modal-save');
|
||||
els.btnSaveUpload = document.getElementById('import-modal-save-upload');
|
||||
els.btnCancel = document.getElementById('import-modal-cancel');
|
||||
|
||||
// Populate the target-type dropdown once.
|
||||
if (els.target && !els.target.dataset.populated) {
|
||||
els.target.innerHTML = TARGET_TYPES
|
||||
.map((t) => `<option value="${t.key}">${t.label}</option>`)
|
||||
.join('');
|
||||
els.target.dataset.populated = '1';
|
||||
}
|
||||
|
||||
// Event wiring (idempotent).
|
||||
if (els.target && !els.target.dataset.wired) {
|
||||
els.target.dataset.wired = '1';
|
||||
els.target.addEventListener('change', onTargetChange);
|
||||
}
|
||||
if (els.btnSave && !els.btnSave.dataset.wired) {
|
||||
els.btnSave.dataset.wired = '1';
|
||||
els.btnSave.addEventListener('click', () => finish('save'));
|
||||
}
|
||||
if (els.btnSaveUpload && !els.btnSaveUpload.dataset.wired) {
|
||||
els.btnSaveUpload.dataset.wired = '1';
|
||||
els.btnSaveUpload.addEventListener('click', () => finish('upload'));
|
||||
}
|
||||
// Cancel uses Bootstrap's data-bs-dismiss; we hook the hidden event so
|
||||
// closing via × / ESC / Cancel all behave the same: a 'cancel' result.
|
||||
if (els.root && !els.root.dataset.wired) {
|
||||
els.root.dataset.wired = '1';
|
||||
els.root.addEventListener('hidden.bs.modal', () => {
|
||||
if (state?.onResult && !state._resolved) {
|
||||
state._resolved = true;
|
||||
state.onResult({ action: 'cancel' });
|
||||
}
|
||||
state = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the field-mapping table for the current targetType. */
|
||||
function renderFieldsTable() {
|
||||
const targetType = state.targetType;
|
||||
const columns = TARGET_FIELDS[targetType] || [];
|
||||
|
||||
// "Other (view only)" → no fields to map.
|
||||
if (targetType === 'other' || columns.length === 0) {
|
||||
els.fieldsWrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
els.fieldsWrap.style.display = '';
|
||||
|
||||
const optionsHtml = ['<option value="">(none)</option>']
|
||||
.concat(state.sourceFields.map((s) =>
|
||||
`<option value="${escapeAttr(s)}">${escapeHtml(s)}</option>`))
|
||||
.join('');
|
||||
|
||||
els.tbody.innerHTML = columns.map((col) => {
|
||||
const current = state.mapping[col] || '';
|
||||
const select = optionsHtml.replace(
|
||||
`<option value="${escapeAttr(current)}">`,
|
||||
`<option value="${escapeAttr(current)}" selected>`
|
||||
);
|
||||
return `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(col)}</code></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm import-field-map"
|
||||
data-col="${escapeAttr(col)}">
|
||||
${select}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Update state.mapping when a row's source field changes.
|
||||
els.tbody.querySelectorAll('.import-field-map').forEach((sel) => {
|
||||
sel.addEventListener('change', (e) => {
|
||||
const col = e.target.dataset.col;
|
||||
state.mapping[col] = e.target.value || null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Handler: target dropdown changed. */
|
||||
function onTargetChange() {
|
||||
const newType = els.target.value;
|
||||
state.targetType = newType;
|
||||
state.mapping = autoMapFields(state.fc, newType);
|
||||
|
||||
// Target-type hint: gentle reminder when "other" is chosen.
|
||||
if (newType === 'other') {
|
||||
els.targetHint.innerHTML =
|
||||
'<em>This dataset will be visible on the map but cannot be uploaded ' +
|
||||
'to the database. You can change the type later.</em>';
|
||||
els.btnSave.disabled = false; // saves the "other" state
|
||||
els.btnSaveUpload.disabled = true;
|
||||
} else {
|
||||
els.targetHint.innerHTML =
|
||||
'Each LUPMIS2 column is matched to a source field where possible. ' +
|
||||
'You can override any choice below.';
|
||||
els.btnSave.disabled = false;
|
||||
els.btnSaveUpload.disabled = false;
|
||||
}
|
||||
renderFieldsTable();
|
||||
}
|
||||
|
||||
/** Handler: Save or Save+Upload clicked. */
|
||||
function finish(action) {
|
||||
if (!state || state._resolved) return;
|
||||
state._resolved = true;
|
||||
const { targetType, mapping, onResult } = state;
|
||||
modal.hide();
|
||||
if (onResult) {
|
||||
onResult({
|
||||
action, // 'save' | 'upload'
|
||||
targetType,
|
||||
mapping: targetType === 'other' ? null : { ...mapping },
|
||||
});
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open the modal for a fresh import. Resolves via onResult callback.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {number} opts.importId — staging row id (purely for the caller)
|
||||
* @param {string} opts.filename
|
||||
* @param {Object} opts.fc — parsed FeatureCollection
|
||||
* @param {Function} opts.onResult — see module header
|
||||
*/
|
||||
export function openImportMappingModal(opts) {
|
||||
cacheEls();
|
||||
if (!els.root) {
|
||||
console.warn('[ImportModal] Modal element missing — calling onResult with cancel');
|
||||
opts.onResult?.({ action: 'cancel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fc = opts.fc;
|
||||
const featureCount = fc?.features?.length ?? 0;
|
||||
const targetType = detectTargetType(fc);
|
||||
|
||||
state = {
|
||||
importId: opts.importId,
|
||||
filename: opts.filename,
|
||||
fc,
|
||||
sourceFields: listSourceFields(fc),
|
||||
targetType,
|
||||
mapping: autoMapFields(fc, targetType),
|
||||
onResult: opts.onResult,
|
||||
_resolved: false,
|
||||
};
|
||||
|
||||
// Header summary
|
||||
els.filename.textContent = opts.filename || 'imported dataset';
|
||||
els.summary.textContent = `— ${featureCount} feature${featureCount === 1 ? '' : 's'}`;
|
||||
|
||||
// Initial dropdown + hint + fields
|
||||
els.target.value = state.targetType;
|
||||
onTargetChange();
|
||||
|
||||
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); }
|
||||
@ -376,6 +376,28 @@ export async function getOSMRoads() {
|
||||
return remotePost('get_osm_roads.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the UPN-grid (district sub-division) for the current district.
|
||||
*
|
||||
* The grid is static per district, so callers cache it locally and only
|
||||
* re-fetch when the user becomes associated with a different district
|
||||
* (see saveUpnGrid / getLocalUpnGrid in database.js and loadUpnGrid in
|
||||
* main.js).
|
||||
*
|
||||
* Source: spatial.upn_grid; endpoint runs ST_AsText(geom_4326) AS polygon.
|
||||
*
|
||||
* Expected response:
|
||||
* { success: true,
|
||||
* data: [{ polygon: "POLYGON((...))" | "MULTIPOLYGON(((...)))",
|
||||
* districtid: 122,
|
||||
* upn_prefix: "AB" }, ...] }
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getUpnGrid() {
|
||||
return remotePost('get_upn_grid_per_district.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a recorded GPS trail (with all its points) to the server.
|
||||
*
|
||||
@ -467,5 +489,6 @@ export default {
|
||||
getBuildingFootprints,
|
||||
getContoursHillshade,
|
||||
getOSMRoads,
|
||||
getUpnGrid,
|
||||
pushGpsTrail,
|
||||
};
|
||||
|
||||
@ -355,6 +355,32 @@
|
||||
.ls-type-tag-geo { background: rgba(0, 107, 63, 0.13); color: #006b3f; }
|
||||
.ls-type-tag-base { background: rgba(30, 26, 75, 0.08); color: #1e1a4b; }
|
||||
|
||||
/* Import-state chip — rendered on layers that came in via a shp/GeoJSON/KML
|
||||
import (see LUPMIS2_Import_Upload_Design.docx §3.2). Sits to the right of
|
||||
the type-tag chip on the same row. Click behaviour is wired in MapView's
|
||||
_decorateLayerListItem and in main.js. */
|
||||
.ls-import-chip {
|
||||
display: inline-block;
|
||||
font-family: var(--ls-font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
}
|
||||
.ls-import-chip-mapped { background: rgba(0, 94, 184, 0.16); color: #005eb8; }
|
||||
.ls-import-chip-mapped:hover { background: rgba(0, 94, 184, 0.26); }
|
||||
.ls-import-chip-uploading { background: rgba(107, 114, 128, 0.18); color: #4b5563; }
|
||||
/* submitted = row exists in lu_parcels_upload_tmp, awaiting supervisor review */
|
||||
.ls-import-chip-submitted { background: rgba(245, 158, 11, 0.18); color: #b45309; }
|
||||
/* migrated = supervisor approved and promoted to the live lu_parcels table */
|
||||
.ls-import-chip-migrated { background: rgba(16, 185, 129, 0.18); color: #058a4e; }
|
||||
.ls-import-chip-failed { background: rgba(220, 38, 38, 0.18); color: #b91c1c; }
|
||||
.ls-import-chip-failed:hover { background: rgba(220, 38, 38, 0.28); }
|
||||
|
||||
/* Header "active count" badge — sits at top of panel-container, before the <ul> */
|
||||
.ls-active-badge {
|
||||
flex: 0 0 auto;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user