Compare commits

..

No commits in common. "4edb0a2f37005e3591760baf5cf93c1d97623374" and "4d594f58e511d1a3e719cddc13cd74b62dbe3506" have entirely different histories.

75 changed files with 1973 additions and 8222 deletions

4
.gitignore vendored
View File

@ -105,7 +105,3 @@ coverage/
# Microsoft Office lock / owner files (e.g. ~$Document.docx)
~$*
# Python bytecode (etl/ scripts)
__pycache__/
*.pyc

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,67 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1680" height="960" viewBox="0 0 1680 960" font-family="Calibri, Arial, sans-serif">
<!-- Figure 1 of LUPMIS2_Analytical_Tools_Concept.docx, also slide 4 of the TWG deck.
Rendered at 1680 px across ~10.6 in of slide (~158 px/in), so 44 px is about 20 pt.
Labels are deliberately short so the diagram stays legible when projected.
Re-render: rsvg-convert -w 1680 -h 960 this.svg -o out.png -->
<defs>
<marker id="arr" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#1E1A4B"/></marker>
<marker id="arrG" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#10B981"/></marker>
</defs>
<rect width="1680" height="960" fill="#FFFFFF"/>
<text x="60" y="62" font-size="52" font-weight="bold" fill="#1E1A4B">Hybrid architecture</text>
<text x="60" y="112" font-size="36" fill="#6B7280">Light work in the browser · heavy work on the server</text>
<!-- DATA SOURCES -->
<rect x="60" y="146" width="1560" height="92" rx="12" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="90" y="198" font-size="40" font-weight="bold" fill="#1E1A4B">Sources</text>
<text x="290" y="198" font-size="40" fill="#333333">upload · WMS · Earth Engine · DE Africa · PostGIS</text>
<line x1="840" y1="238" x2="840" y2="286" stroke="#1E1A4B" stroke-width="4" marker-end="url(#arr)"/>
<!-- CLIENT -->
<rect x="60" y="286" width="1560" height="286" rx="16" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="90" y="344" font-size="46" font-weight="bold" fill="#1E1A4B">CLIENT — LUPMIS2 PWA (offline-capable)</text>
<rect x="90" y="372" width="480" height="172" rx="12" fill="#FFFFFF" stroke="#C7D2FE" stroke-width="2"/>
<text x="118" y="426" font-size="44" font-weight="bold" fill="#1E1A4B">Map &amp; drawing</text>
<text x="118" y="476" font-size="40" fill="#333333">Analysis panel</text>
<text x="118" y="522" font-size="40" fill="#333333">Job progress</text>
<rect x="600" y="372" width="480" height="172" rx="12" fill="#FFFFFF" stroke="#8FE3C2" stroke-width="3"/>
<text x="628" y="426" font-size="44" font-weight="bold" fill="#0E9F6E">Vector analysis</text>
<text x="628" y="476" font-size="40" fill="#333333">Overlays · zonal</text>
<text x="628" y="522" font-size="40" fill="#333333">Works offline</text>
<rect x="1110" y="372" width="480" height="172" rx="12" fill="#FFFFFF" stroke="#C7D2FE" stroke-width="2"/>
<text x="1138" y="426" font-size="44" font-weight="bold" fill="#1E1A4B">Raster display</text>
<text x="1138" y="476" font-size="40" fill="#333333">COG via WebGL</text>
<text x="1138" y="522" font-size="40" fill="#333333">+ WMS layers</text>
<!-- ARROWS -->
<line x1="560" y1="572" x2="560" y2="646" stroke="#1E1A4B" stroke-width="4" marker-end="url(#arr)"/>
<text x="596" y="618" font-size="36" font-weight="bold" fill="#1E1A4B">Request →</text>
<line x1="1150" y1="646" x2="1150" y2="572" stroke="#10B981" stroke-width="4" marker-end="url(#arrG)"/>
<text x="1114" y="618" font-size="36" font-weight="bold" fill="#0E9F6E" text-anchor="end">← Result</text>
<!-- SERVER -->
<rect x="60" y="646" width="1560" height="252" rx="16" fill="#1E1A4B"/>
<text x="90" y="706" font-size="46" font-weight="bold" fill="#FFFFFF">SERVER — geoprocessing jobs</text>
<rect x="90" y="732" width="480" height="140" rx="12" fill="#2A2660"/>
<text x="118" y="786" font-size="42" font-weight="bold" fill="#8FE3C2">PostGIS + Raster</text>
<text x="118" y="836" font-size="38" fill="#CADCFC">Overlays · zonal</text>
<rect x="600" y="732" width="480" height="140" rx="12" fill="#2A2660"/>
<text x="628" y="786" font-size="42" font-weight="bold" fill="#FFB65C">pgRouting · GDAL</text>
<text x="628" y="836" font-size="38" fill="#CADCFC">Routing · surfaces</text>
<rect x="1110" y="732" width="480" height="140" rx="12" fill="#2A2660"/>
<text x="1138" y="786" font-size="42" font-weight="bold" fill="#FF8A80">WhiteboxTools</text>
<text x="1138" y="836" font-size="38" fill="#CADCFC">Hydrology · flood</text>
<text x="60" y="944" font-size="32" fill="#6B7280">Stage 1 low maintenance · Stage 2 medium · Stage 3 specialist skills</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,72 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1680" height="1010" viewBox="0 0 1680 1010" font-family="Calibri, Arial, sans-serif">
<!-- Sized for projection: 1680 px across ~11.7 in (~144 px/in), so
40 px = 20 pt. Labels kept terse for legibility from the back of a room. -->
<defs>
<marker id="b1" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#1E1A4B"/></marker>
<marker id="b2" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#16A34A"/></marker>
</defs>
<rect width="1680" height="1010" fill="#FFFFFF"/>
<text x="60" y="66" font-size="60" font-weight="bold" fill="#1E1A4B">Digital Earth Africa integration</text>
<text x="60" y="118" font-size="36" fill="#6B7280">One route is live today; the other is a batch step</text>
<!-- CATALOGUE -->
<rect x="60" y="150" width="1560" height="132" rx="14" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="90" y="205" font-size="46" font-weight="bold" fill="#1E1A4B">Digital Earth Africa · open and free</text>
<text x="90" y="255" font-size="40" fill="#333333">DEM · slope · imagery · land cover · water · rainfall</text>
<line x1="420" y1="282" x2="420" y2="332" stroke="#16A34A" stroke-width="4" marker-end="url(#b2)"/>
<line x1="1240" y1="282" x2="1240" y2="332" stroke="#1E1A4B" stroke-width="4" marker-end="url(#b1)"/>
<!-- LANE 1 — live WMS -->
<rect x="60" y="332" width="720" height="452" rx="14" fill="#FFFFFF" stroke="#16A34A" stroke-width="3"/>
<rect x="60" y="332" width="720" height="66" rx="14" fill="#16A34A"/>
<text x="90" y="379" font-size="44" font-weight="bold" fill="#FFFFFF">1 — Live WMS</text>
<rect x="92" y="418" width="656" height="78" rx="10" fill="#F0FDF4" stroke="#86EFAC" stroke-width="2"/>
<text x="120" y="470" font-size="40" fill="#166534">DE Africa web service</text>
<line x1="420" y1="496" x2="420" y2="522" stroke="#16A34A" stroke-width="3" marker-end="url(#b2)"/>
<rect x="92" y="522" width="656" height="78" rx="10" fill="#F0FDF4" stroke="#86EFAC" stroke-width="2"/>
<text x="120" y="574" font-size="40" fill="#166534">Added as a map layer</text>
<rect x="92" y="626" width="656" height="78" rx="10" fill="#DCFCE7" stroke="#16A34A" stroke-width="3"/>
<text x="120" y="678" font-size="40" font-weight="bold" fill="#166534">✓ Live in LUPMIS2 today</text>
<text x="120" y="748" font-size="32" fill="#991B1B">A picture only — no pixel values</text>
<!-- LANE 2 — batch ETL -->
<rect x="820" y="332" width="800" height="452" rx="14" fill="#FFFFFF" stroke="#1E1A4B" stroke-width="3"/>
<rect x="820" y="332" width="800" height="66" rx="14" fill="#1E1A4B"/>
<text x="850" y="379" font-size="44" font-weight="bold" fill="#FFFFFF">2 — Batch export</text>
<rect x="852" y="418" width="736" height="70" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="880" y="465" font-size="38" fill="#1E1A4B">1. Load in the Sandbox</text>
<line x1="1220" y1="488" x2="1220" y2="510" stroke="#1E1A4B" stroke-width="3" marker-end="url(#b1)"/>
<rect x="852" y="510" width="736" height="70" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="880" y="557" font-size="38" fill="#1E1A4B">2. write_cog()</text>
<line x1="1220" y1="580" x2="1220" y2="602" stroke="#1E1A4B" stroke-width="3" marker-end="url(#b1)"/>
<rect x="852" y="602" width="736" height="70" rx="10" fill="#E7F6EF" stroke="#16A34A" stroke-width="3"/>
<text x="880" y="649" font-size="38" font-weight="bold" fill="#0E7A50">3. Upload to MinIO</text>
<line x1="1220" y1="672" x2="1220" y2="694" stroke="#1E1A4B" stroke-width="3" marker-end="url(#b1)"/>
<rect x="852" y="694" width="736" height="70" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="880" y="741" font-size="38" fill="#1E1A4B">4. Load as COG</text>
<!-- CONVERGE -->
<line x1="420" y1="784" x2="420" y2="818" stroke="#16A34A" stroke-width="4"/>
<line x1="1220" y1="784" x2="1220" y2="818" stroke="#1E1A4B" stroke-width="4"/>
<line x1="420" y1="818" x2="1220" y2="818" stroke="#1E1A4B" stroke-width="4"/>
<line x1="820" y1="818" x2="820" y2="852" stroke="#16A34A" stroke-width="5" marker-end="url(#b2)"/>
<rect x="310" y="852" width="1020" height="106" rx="14" fill="#1E1A4B"/>
<text x="346" y="905" font-size="46" font-weight="bold" fill="#FFFFFF">LUPMIS2 PWA</text>
<text x="346" y="943" font-size="34" fill="#CADCFC">WMS to view · COG to view and analyse</text>
<text x="60" y="995" font-size="30" fill="#166534" font-weight="bold">Why:</text>
<text x="146" y="995" font-size="30" fill="#6B7280">open, African, no commercial licence question · Sandbox needs no local setup</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -1,74 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1680" height="1010" viewBox="0 0 1680 1010" font-family="Calibri, Arial, sans-serif">
<!-- Sized for projection: rendered at 1680 px across ~11.7 in of slide
(~144 px/in), so 40 px = 20 pt, 48 px = 24 pt, 60 px = 30 pt.
Labels are deliberately terse — detail belongs in the spoken commentary. -->
<defs>
<marker id="a1" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#1E1A4B"/></marker>
<marker id="a2" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#16A34A"/></marker>
<marker id="a3" markerWidth="10" markerHeight="10" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#D97706"/></marker>
</defs>
<rect width="1680" height="1010" fill="#FFFFFF"/>
<text x="60" y="66" font-size="60" font-weight="bold" fill="#1E1A4B">Google Earth Engine integration</text>
<text x="60" y="118" font-size="36" fill="#6B7280">A cloud catalogue that can also compute</text>
<!-- SOURCE -->
<rect x="60" y="150" width="1560" height="132" rx="14" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="90" y="205" font-size="46" font-weight="bold" fill="#1E1A4B">Earth Engine · LUSPA service account</text>
<text x="90" y="255" font-size="40" fill="#333333">DEM · imagery · land cover · water · rainfall</text>
<line x1="440" y1="282" x2="440" y2="332" stroke="#1E1A4B" stroke-width="4" marker-end="url(#a1)"/>
<line x1="1240" y1="282" x2="1240" y2="332" stroke="#D97706" stroke-width="4" marker-end="url(#a3)"/>
<!-- LANE A -->
<rect x="60" y="332" width="760" height="452" rx="14" fill="#FFFFFF" stroke="#1E1A4B" stroke-width="3"/>
<rect x="60" y="332" width="760" height="66" rx="14" fill="#1E1A4B"/>
<text x="90" y="379" font-size="44" font-weight="bold" fill="#FFFFFF">A — Data source</text>
<rect x="92" y="418" width="696" height="78" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="120" y="470" font-size="40" fill="#1E1A4B">1. Clip to district</text>
<line x1="440" y1="496" x2="440" y2="522" stroke="#1E1A4B" stroke-width="3" marker-end="url(#a1)"/>
<rect x="92" y="522" width="696" height="78" rx="10" fill="#EEF2FF" stroke="#C7D2FE" stroke-width="2"/>
<text x="120" y="574" font-size="40" fill="#1E1A4B">2. Export as COG</text>
<line x1="440" y1="600" x2="440" y2="626" stroke="#1E1A4B" stroke-width="3" marker-end="url(#a1)"/>
<rect x="92" y="626" width="696" height="78" rx="10" fill="#E7F6EF" stroke="#16A34A" stroke-width="3"/>
<text x="120" y="678" font-size="40" font-weight="bold" fill="#0E7A50">3. Publish to MinIO</text>
<text x="120" y="748" font-size="32" fill="#6B7280">Or stream tiles directly</text>
<!-- LANE B -->
<rect x="860" y="332" width="760" height="452" rx="14" fill="#FFFFFF" stroke="#D97706" stroke-width="3"/>
<rect x="860" y="332" width="760" height="66" rx="14" fill="#D97706"/>
<text x="890" y="379" font-size="44" font-weight="bold" fill="#FFFFFF">B — Compute offload</text>
<rect x="892" y="418" width="696" height="78" rx="10" fill="#FFF7ED" stroke="#FDBA74" stroke-width="2"/>
<text x="920" y="470" font-size="40" fill="#9A3412">1. Send the operation</text>
<line x1="1240" y1="496" x2="1240" y2="522" stroke="#D97706" stroke-width="3" marker-end="url(#a3)"/>
<rect x="892" y="522" width="696" height="78" rx="10" fill="#FFF7ED" stroke="#FDBA74" stroke-width="2"/>
<text x="920" y="574" font-size="40" fill="#9A3412">2. Google computes it</text>
<line x1="1240" y1="600" x2="1240" y2="626" stroke="#D97706" stroke-width="3" marker-end="url(#a3)"/>
<rect x="892" y="626" width="696" height="78" rx="10" fill="#FFF7ED" stroke="#FDBA74" stroke-width="2"/>
<text x="920" y="678" font-size="40" fill="#9A3412">3. Return result only</text>
<text x="920" y="748" font-size="32" font-weight="bold" fill="#166534">Less load on our server</text>
<!-- CONVERGE -->
<line x1="440" y1="784" x2="440" y2="818" stroke="#1E1A4B" stroke-width="4"/>
<line x1="1240" y1="784" x2="1240" y2="818" stroke="#D97706" stroke-width="4"/>
<line x1="440" y1="818" x2="1240" y2="818" stroke="#1E1A4B" stroke-width="4"/>
<line x1="840" y1="818" x2="840" y2="852" stroke="#16A34A" stroke-width="5" marker-end="url(#a2)"/>
<rect x="330" y="852" width="1020" height="106" rx="14" fill="#1E1A4B"/>
<text x="366" y="905" font-size="46" font-weight="bold" fill="#FFFFFF">LUPMIS2 PWA</text>
<text x="366" y="943" font-size="34" fill="#CADCFC">Display · analysis · cached offline</text>
<text x="60" y="995" font-size="30" fill="#DC2626" font-weight="bold">Needs:</text>
<text x="172" y="995" font-size="30" fill="#6B7280">connectivity · Google quotas · licence check for government use</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

49
dist/.htaccess vendored
View File

@ -13,58 +13,9 @@ DirectoryIndex index.php index.html
SetHandler application/x-httpd-php
</FilesMatch>
# Second line of defence for hidden FILES (.env, .htpasswd, …), in case
# mod_rewrite is not loaded. This does not cover hidden directories such as
# /.git/config — see the mod_rewrite block below, which does.
# Both Apache 2.4 and 2.2 syntaxes are given so this works either way.
#
# Access control runs before mod_rewrite, so a hidden file answers 403 here
# while a hidden directory answers 404 from the rewrite below. Both refuse to
# serve the content, which is the point; the differing codes are cosmetic.
<FilesMatch "^\.">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
<IfModule mod_rewrite.c>
RewriteEngine On
# ------------------------------------------------------------------
# Block hidden paths (.git, .env, .svn, …) — MUST stay first
# ------------------------------------------------------------------
# Scanners routinely fetch /.env and /.git/config, and if /.git/index
# plus /.git/objects/… can be read the whole repository — including its
# history and any credential ever committed — can be reconstructed.
#
# Two things make this easy to get wrong:
#
# 1. <FilesMatch "^\."> does NOT stop /.git/config. FilesMatch tests
# the basename only, and the basename there is "config". Blocking a
# hidden *directory* needs a rule that sees the whole path, which in
# .htaccess means mod_rewrite (<DirectoryMatch> is server-config only).
#
# 2. The SPA fallback further down only rewrites paths that do NOT
# exist (!-f / !-d). A real .env on disk therefore skips the
# fallback and gets served as a plain file. So this rule has to come
# before it, not after.
#
# .well-known is deliberately exempt: blocking it breaks Let's Encrypt
# (ACME) certificate issuance and renewal.
#
# 404 rather than 403, so a probe learns nothing about what exists.
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule (^|/)\. - [R=404,L]
# Files that are never meant to be fetched over HTTP, if one is ever
# deployed into the document root by mistake.
RewriteRule \.(sql|sqlite|sqlite3|db|bak|backup|old|orig|save|swp|log|ini|conf|key|pem|crt|p12|pfx)$ - [R=404,L,NC]
RewriteRule (^|/)(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml|\.dockerignore)$ - [R=404,L,NC]
# Clean URL for the iframe embed endpoint: /embed → embed.php
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
# index.php. Query strings (?mode=permit&...) pass through automatically.

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

918
dist/assets/index-DRlPLJxg.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-DRlPLJxg.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

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

172
dist/assets/jspdf-BTK_8o8D.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/jspdf-BTK_8o8D.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

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

573
dist/assets/openlayers-D8ReJJOp.js 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

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

2
dist/assets/pdf-export-BG6jqfsR.js vendored Normal file
View File

@ -0,0 +1,2 @@
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

View File

@ -1,2 +0,0 @@
import{E as f,a as b}from"./jspdf-FJgf4ijI.js";import"./pako-Xa-UToif.js";import"./geotiff-BaoeLn6q.js";import"./openlayers-J9qS6Th1.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 F({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{F as exportAnalysisPDF};
//# sourceMappingURL=pdf-export-BONdNTHD.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

View File

@ -1,30 +0,0 @@
function u(n){return String(n).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function g(n){let o="",e=n;for(;e>=0;)o=String.fromCharCode(e%26+65)+o,e=Math.floor(e/26)-1;return o}function F(n){return n&&!Array.isArray(n)&&Array.isArray(n.cells)?{cells:n.cells,bold:!!n.bold}:{cells:Array.isArray(n)?n:[n],bold:!1}}function b(n){const o=[];return n.forEach((e,r)=>{const{cells:l,bold:c}=F(e),i=c?' s="1"':"",m=l.map((t,a)=>{const f=`${g(a)}${r+1}`;return t==null||t===""?`<c r="${f}"${i}/>`:typeof t=="number"&&Number.isFinite(t)?`<c r="${f}"${i}><v>${t}</v></c>`:`<c r="${f}"${i} t="inlineStr"><is><t xml:space="preserve">${u(t)}</t></is></c>`}).join("");o.push(`<row r="${r+1}">${m}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${o.join("")}</sheetData></worksheet>`}const k=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`,T=(()=>{const n=new Uint32Array(256);for(let o=0;o<256;o++){let e=o;for(let r=0;r<8;r++)e=e&1?3988292384^e>>>1:e>>>1;n[o]=e>>>0}return n})();function I(n){let o=4294967295;for(let e=0;e<n.length;e++)o=T[(o^n[e])&255]^o>>>8;return(o^4294967295)>>>0}function $(n){const o=new TextEncoder,e=Object.entries(n).map(([s,p])=>({name:s,nameBytes:o.encode(s),data:o.encode(p)})),r=0,l=20513,c=[],i=[];let m=0;const t=s=>[s&255,s>>>8&255],a=s=>[s&255,s>>>8&255,s>>>16&255,s>>>24&255];for(const s of e){const p=I(s.data),d=s.data.length,x=[...a(67324752),...t(20),...t(0),...t(0),...t(r),...t(l),...a(p),...a(d),...a(d),...t(s.nameBytes.length),...t(0)];c.push(new Uint8Array(x),s.nameBytes,s.data),i.push([...a(33639248),...t(20),...t(20),...t(0),...t(0),...t(r),...t(l),...a(p),...a(d),...a(d),...t(s.nameBytes.length),...t(0),...t(0),...t(0),...t(0),...a(0),...a(m)]),i.push(s.nameBytes),m+=x.length+s.nameBytes.length+d}const f=[];let h=0;for(const s of i){const p=s instanceof Uint8Array?s:new Uint8Array(s);f.push(p),h+=p.length}const y=new Uint8Array([...a(101010256),...t(0),...t(0),...t(e.length),...t(e.length),...a(h),...a(m),...t(0)]);return new Blob([...c,...f,y],{type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"})}function w(n){const o=(n||[]).filter(l=>l&&Array.isArray(l.rows));if(o.length===0)throw new Error("No sheets to export.");const e={},r=o.map((l,c)=>{const i=`xl/worksheets/sheet${c+1}.xml`;e[i]=b(l.rows);const m=String(l.name||`Sheet${c+1}`).replace(/[:\\/?*[\]]/g," ").slice(0,31);return{id:c+1,name:m,file:i}});return e["[Content_Types].xml"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${r.map(l=>`<Override PartName="/${l.file}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join(`
`)}
</Types>`,e["_rels/.rels"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`,e["xl/workbook.xml"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>${r.map(l=>`<sheet name="${u(l.name)}" sheetId="${l.id}" r:id="rId${l.id}"/>`).join("")}</sheets>
</workbook>`,e["xl/_rels/workbook.xml.rels"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${r.map(l=>`<Relationship Id="rId${l.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${l.id}.xml"/>`).join(`
`)}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`,e["xl/styles.xml"]=k,$(e)}function S(n,o){const e=URL.createObjectURL(n),r=document.createElement("a");r.href=e,r.download=o,document.body.appendChild(r),r.click(),r.remove(),setTimeout(()=>URL.revokeObjectURL(e),1e3)}function U(n,o){S(w(n),o)}export{w as buildXlsxBlob,S as downloadBlob,U as exportXlsx};
//# sourceMappingURL=xlsx-d1LrY7GM.js.map

File diff suppressed because one or more lines are too long

606
dist/index.html vendored
View File

@ -112,8 +112,6 @@
--destructive-foreground: #fff;
--border: #1e1a4b1f;
--ring: var(--brand-blue-strong);
/* Floating map panels (drawing rail, its option bars) */
--bar-bg: rgba(255, 255, 255, 0.94);
/* Typography */
--font-display: "Bebas Neue", sans-serif;
@ -875,30 +873,6 @@
font-family: var(--font-body);
}
/* Narrow screens: share the width evenly rather than relying on each
button's 60px minimum, which no longer fits once the dock is padded
(6 x 60 + 32 = 392px, against 390px on the common iPhone widths).
Equal division also keeps the row visually regular now that Add Location
has gone and the count changed. */
@media (max-width: 576px) {
.bottom-dock {
justify-content: space-between;
gap: 4px;
padding-left: 8px;
padding-right: 8px;
}
.dock-btn {
flex: 1 1 0;
min-width: 0;
padding: 6px 2px;
font-size: 1.25rem;
}
.dock-btn-label {
font-size: 0.58rem;
letter-spacing: -0.01em;
}
}
/* EditBar colour picker inline styling */
.ol-editbar .ol-colorpicker button {
padding: 2px;
@ -1080,8 +1054,6 @@
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
.gps-coords { font-weight: 600; }
/* Secondary UTM line — only shown in "Both" coordinate-format mode */
.gps-utm { font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0.92; }
.gps-meta { display: flex; gap: 5px; opacity: 0.85; }
/* Active fix: tint green-ish; colour-coded further from JS via quality class */
.gps-readout.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); }
@ -1097,7 +1069,6 @@
}
@media (max-width: 380px) {
.gps-readout .gps-meta { display: none; }
.gps-readout .gps-utm { display: none; }
}
/* ol-ext SearchNominatim styling */
@ -1542,353 +1513,18 @@
gap: 2px;
}
/* ================================================================
Drawing toolbar — docked left rail
----------------------------------------------------------------
Field officers reported three problems with the palette: it had no
background and vanished over light imagery, it sat centred at the top
across the area being drawn, and its icons did not match each other.
The bar is now a two-column rail docked to the left edge, on a solid
panel, with its controls banded into labelled groups by
MapView._buildToolbarRail(). Two columns rather than one because a
single file of 13 buttons plus labels runs past the bottom of a laptop
map view.
`ol-left` is ol-ext's own class: it docks the bar left AND makes option
bars (Select's Delete/Info, Split's sub-tools) fly out to the right
instead of dropping downwards. We keep that behaviour and override only
the layout.
================================================================ */
.ol-control.ol-bar.ol-editbar.ol-editbar-rail {
/* Clear of the left off-canvas toggle, which sits at left:10px and is
44px wide, vertically centred — exactly where ol-ext docks a left bar.
Officers can drag the rail anywhere from here; see the grip below. */
left: 66px;
display: grid;
grid-template-columns: repeat(2, 30px);
gap: 3px;
align-content: start;
padding: 5px;
background: var(--bar-bg);
border: 1px solid rgba(30, 26, 75, 0.14);
border-radius: 11px;
box-shadow:
0 6px 24px -6px rgba(30, 26, 75, 0.3),
0 1px 3px rgba(30, 26, 75, 0.14);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
/* Deliberately NOT scrollable. The rail measures 75 x 363 px, which fits
any map viewport we support, and setting overflow on either axis makes
the other compute to `auto` as well — which clips the option bars that
fly out past the right edge. */
overflow: visible;
}
/* Direct children only. A descendant selector here would also match the
nested option bars — which are themselves .ol-control — and override the
`display: none` that keeps them closed until their tool is active. */
.ol-editbar.ol-editbar-rail.ol-left > .ol-control {
margin: 0;
}
/* --- drag grip ---------------------------------------------------
The rail can be moved: officers work at different zoom levels and on
different hands, and a fixed panel always covers something. Dragging is
deliberately restricted to this grip so a mis-aimed tap on a tool never
moves the palette instead of selecting.
---------------------------------------------------------------- */
.ol-editbar-rail .ol-rail-grip {
grid-column: 1 / -1;
display: grid;
place-items: center;
height: 18px;
margin: -2px 0 2px;
border-radius: 6px;
color: var(--muted-foreground);
cursor: grab;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.ol-editbar-rail .ol-rail-grip:hover {
background: var(--muted);
color: var(--foreground);
}
.ol-editbar-rail .ol-rail-grip:active,
.ol-editbar-rail.is-dragging .ol-rail-grip {
cursor: grabbing;
}
.ol-editbar-rail .ol-rail-grip::before {
content: "";
width: 22px;
height: 4px;
border-radius: 2px;
background: currentColor;
opacity: 0.45;
}
.ol-editbar-rail.is-dragging {
opacity: 0.9;
user-select: none;
}
/* No dragging on phones — the strip is docked above the dock bar. */
@media (max-width: 576px), (max-height: 560px) {
.ol-editbar-rail .ol-rail-grip {
display: none;
}
}
/* Group heading — spans both columns. */
.ol-editbar-rail .ol-rail-label {
grid-column: 1 / -1;
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted-foreground);
text-align: center;
padding: 4px 0 1px;
user-select: none;
-webkit-user-select: none;
}
.ol-editbar-rail .ol-rail-label:first-child {
padding-top: 1px;
}
/* The action group is one control holding four buttons; let it span the
rail and lay its own buttons out on the same 2-column rhythm. */
.ol-editbar-rail > .ol-editbar-actions {
grid-column: 1 / -1;
}
.ol-editbar-rail > .ol-editbar-actions,
.ol-editbar-rail > .ol-editbar-actions .ol-bar {
display: grid !important;
grid-template-columns: repeat(2, 30px);
gap: 3px;
}
/* --- buttons ---------------------------------------------------- */
.ol-editbar-rail .ol-control button {
width: 30px;
height: 30px;
margin: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--foreground);
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: background-color 0.13s ease, color 0.13s ease;
}
.ol-editbar-rail .ol-control button:hover {
background: var(--muted);
}
/* The `.ol-left` is here for specificity, not layout: ol-ext paints the
active tool with `.ol-control.ol-bar .ol-toggle.ol-active > button`
(0,4,1) in its own brand cyan. Matching that and adding a class puts the
LUSPA blue back. */
.ol-editbar.ol-editbar-rail.ol-left .ol-control.ol-active > button,
.ol-editbar.ol-editbar-rail.ol-left .ol-toggle.ol-active > button,
.ol-editbar.ol-editbar-rail.ol-left .ol-toggle.ol-active > button:hover,
.ol-editbar.ol-editbar-rail.ol-left .ol-control button.ol-active,
.ol-editbar.ol-editbar-rail.ol-left .ol-snap-toggle.ol-active button {
background: var(--primary);
color: var(--primary-foreground);
}
.ol-editbar-rail .ol-control button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.ol-editbar-rail .ol-control button i {
pointer-events: none;
}
/* --- suppress ol-ext's CSS-drawn icons --------------------------
ol-ext has no icon font: each built-in tool is drawn with ::before and
::after shapes built from borders and box-shadows. Those are switched
off here so the Bootstrap Icon injected by _buildToolbarRail() is the
only thing in the button. The `> button` shapes and the `> button i`
shapes (Transform, Offset) both have to go.
---------------------------------------------------------------- */
.ol-editbar-rail .ol-control > button::before,
.ol-editbar-rail .ol-control > button::after {
content: none !important;
}
/* The injected glyph is a <span>, so ol-ext's `> button i` rules for
Transform and Offset cannot reach it — nothing to undo here, only the
sizing ol-ext applies to any icon holder. */
.ol-editbar-rail .ol-control > button > span.bi {
position: static;
width: auto;
height: auto;
overflow: visible;
transform: none;
display: block;
}
/* --- option bars (Select ▸ Delete/Info, Split ▸ sub-tools) -------
ol-ext anchors these to the control with `left: 100%`, which assumes a
one-column rail — in two columns that lands the flyout on top of the
neighbouring button. Making the controls static hands the containing
block to the rail itself, so `left: 100%` clears the whole panel. The
flyout then always appears at the same place (rail's right edge, centred)
instead of jumping to whichever button was pressed.
---------------------------------------------------------------- */
.ol-editbar.ol-editbar-rail > .ol-control {
position: static;
}
.ol-editbar-rail .ol-option-bar {
top: 50% !important;
bottom: auto !important;
left: 100% !important;
transform: translateY(-50%);
background: var(--bar-bg);
border-radius: 9px;
box-shadow:
0 4px 16px -4px rgba(30, 26, 75, 0.3),
0 0 0 1px rgba(30, 26, 75, 0.14);
padding: 4px;
margin: 0 0 0 8px !important;
white-space: nowrap;
}
/* ol-ext draws a little pointer triangle on the option bar. It reads as a
stray dark button next to the flyout, and it is declared at
`.ol-control.ol-bar.ol-left .ol-option-bar:before` (0,4,1) — so the
selector that hides it has to be at least as specific. */
.ol-control.ol-bar.ol-editbar-rail.ol-left .ol-option-bar::before,
.ol-control.ol-bar.ol-editbar-rail.ol-left .ol-option-bar::after {
display: none;
content: none;
}
.ol-editbar-rail .ol-option-bar .ol-control {
display: inline-block !important;
}
/* --- dark mode -------------------------------------------------- */
.dark-mode {
--bar-bg: rgba(28, 25, 64, 0.94);
}
.dark-mode .ol-editbar.ol-editbar-rail,
.dark-mode .ol-editbar-rail .ol-option-bar {
border-color: rgba(255, 255, 255, 0.14);
}
.dark-mode .ol-editbar-rail .ol-control button {
color: #eceaf7;
}
.dark-mode .ol-editbar-rail .ol-control button:hover {
background: rgba(255, 255, 255, 0.1);
}
/* --- fieldwork mode: larger touch targets ----------------------- */
.fieldwork-mode .ol-editbar.ol-editbar-rail {
grid-template-columns: repeat(2, 38px);
}
.fieldwork-mode .ol-editbar-rail > .ol-editbar-actions,
.fieldwork-mode .ol-editbar-rail > .ol-editbar-actions .ol-bar {
grid-template-columns: repeat(2, 38px);
}
.fieldwork-mode .ol-editbar-rail .ol-control button {
width: 38px;
height: 38px;
font-size: 18px;
}
/* ----------------------------------------------------------------
Phones: the rail becomes a horizontally scrolling strip sitting
above the bottom dock. Scrolling replaces the previous two-row
wrap, so every tool stays reachable at full touch size.
Small-screen drawing toolbar: the EditBar is a single nowrap row
by default and overflows narrow phones. Below the sm breakpoint we
let it wrap and push the action group (undo / redo / save / snap)
onto its own second row so every tool stays reachable.
---------------------------------------------------------------- */
/* Narrow OR short. A phone in landscape is 844px wide but leaves only
~270px of map height, where the 395px rail cannot fit — so the strip is
chosen on either axis, not on width alone. */
@media (max-width: 576px), (max-height: 560px) {
/* `.ol-left` is repeated here for specificity: ol-ext's own
`.ol-control.ol-bar.ol-left` sets top/transform, and without matching
it the rail stays vertically centred and stretches to `bottom`.
This layout is deliberately plain. An earlier version scrolled
horizontally and blurred its backdrop, and on iOS Safari it did not
paint at all — a scroll container that also has backdrop-filter is a
known way to lose an element entirely. Wrapping onto two rows needs
neither, and it is what shipped before the rail, on the same phones.
Nothing here should be made cleverer without testing on a device. */
.ol-control.ol-bar.ol-editbar.ol-editbar-rail.ol-left {
/* NOTE: display must NOT be !important — ol-ext toggles the bar with an
inline display:none/'' (setVisible). An !important here would beat
that and keep the toolbar visible even when Draw mode is off. */
display: flex;
grid-template-columns: none;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
row-gap: 3px;
top: auto;
/* Clear of the right-edge control stack, which the strip spans the full
width of and would otherwise sit under:
My Location bottom: 90px + 44px tall -> top at 134px
Base map switcher bottom: 144px + 44px tall -> top at 188px
so 188 + 8px gap. Keep these in step with .ls-locate-toggle and the
LayerSwitcher button in src/styles/layerswitcher.css. */
bottom: 196px;
left: 8px;
/* width rather than `right`, which is the more conventional pairing
with `left` and avoids another layout edge case. */
right: auto;
width: calc(100% - 16px);
transform: none;
overflow: visible;
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--card);
}
/* Group headings become hairline dividers: the words cost more width than
two rows can spare, but the grouping is still worth showing. */
.ol-editbar-rail .ol-rail-label {
grid-column: auto;
flex: none;
width: 1px;
height: 24px;
padding: 0;
margin: 0 5px;
font-size: 0;
background: var(--border);
}
.ol-editbar-rail .ol-rail-grip + .ol-rail-label {
/* Flex line-break used to start the toolbar's second row. Inert (removed
from flow) on wider screens; activated inside the sm media query. */
.ol-editbar-break {
display: none;
}
.ol-editbar-rail > .ol-editbar-actions,
.ol-editbar-rail > .ol-editbar-actions .ol-bar {
display: flex !important;
grid-template-columns: none;
flex: none;
}
.ol-editbar.ol-editbar-rail.ol-left .ol-control {
flex: none;
}
/* Touch targets stay at the recommended minimum on a phone. */
.ol-editbar-rail .ol-control button {
width: 36px;
height: 36px;
font-size: 17px;
}
/* Option bars open upward here — there is no room to the right, and
downward would land under the dock. */
.ol-editbar.ol-editbar-rail .ol-option-bar {
top: auto !important;
bottom: 100% !important;
left: 8px !important;
transform: none;
margin: 0 0 8px !important;
}
}
/* ----------------------------------------------------------------
Iframe embed mode (see public/embed.php + src/embed-bridge.js).
The body class is set by the inline script injected by embed.php
@ -1916,14 +1552,57 @@
height: 100dvh;
}
@media (max-width: 576px) {
.ol-editbar.ol-bar {
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
inline `display:none`/`''` (setVisible). A `!important` here would beat
that inline style and keep the toolbar permanently visible even when
Draw mode is off. Plain `display:flex` only applies when visible. */
display: flex;
flex-wrap: wrap !important;
justify-content: center;
align-items: center;
row-gap: 4px;
white-space: normal !important;
max-width: calc(100vw - 12px);
}
/* Full-width, zero-height break → forces everything after it (the
action group + Split + Merge) onto a shared second row. */
.ol-editbar.ol-bar > .ol-editbar-break {
display: block;
flex-basis: 100%;
width: 100%;
height: 0;
margin: 0;
padding: 0;
}
/* Right-align the second row (action group + Split + Merge). The auto
left-margin pushes them to the right end of the line, clearing the
far-left zone where an active tool's option bar (e.g. Select's
Delete/Info) drops down from row 1 — which otherwise overlaps them. */
.ol-editbar.ol-bar > .ol-editbar-actions {
/* !important: ol-ext's `.ol-control.ol-bar .ol-control { margin:0 }`
has equal specificity and loads later, so it would otherwise cancel
this auto-margin and the group would stay glued to the left. */
margin-left: auto !important;
}
/* Pull the second row (action group + Split + Merge — everything after
the line-break) up ~10px. The zero-height break sits on its own flex
line, stacking two row-gaps above the row; this negative top margin
closes that extra space so row 2 aligns nicely under row 1. */
.ol-editbar.ol-bar > .ol-editbar-break ~ .ol-control {
margin-top: -8px !important;
}
}
</style>
<script type="module" crossorigin src="/assets/index-ErUYnuzo.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-J9qS6Th1.js">
<link rel="modulepreload" crossorigin href="/assets/pako-Xa-UToif.js">
<link rel="modulepreload" crossorigin href="/assets/geotiff-BaoeLn6q.js">
<script type="module" crossorigin src="/assets/index-DRlPLJxg.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-C5frl4dY.js">
<link rel="modulepreload" crossorigin href="/assets/shpjs-Doisr2ug.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">
@ -1945,7 +1624,6 @@
<i class="bi bi-broadcast" aria-hidden="true"></i>
<span class="gps-readout-body">
<span class="gps-coords" id="gps-coords">GPS off</span>
<span class="gps-utm d-none" id="gps-utm"></span>
<span class="gps-meta">
<span id="gps-accuracy"></span>
<span class="gps-sep">·</span>
@ -2020,9 +1698,10 @@
<!-- Bottom Dock -->
<div class="bottom-dock">
<!-- The Add Location button used to sit here. Adding a point is now
the Draw > Point tool inside Digitise, so there is one gesture
for it rather than two. -->
<button class="dock-btn active" type="button" id="dock-btn-add-location" title="Add Location Mode">
<span>📍</span>
<span class="dock-btn-label">Add</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-measure-circle" title="Measure Circle">
<span></span>
<span class="dock-btn-label">Circle</span>
@ -2039,11 +1718,6 @@
<span>✏️</span>
<span class="dock-btn-label">Digitise</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-analysis"
title="Spatial Analysis — overlays and zonal statistics">
<span>🧮</span>
<span class="dock-btn-label">Analyse</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
<span>🗑️</span>
<span class="dock-btn-label">Clear</span>
@ -2146,11 +1820,6 @@
<label class="btn btn-outline-primary" for="export-gis-fmt-kml">
KML
</label>
<input type="radio" class="btn-check" name="export-gis-format"
id="export-gis-fmt-pdf" value="pdf">
<label class="btn btn-outline-primary" for="export-gis-fmt-pdf">
PDF report
</label>
</div>
<div class="form-text" id="export-gis-format-hint">
GeoJSON keeps all attributes as-is and is the safest default.
@ -2166,9 +1835,8 @@
value="area_analysis">
</div>
<!-- Field rename table (hidden for the PDF report, which exports the
analysis summary figures rather than feature attributes) -->
<div id="export-gis-fields-wrap">
<!-- 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
@ -2202,118 +1870,6 @@
</div>
</div>
<!-- Analysis panel — Stage 1 client-side spatial analysis (overlays + zonal
statistics). Runs in-browser via Turf.js, so it works offline. Results
are added as a new layer in the "Analysis" group. -->
<div class="modal fade" id="analysisModal" tabindex="-1"
aria-labelledby="analysisModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="analysisModalLabel">
<i class="bi bi-diagram-3 me-2"></i>Spatial Analysis
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Mode switch -->
<div class="btn-group w-100 mb-3" role="group" aria-label="Analysis type">
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-overlay"
value="overlay" checked>
<label class="btn btn-outline-primary" for="analysis-mode-overlay">
<i class="bi bi-intersect me-1"></i>Overlay
</label>
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-zonal"
value="zonal">
<label class="btn btn-outline-primary" for="analysis-mode-zonal">
<i class="bi bi-table me-1"></i>Zonal statistics
</label>
</div>
<!-- Scope: keeps big layers workable by restricting the inputs -->
<div class="mb-3">
<label for="analysis-scope" class="form-label fw-bold">Apply to</label>
<select class="form-select form-select-sm" id="analysis-scope">
<option value="all" selected>Whole layer</option>
<option value="view">Features in current map view</option>
<option value="selection">Selected features only</option>
</select>
<div class="form-text" id="analysis-scope-hint"></div>
</div>
<!-- ---------- Overlay ---------- -->
<div id="analysis-overlay-box">
<div class="mb-3">
<label for="analysis-op" class="form-label fw-bold">Operation</label>
<select class="form-select form-select-sm" id="analysis-op"></select>
<div class="form-text" id="analysis-op-hint"></div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="analysis-layer-a" class="form-label fw-bold">Layer A</label>
<select class="form-select form-select-sm" id="analysis-layer-a"></select>
</div>
<div class="col-md-6" id="analysis-layer-b-wrap">
<label for="analysis-layer-b" class="form-label fw-bold">Layer B</label>
<select class="form-select form-select-sm" id="analysis-layer-b"></select>
</div>
</div>
</div>
<!-- ---------- Zonal statistics ---------- -->
<div id="analysis-zonal-box" class="d-none">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-zone-layer" class="form-label fw-bold">Zone layer</label>
<select class="form-select form-select-sm" id="analysis-zone-layer"></select>
<div class="form-text">Polygons the results are summarised into.</div>
</div>
<div class="col-md-6">
<label for="analysis-input-layer" class="form-label fw-bold">Features to summarise</label>
<select class="form-select form-select-sm" id="analysis-input-layer"></select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-membership" class="form-label fw-bold">Counted when</label>
<select class="form-select form-select-sm" id="analysis-membership"></select>
<div class="form-text" id="analysis-membership-hint"></div>
</div>
<div class="col-md-6">
<label for="analysis-field" class="form-label fw-bold">Numeric field</label>
<select class="form-select form-select-sm" id="analysis-field"></select>
<div class="form-text">Needed for sum, mean, minimum and maximum.</div>
</div>
</div>
<label class="form-label fw-bold d-block mb-1">Statistics</label>
<div id="analysis-stats-box" class="mb-1"><!-- populated at runtime --></div>
</div>
<hr>
<div class="mb-3">
<label for="analysis-output-name" class="form-label fw-bold">Result layer name</label>
<input type="text" class="form-control form-control-sm" id="analysis-output-name">
</div>
<div id="analysis-result"><!-- summary / table / errors --></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
data-bs-dismiss="modal">Close</button>
<!-- Shown only after a zonal run — exports the results table plus the
parameters that produced it, as a two-sheet Excel workbook. -->
<button type="button" class="btn btn-outline-primary d-none" id="analysis-export-table">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export table (Excel)
</button>
<button type="button" class="btn btn-primary" id="analysis-run">
<i class="bi bi-play-fill me-1"></i>Run analysis
</button>
</div>
</div>
</div>
</div>
<!-- Import spinner — shown while a dropped file is being parsed and staged
(Shapefile decompression in particular can take several seconds).
Hidden once the mapping modal opens or an error occurs. -->
@ -2500,25 +2056,11 @@
<hr>
<!-- Back to Landing Page (stay signed in) — visible when authenticated.
The landing page is the dispatch hub for launching the other LUSPA
modules. Plain navigation: keeps the SSO session, no logout. -->
<a id="menu-landing-link"
href="https://lupmis4luspa.org/"
class="btn btn-primary w-100 d-none mb-2"
style="font-weight:600;">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Back to Landing Page
</a>
<!-- Logout (only visible when authenticated) — routes through the
server logout endpoint (/?logout=1) which destroys the PWA's PHP
session and hands off to the portal's /user-logout for full SSO
logout. Distinct from "Back to Landing Page" above, which stays
signed in. -->
<!-- Sign-out (only visible when authenticated) -->
<button type="button" id="menu-signout-btn"
class="btn btn-outline-danger w-100 d-none"
style="font-weight:600;">
<i class="bi bi-box-arrow-right me-2"></i>Logout
<i class="bi bi-box-arrow-right me-2"></i>Return to Landing Page
</button>
<!-- Sign-in prompt (only visible when NOT authenticated) -->
@ -2678,26 +2220,6 @@
</div>
</div>
</div>
<!-- GPS Coordinate Format -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">GPS Coordinate Format</h6>
<small class="text-muted">Format of the live GPS read-out in the top bar. UTM is shown in metres (zone, easting, northing).</small>
</div>
<div class="ms-3" style="min-width:140px;">
<select class="form-select form-select-sm" id="coord-format-select" aria-label="GPS coordinate format">
<option value="latlon">Lat / Lon</option>
<option value="utm">UTM</option>
<option value="both">Both</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Default Base Map -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">

43
dist/index.php vendored
View File

@ -24,49 +24,8 @@
session_start();
// ────────────────────────────────────────────────────────────────────────────
// Logout — end the PWA's OWN session, then hand off to the central SSO logout
// SSO authentication — validate the cookie if we don't already have a session
// ────────────────────────────────────────────────────────────────────────────
// The PWA keeps its own PHP session (PHPSESSID on this host), independent of
// the central SSO portal, so the previous user's fields (incl. district_id)
// persist until this session is destroyed. We do that here, then redirect to
// the portal's `/user-logout`, which performs the FULL SSO logout (invalidates
// the token server-side and clears the sso_auth_token cookie itself).
//
// Important: we deliberately do NOT clear `sso_auth_token` here. `/user-logout`
// needs that cookie to identify which SSO session to terminate — expiring it
// first would leave the SSO session alive on the server. Redirecting to the
// bare landing page (the old behaviour) only dropped the user on a page that
// then blocked them; it never logged them out of SSO.
//
// Triggered by `/?logout=1` from the in-app menu.
if (isset($_GET['logout'])) {
$_SESSION = [];
// Expire the PWA's own session cookie (PHPSESSID).
if (ini_get('session.use_cookies')) {
$cp = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
}
session_destroy();
// Hand off to the central SSO logout (full logout: invalidates the token
// and clears sso_auth_token). Keep the cookie intact so it can do so.
header('Location: https://lupmis4luspa.org/user-logout', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate once per session, at login
// ────────────────────────────────────────────────────────────────────────────
// A user's district changes only when they are transferred — a rare event — so
// the validated fields (district_id, region_id, name, …) are valid for the
// whole session. We therefore validate the SSO token only when there is no
// session yet (i.e. at login) and do NOT re-poll SSO on every load.
//
// A transfer is picked up the next time the user logs out and back in: the
// Logout endpoint above destroys the PHP session, so the subsequent login has
// no session and re-validates, fetching the new district_id. This is correct
// precisely because logout now tears the session down (previously it only
// cleared the SSO cookie, which left the stale district pinned).
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);

87
dist/sw.js vendored
View File

@ -45,92 +45,7 @@
// tagging, geometry-edit + delete persistence to the staging tables,
// sample values + Unicode-bold field names in the mapping dropdown).
// New hashed bundle + updated embed.php shell.
// v12: Session/district correctness — a real /?logout=1 endpoint that destroys
// the PWA's own PHP session and then hands off to the portal's
// /user-logout for a FULL SSO logout (token invalidated + sso_auth_token
// cleared by the portal). Previously logout left the PHP session intact,
// pinning a stale district_id. SSO is validated once per session at login;
// a transfer is picked up on the next logout→login.
// Client-side stale-district guard (wipes district-scoped caches when the
// session district changes) + district-keyed boundary cache; GPS read-out
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
// devices. New hashed bundle + updated index.php shell.
// v13: Analytical tools Stage 1 — client-side spatial analysis. New "Analyse"
// dock button opens an Analysis panel offering vector overlays
// (intersect / clip / difference / union, via Turf.js) and vector-in-vector
// zonal statistics (count / sum / mean / min / max / area per zone); results
// are added as layers in a new "Analysis" group and work fully offline.
// Analysis can be scoped via "Apply to": whole layer, current map view,
// selected features, or the catch of a drawn Circle/Area. The Circle/Area
// analysis popup now has one "Export" button (PDF folded into the export
// modal as a fourth format) plus an "Analyse" button that opens the panel
// pre-scoped to the intersecting features.
// Raster display gains Cloud-Optimized GeoTIFF support (MapView.addCOGLayer).
// Turf, the analysis panel, geotiff.js and pako are all code-split and
// fetched on first use, so the eager bundle is unchanged.
// v14: Zonal statistics results can be exported as a two-sheet Excel workbook
// (Results + the Parameters that produced them) via a dependency-free
// XLSX writer in src/analysis/xlsx.js; Add External Layer gains a COG
// option with a URL pre-flight check.
// v15: Drawing toolbar reworked into a docked left rail, after field officers
// reported the palette had no background, sat centred over the area being
// drawn, and mixed two icon styles. It is now a two-column panel on the
// left edge with its tools banded into Draw / Edit / Shape / File, and
// every icon — including ol-ext's own CSS-drawn ones — is a Bootstrap
// Icon. On phones it becomes a horizontally scrolling strip above the
// dock, which retires the previous two-row wrapping workaround.
// New hashed bundle + updated index.html shell.
// v16: Three fixes to the v15 rail, all reported from the field. The phone
// layout stretched down the whole screen because its rule only tied with
// ol-ext's on specificity, so stylesheet order decided the winner and the
// built app ordered them the other way round from the test harness. A
// stray dark shape beside the option bar was ol-ext's pointer triangle,
// hidden by a selector that was likewise not specific enough. And the rail
// covered the left off-canvas toggle, which sits at exactly the position
// ol-ext docks a left bar to — so the rail now starts clear of it and can
// be dragged anywhere by its grip, with the position remembered.
// v17: iPhone fixes. In portrait the toolbar did not paint at all: on iOS an
// element that both blurs its backdrop and scrolls often renders blank,
// and v16's strip did both. The strip now uses an opaque background and no
// blur. In landscape the phone is 844px wide, so the width-only breakpoint
// handed it the 396px-tall rail inside a ~270px map; the layout now
// switches on height as well as width.
// v18: The phone toolbar still did not paint on iPhone. Reverted the strip from
// a horizontally scrolling row back to the two-row wrap that shipped
// before the rail and was known to work on those phones — removing the
// overflow, and with it the whole class of iOS scroll-container paint
// bugs, rather than only the backdrop-filter half of it. Group headings
// become hairline dividers so the grouping survives the narrower layout.
// Building the rail is now also wrapped in a fallback: if it throws, the
// default ol-ext bar is shown instead of no bar at all.
// v19: The phone strip sat at the very bottom edge, under the My Location and
// base-map buttons on the right and clipped by the dock. It now sits above
// that whole right-edge stack (bottom: 196px = the switcher's 144px anchor
// + its 44px height + an 8px gap).
// v20: One way to add a point instead of two. The Add Location dock button is
// gone; placing a point with Draw > Point now opens the same Add Location
// form. The sketch is discarded so the marker is the only record, and the
// add/remove pair is kept off the undo stack. Marker details, which used
// to need Add Location mode, now open in the neutral mode. The dock keeps
// its six remaining buttons and divides them evenly on narrow screens.
// v21: Location markers were saved but never drawn. The Markers layer is built
// hidden and nothing ever switched it on except the LayerSwitcher, so a
// point placed with Draw > Point was written to the database and the map
// panned to it with nothing to see. Creating a location, or picking one
// from the Locations list, now switches the layer on.
// v22: The point placed with Draw > Point now stays on screen while the Add
// Location form is open, so it visibly becomes the marker on save instead
// of vanishing and a marker appearing from nowhere. It is discarded when
// the form closes, either way. Marker loading is also kept out of the undo
// history: entering edit mode makes UndoRedo watch every source, so Undo
// after saving a location used to strip the markers off the map while
// leaving the row in the database.
// v23: Showing the Markers layer moved into loadLocations(), so it holds
// whenever the district has locations — at startup and on every reload,
// not only in the moment a location is saved. Previously a reload put the
// layer back to hidden and every marker vanished. An empty district still
// opens with the layer off.
const CACHE_VERSION = 'v23';
const CACHE_VERSION = 'v11';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;

View File

@ -1,634 +0,0 @@
# LUPMIS2 — Re-importing the OSM data so it can be kept current
**For:** whoever runs the import (LUSPA database team)
**Date:** 11 August 2026
> **What has and has not been verified.**
>
> **Executed** — against osm2pgsql 2.2.0 and a scratch PostGIS database, using a
> hand-made extract covering every shape this config has to handle: an amenity
> node, a `healthcare`-only node, a `highway` way, a `waterway` way, an amenity
> mapped as a closed way, and one mapped as a multipolygon relation.
> The Lua config in §3 imported all four tables cleanly; `updatable` and
> `attributes` both came out `true`; all four amenity shapes reached
> `pi_osm_points`; geometries are EPSG:4326. A subsequent `--append` applied a
> diff correctly, and — the claim §5.2 depends on — left `districtid` **NULL on
> the changed row while untouched rows kept theirs**.
>
> **Not verified** — anything that needs the LUPMIS database, which is not
> reachable from where this was written: the current state of the import, the
> real name of the district boundary table, and the row counts. §1 is the set of
> checks to run before trusting those parts.
---
## Why this is needed
The LUPMIS roads endpoint returns, for district 1, 5,238 rows with these
columns:
```
geom, districtid, osm_id, surface, oneway, name
```
`highway` is not among them. The road class — trunk, primary, residential,
track — was discarded when the data was loaded. That is the normal outcome of
importing with osm2pgsql's `default.style`, which keeps a fixed list of tags
and silently drops everything else; the same mechanism discards `phone`,
`opening_hours` and `website`. The consequences are already visible in the
application:
- Roads cannot be styled or filtered by class. Every road is drawn identically,
so a trunk road and a footpath look the same at every zoom.
- `src/import-detect.js` lists `highway` as an expected field for the
`osm_roads` target, so the mapping UI offers a column the data does not have.
A discarded tag cannot be recovered by querying; it was never written. The only
way to get `highway` is to import again — and if the import is going to be
redone, it is worth redoing on terms that allow it to be kept current
afterwards, rather than repeating the same exercise in a year.
Whether the rest of the diagnosis applies — no middle tables, no replication
set up, a stale `import_timestamp` — is what §1 determines.
---
## What this import decides
Three decisions determine whether this import can be maintained or has to be
repeated by hand every time.
**The flex output**, configured by the Lua file in §3. The alternative — the
classic pgsql output that produces `planet_osm_point`, `_line`, `_polygon` and
`_roads` — is deprecated in osm2pgsql 2.x and warns on every run, and its tag
selection is governed by a style file that drops whatever it does not know.
Flex puts that choice in a configuration you own: the columns are the ones you
list, and everything else can be kept in a `jsonb` column instead of being
thrown away. It also writes one table per purpose rather than four fixed ones,
which is what allows an amenity mapped as a building outline to be served as a
point.
**`--slim`, without `--drop`.** This keeps the middle tables — osm2pgsql's own
record of every node, way and relation it has seen. They cost disk, and they
are the entire difference between an import that can accept a daily diff and
one that can only ever be replaced wholesale. An import run without them
records `updatable=false`, and no later flag will change that; the only repair
is a full re-import. `--drop` discards them, which is why it must not be used
here.
**`--extra-attributes`.** Records each object's OSM version and timestamp. That
makes it possible to say when a feature was last edited, and to answer "what
changed" at all.
Two further decisions are specific to LUPMIS.
**A separate `osm` schema — this one is not negotiable.** osm2pgsql `--create`
drops and recreates every table it owns in its schema. The `spatial` schema
holds `lu_parcels`, the hand-edited planning data this whole system exists to
manage. Pointing osm2pgsql at `spatial` puts a `--create` run one name
collision away from destroying it. Import into `osm`; expose to the API through
views in `spatial` (§7).
**Districts are stamped after import, not during.** osm2pgsql imports the
national Ghana extract and has no concept of a district. Every LUPMIS table and
endpoint is district-scoped, so a `districtid` column is filled by a spatial
join once the import finishes (§5). This has to run after every daily update as
well, and §5.2 explains why that turns out to be cheap.
---
## 1. Find out what you actually have
Run these before anything else. They decide whether this is a re-import or a
first proper import, and none of the answers can be guessed from outside.
```sql
-- Does an osm2pgsql import exist at all, and on what terms?
SELECT property, value FROM osm2pgsql_properties
ORDER BY property;
-- Look for: updatable, attributes, output, style,
-- import_timestamp, replication_base_url, replication_sequence_number
-- If this table does not exist, pi_osm_roads was loaded some other way
-- (ogr2ogr, a hand-written script, a one-off SQL dump).
-- Where does the current roads data live, and what is in it?
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_name LIKE '%osm%' OR table_name LIKE 'planet_osm%'
ORDER BY 1, 2;
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'pi_osm_roads'
ORDER BY ordinal_position;
-- Middle tables present? Their absence is what makes --append impossible.
SELECT to_regclass('osm.planet_osm_nodes') AS nodes,
to_regclass('osm.planet_osm_ways') AS ways,
to_regclass('osm.planet_osm_rels') AS rels;
```
**If `osm2pgsql_properties` does not exist**, there is nothing to preserve or
migrate: skip §2 and import fresh. That is the more likely case here, because a
`pi_`-prefixed table in `spatial` with exactly six columns looks like a
purpose-built extract rather than anything osm2pgsql produced.
**If it does exist**, note `replication_base_url` and
`replication_sequence_number` before touching anything — even a non-updatable
import records them, and they tell a fresh import where the data left off.
---
## 2. Clear out only what is safe to clear
Nothing in this section touches `spatial`. The live application keeps reading
`spatial.pi_osm_roads` until §7 switches it over.
```sql
CREATE SCHEMA IF NOT EXISTS osm;
```
If a previous osm2pgsql run left pgsql-output tables in `osm`, drop these four
and **no others**:
```sql
DROP TABLE IF EXISTS osm.planet_osm_point,
osm.planet_osm_line,
osm.planet_osm_polygon,
osm.planet_osm_roads;
```
`planet_osm_nodes`, `planet_osm_ways`, `planet_osm_rels` and
`planet_osm_users` look like the same family and are not — they are the middle
tables that `--slim` exists to keep, and flex uses them exactly as the pgsql
output did. Dropping them costs you `--append`, which is the point of this
exercise.
Leave `osm2pgsql_properties` alone; `--create` rewrites it.
---
## 3. The flex configuration
Save as `sql/lupmis-osm.lua` next to wherever the import is run.
Four tables, matching the four datasets requested. Every geometry is EPSG:4326,
so the API serves what the table stores and nothing transforms on read. Each
table carries a `districtid` column that the import leaves empty and §5 fills.
```lua
-- lupmis-osm.lua — osm2pgsql flex config for LUPMIS2
--
-- Four tables in the `osm` schema:
-- pi_osm_roads routable `highway` ways (LineString)
-- pi_osm_lines other linear features (LineString)
-- pi_osm_points amenities and healthcare (Point)
-- pi_osm_polygons buildings, land use, areas (MultiPolygon)
--
-- districtid is declared here but never written by the import. It is filled by
-- the spatial join in §5 of the runbook. Because osm2pgsql re-inserts a row
-- whenever its object changes, an updated row comes back with districtid NULL —
-- which is what makes the incremental re-stamp in §5.2 both simple and correct.
local srid = 4326
local roads = osm2pgsql.define_table({
name = 'pi_osm_roads', schema = 'osm',
ids = { type = 'way', id_column = 'osm_id' },
columns = {
{ column = 'highway', type = 'text' }, -- the column that was missing
{ column = 'name', type = 'text' },
{ column = 'ref', type = 'text' },
{ column = 'surface', type = 'text' },
{ column = 'oneway', type = 'text' },
{ column = 'bridge', type = 'text' },
{ column = 'tunnel', type = 'text' },
{ column = 'layer', type = 'text' },
{ column = 'districtid', type = 'int' },
{ column = 'tags', type = 'jsonb' },
{ column = 'geom', type = 'linestring', projection = srid, not_null = true },
}
})
local lines = osm2pgsql.define_table({
name = 'pi_osm_lines', schema = 'osm',
ids = { type = 'way', id_column = 'osm_id' },
columns = {
{ column = 'waterway', type = 'text' },
{ column = 'railway', type = 'text' },
{ column = 'power', type = 'text' },
{ column = 'barrier', type = 'text' },
{ column = 'name', type = 'text' },
{ column = 'districtid', type = 'int' },
{ column = 'tags', type = 'jsonb' },
{ column = 'geom', type = 'linestring', projection = srid, not_null = true },
}
})
-- Points accept nodes, ways and relations, so an amenity mapped as a building
-- outline or a multipolygon appears here as a point like any other. That is the
-- single biggest gain over the classic layout, where areas lived in
-- planet_osm_polygon and the endpoint never looked there.
local points = osm2pgsql.define_table({
name = 'pi_osm_points', schema = 'osm',
ids = { type = 'any', type_column = 'osm_type', id_column = 'osm_id' },
columns = {
{ column = 'amenity', type = 'text' },
{ column = 'healthcare', type = 'text' },
{ column = 'name', type = 'text' },
{ column = 'districtid', type = 'int' },
{ column = 'geom', type = 'point', projection = srid, not_null = true },
}
})
local polygons = osm2pgsql.define_table({
name = 'pi_osm_polygons', schema = 'osm',
ids = { type = 'any', type_column = 'osm_type', id_column = 'osm_id' },
columns = {
{ column = 'building', type = 'text' },
{ column = 'landuse', type = 'text' },
{ column = 'amenity', type = 'text' },
{ column = 'leisure', type = 'text' },
{ column = 'natural', type = 'text' },
{ column = 'name', type = 'text' },
{ column = 'districtid', type = 'int' },
{ column = 'tags', type = 'jsonb' },
{ column = 'geom', type = 'multipolygon', projection = srid, not_null = true },
}
})
-- Tags that say nothing once the typed columns exist.
local uninteresting = {
'created_by', 'source', 'source:date', 'note', 'comment',
'fixme', 'FIXME', 'attribution',
}
local function clean(tags)
for _, k in ipairs(uninteresting) do tags[k] = nil end
end
local function is_area(tags)
return tags.area == 'yes'
or tags.building or tags.landuse or tags.leisure or tags.natural
end
function osm2pgsql.process_node(object)
local t = object.tags
if t.amenity or t.healthcare then
points:insert({
amenity = t.amenity,
healthcare = t.healthcare,
name = t.name,
geom = object:as_point(),
})
end
end
function osm2pgsql.process_way(object)
local t = object.tags
clean(t)
if object.is_closed and is_area(t) then
polygons:insert({
building = t.building, landuse = t.landuse, amenity = t.amenity,
leisure = t.leisure, natural = t['natural'], name = t.name,
tags = t, geom = object:as_polygon(),
})
-- An amenity mapped as an area is also a point, so it is findable
-- alongside amenities mapped as nodes.
if t.amenity or t.healthcare then
points:insert({
amenity = t.amenity, healthcare = t.healthcare, name = t.name,
geom = object:as_polygon():centroid(),
})
end
return
end
if t.highway then
roads:insert({
highway = t.highway, name = t.name, ref = t.ref,
surface = t.surface, oneway = t.oneway,
bridge = t.bridge, tunnel = t.tunnel, layer = t.layer,
tags = t, geom = object:as_linestring(),
})
elseif t.waterway or t.railway or t.power or t.barrier then
lines:insert({
waterway = t.waterway, railway = t.railway,
power = t.power, barrier = t.barrier, name = t.name,
tags = t, geom = object:as_linestring(),
})
end
end
function osm2pgsql.process_relation(object)
local t = object.tags
clean(t)
if t.type ~= 'multipolygon' and t.type ~= 'boundary' then return end
if is_area(t) or t.amenity or t.healthcare then
polygons:insert({
building = t.building, landuse = t.landuse, amenity = t.amenity,
leisure = t.leisure, natural = t['natural'], name = t.name,
tags = t, geom = object:as_multipolygon(),
})
if t.amenity or t.healthcare then
points:insert({
amenity = t.amenity, healthcare = t.healthcare, name = t.name,
geom = object:as_multipolygon():centroid(),
})
end
end
end
```
---
## 4. Import
```bash
curl -O https://download.geofabrik.de/africa/ghana-latest.osm.pbf
osm2pgsql --create --slim --output=flex \
--style sql/lupmis-osm.lua --extra-attributes \
--database lupmis --schema osm --middle-schema osm \
--cache 2000 \
ghana-latest.osm.pbf
```
`--slim` **without** `--drop`. `--drop` discards the middle tables and is what
makes an import permanently un-updatable.
Substitute the real database name for `lupmis`.
---
## 5. Stamp the districts
osm2pgsql knows nothing about districts, so this step has no equivalent in the
standard OSM tooling. It is what makes a national import usable by an
application where every table and every endpoint is district-scoped.
### 5.1 First pass, after the initial import
Confirm the boundary table's real name and column first — the application only
ever sees it through `get_district_boundary.php`:
```sql
SELECT table_schema, table_name FROM information_schema.tables
WHERE table_name ILIKE '%district%';
```
Then, for each of the four tables (shown here for roads):
```sql
UPDATE osm.pi_osm_roads r
SET districtid = d.districtid
FROM spatial.districts d -- confirm this name first
WHERE r.districtid IS NULL
AND ST_Intersects(d.geom, r.geom);
```
A linear feature crossing a district boundary matches more than one district.
`UPDATE` takes an arbitrary one of them, which is wrong for a road that spans a
boundary. If roads must appear in every district they touch, use a join table
instead of a column:
```sql
CREATE TABLE osm.pi_osm_roads_district AS
SELECT r.osm_id, d.districtid
FROM osm.pi_osm_roads r
JOIN spatial.districts d ON ST_Intersects(d.geom, r.geom);
CREATE INDEX ON osm.pi_osm_roads_district (districtid);
```
Points never have this problem. For polygons, decide whether a district should
own an area by intersection or by where its centroid falls —
`ST_Intersects(d.geom, ST_Centroid(p.geom))` gives one district per polygon.
### 5.2 After every update
The same `UPDATE` again — `WHERE districtid IS NULL` is doing real work here,
not just guarding against repetition.
When an object changes, osm2pgsql deletes its row and re-inserts it from the
new data. The Lua config never writes `districtid`, so the re-inserted row
comes back **NULL**. `WHERE districtid IS NULL` therefore selects exactly the
objects that are new or were edited since the last stamp — including any that
moved across a boundary — and nothing else. A daily re-stamp touches a few
hundred rows rather than millions.
Put it in the same cron entry as the update, immediately after it. If the two
ever get separated, the symptom is new roads that no district can see.
---
## 6. Indexes
osm2pgsql creates the geometry and id indexes. Add what the endpoints filter on:
```sql
CREATE INDEX ON osm.pi_osm_roads (districtid);
CREATE INDEX ON osm.pi_osm_lines (districtid);
CREATE INDEX ON osm.pi_osm_points (districtid);
CREATE INDEX ON osm.pi_osm_polygons (districtid);
CREATE INDEX ON osm.pi_osm_roads (highway) WHERE highway IS NOT NULL;
CREATE INDEX ON osm.pi_osm_points (amenity) WHERE amenity IS NOT NULL;
CREATE INDEX ON osm.pi_osm_points (healthcare) WHERE healthcare IS NOT NULL;
CREATE INDEX ON osm.pi_osm_roads USING gin (tags);
CREATE INDEX ON osm.pi_osm_polygons USING gin (tags);
```
---
## 7. Expose it to the API
Keep osm2pgsql's tables in `osm`, and give the API views in `spatial` under the
existing `pi_` naming. The application then reads the names it already expects,
and no import can ever write into `spatial`.
The current `spatial.pi_osm_roads` is a table, and a view cannot replace a table
of the same name. Rename it rather than dropping it, so there is a way back
until the new endpoints have been verified:
```sql
ALTER TABLE spatial.pi_osm_roads RENAME TO pi_osm_roads_pre_osm2pgsql;
CREATE VIEW spatial.pi_osm_roads AS
SELECT osm_id, highway, name, ref, surface, oneway, districtid, geom
FROM osm.pi_osm_roads;
CREATE VIEW spatial.pi_osm_points AS
SELECT osm_type, osm_id, amenity, healthcare, name, districtid, geom
FROM osm.pi_osm_points;
CREATE VIEW spatial.pi_osm_lines AS
SELECT osm_id, waterway, railway, power, barrier, name, districtid, geom
FROM osm.pi_osm_lines;
CREATE VIEW spatial.pi_osm_polygons AS
SELECT osm_type, osm_id, building, landuse, amenity, leisure, name, districtid, geom
FROM osm.pi_osm_polygons;
```
The existing `get_osm_roads.php` keeps working unchanged and gains `highway`,
because it reads `spatial.pi_osm_roads` and the view now supplies that column.
Three new endpoints are needed, in the shape of the existing one — same
`{ api_token, district_id }` request, same `{ success, data: [...] }` response,
geometry as WKT in `geom`:
| Endpoint | Serves | Application layer |
|---|---|---|
| `get_osm_points.php` | `spatial.pi_osm_points` | OSM Points |
| `get_osm_lines.php` | `spatial.pi_osm_lines` | OSM Lines |
| `get_osm_polygons.php` | `spatial.pi_osm_polygons` | OSM Polygons |
All four belong to layer group **5, Physical Infrastructures**, which is where
`OSM_roads` already sits.
**Volumes are the thing to watch.** Roads alone are 5,238 rows for district 1
under the current extract, and the endpoint returns every row as WKT in one
response. Points and polygons will be larger. Before wiring the new layers into
the application, decide whether these endpoints should take a bounding box, or
a `highway`/`amenity` filter, rather than returning a whole district. That
decision belongs with the endpoints, not the application.
---
## 8. Keep it current
This is the part the import exists for. Everything above only pays off if the
daily update actually runs.
### 8.1 osm2pgsql-replication needs a Python that has its libraries
`osm2pgsql-replication` ships with osm2pgsql but is a **Python** script, and
package managers do not install what it imports. Its first run says:
```
Missing required Python libraries psycopg2 osmium.
To install them via pip run: pip install psycopg2 osmium
```
Following that advice often changes nothing, and it is worth understanding why
before chasing it. The script's shebang is `#!/usr/bin/env python3`, and **`env`
does not see shell aliases**. If `python3` in your shell is aliased — a MAMP
installation does exactly this, and MAMP is present on at least one machine in
this project — then `pip3 install` puts the libraries where the script will
never look. Check before doing anything:
```bash
env python3 -c "import sys; print(sys.executable)"
env python3 -c "import psycopg2, osmium; print('both present')"
```
A Homebrew Python is also likely to be PEP 668 "externally managed", which
refuses the install outright.
**On the Linux server, use distribution packages** — no virtualenv, no pip:
```bash
apt install python3-psycopg2 python3-pyosmium # Debian/Ubuntu
```
**On a workstation**, give the script an interpreter that has both, once:
```bash
python3.12 -m venv ~/.venvs/osm2pgsql
~/.venvs/osm2pgsql/bin/pip install psycopg2-binary osmium
```
`psycopg2-binary` rather than `psycopg2`: it ships as a wheel and needs no
`pg_config` or compiler. Then invoke the tool with that interpreter:
```bash
~/.venvs/osm2pgsql/bin/python $(brew --prefix)/bin/osm2pgsql-replication …
```
You know it is working when the message changes from the missing-library error
to `Updates not set up correctly. Run 'osm2pgsql-replication init' first.`
that is the tool running properly and telling you about §8.2, not about Python.
### 8.2 Initialise, then run daily
```bash
osm2pgsql-replication init \
--database lupmis --schema osm --osm-file ghana-latest.osm.pbf
```
That reads the replication URL and sequence number out of the extract's header,
so updates start exactly where the downloaded file left off.
Then daily, with the district stamp in the same job:
```bash
osm2pgsql-replication update \
--database lupmis --schema osm \
--diff-file /var/osm/diffs/$(date +%F).osc.gz \
-- --slim --output=flex --style sql/lupmis-osm.lua --extra-attributes \
--schema osm --middle-schema osm \
&& psql -d lupmis -f sql/stamp-districts.sql
```
The append updates the tables in place; there is no refresh step. `&&` rather
than `;` so a failed update does not leave the stamp running against half-applied
data.
In cron, spell out the full path to every binary — `osm2pgsql-replication`,
`psql`, and the Python interpreter if §8.1 required one. A cron job does not
inherit your shell's `PATH`, and this is the most common reason a daily update
works when run by hand and silently never runs from cron.
`--diff-file` keeps each day's changes. It is what lets you answer "what
changed near this parcel last week", which nothing else in this pipeline
records.
---
## 9. Check it worked
```sql
-- The column that started all this.
SELECT highway, count(*) FROM osm.pi_osm_roads
WHERE highway IS NOT NULL GROUP BY 1 ORDER BY 2 DESC LIMIT 12;
-- Amenities mapped as areas — these could not appear in the old layout.
SELECT osm_type, count(*) FROM osm.pi_osm_points
WHERE amenity IS NOT NULL GROUP BY 1;
-- Clinics carrying healthcare and no amenity, invisible until now.
SELECT count(*) FROM osm.pi_osm_points
WHERE amenity IS NULL AND healthcare IS NOT NULL;
-- Nothing left unstamped. A non-zero count is usually genuine — offshore
-- features, or gaps between district polygons — but check before assuming.
SELECT count(*) FROM osm.pi_osm_roads WHERE districtid IS NULL;
-- Comparable with the endpoint's 5,238 for district 1.
SELECT count(*) FROM osm.pi_osm_roads WHERE districtid = 1;
-- The doors are open this time.
SELECT property, value FROM osm.osm2pgsql_properties
WHERE property IN ('updatable','attributes','output','current_timestamp');
-- updatable and attributes must both be true.
```
Report freshness from `current_timestamp`, which moves with every append, not
`import_timestamp`, which is fixed at the original import.
Once the endpoints serve from the views and the application is verified, the
renamed original can go:
```sql
DROP TABLE spatial.pi_osm_roads_pre_osm2pgsql;
```
---
## 10. When it goes wrong
Fetch a fresh extract, re-run §4 with `--create`, re-run §5.1 and
`osm2pgsql-replication init`. Nothing in `spatial` is touched by any of it,
which is the entire reason the import lives in its own schema.

View File

@ -1,97 +0,0 @@
# District-reassignment fix — implemented
**Status:** Implemented in `public/index.php`, `main.js`, `index.html`, `public/sw.js` (v12).
**Approach chosen:** validate-once-per-session + destroy-session-on-logout (no periodic TTL polling).
---
## 1. The bug
When an administrator reassigned a user to a different district (e.g. **47 → 238**),
the PWA kept navigating to the old district even after logout, cache clear, and
re-login.
Two compounding causes:
1. **`public/index.php` validated the SSO token only when no PHP session
existed.** Once `$_SESSION['user_id']` was set, the user's fields
(`district_id`, …) were frozen for the life of that PHP session.
2. **Nothing ever destroyed the PWA's own PHP session.** The PWA
(`pwa.lupmis4luspa.org`) keeps its own `PHPSESSID` session, independent of
the central SSO portal. "Logout" cleared only the `sso_auth_token` cookie;
the `PHPSESSID` session (holding district 47) survived. Browser "clear cache"
does not delete cookies, so the stale `PHPSESSID` kept being reused — and
because a session still existed, SSO was never re-validated.
Net effect: district 47 stayed pinned until the `PHPSESSID` session was
physically destroyed (which nothing did).
## 2. The fix (as implemented)
A district changes only on transfer — a rare event — so the validated session
is correct for the entire session. We therefore do **not** poll SSO on a timer.
Instead:
- **Validate once per session, at login** — unchanged first-login validation in
`index.php` (`if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token']))`).
- **Destroy the session on logout** — a new `/?logout=1` endpoint in
`index.php` runs `session_destroy()`, expires the `PHPSESSID` cookie, clears
`sso_auth_token`, and redirects to the LUSPA portal.
A transferred user logs out → the PHP session is destroyed → on the next login
there is no session, so SSO is re-validated and the **new** `district_id` is
fetched. No periodic SSO calls; the session stays valid the whole time the user
is working.
### Logout endpoint (`public/index.php`, immediately after `session_start()`)
```php
if (isset($_GET['logout'])) {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$cp = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
}
session_destroy();
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
header('Location: https://lupmis4luspa.org/', true, 302);
exit;
}
```
The SSO validation block below it is the original first-login-only logic.
## 3. Client-side changes
- **Logout button** (`index.html` / `main.js`) — the menu's "Logout" button now
routes through `/?logout=1` instead of only clearing the cookie, and wipes the
device's district-scoped SQLite caches first.
- **Stale-district guard** (`main.js` `enforceDistrictConsistency()`) — on load,
if the session's `district_id` differs from the last-seen value in
`localStorage`, all district-scoped caches (boundary, UPN grid, parcels,
zones, roads) are cleared so the map repopulates for the new district.
- **District-keyed boundary cache** — the boundary is cached as
`district_boundary_<id>`, so one district's geometry can never be served for
another.
## 4. Why not a periodic TTL
An earlier draft re-validated SSO on a 5-minute TTL. Rejected because:
- District changes are rare (transfers only), so polling SSO on every load
wastes a blocking `curl` on the page's critical path for no benefit almost
all of the time.
- The logout→login cycle is the natural, explicit moment a transfer takes
effect, and it is now correct because logout destroys the session.
## 5. Operational notes
- **Existing stale sessions:** a transferred user who is still on an old session
must **log out and log back in** (or have their `PHPSESSID` cleared) once after
deploy to pick up the new district. After that the model is self-correcting.
- **Assumes the SSO `validate` endpoint returns the new district** — confirmed
via an incognito test (fresh session injected the correct `district_id: 238`).
- **Edge case:** switching to a different SSO user *without* logging out of the
PWA first would reuse the old PHP session. The Logout button is the supported
path; this matches the original single-user-per-device assumption.

View File

@ -1,52 +0,0 @@
Yes, that's a standard PostGIS operation. You use the `ST_Transform` function, which reprojects geometry from one spatial reference system (SRID) to another.
The basic command looks like this:
```sql
SELECT ST_Transform(geom, 4326) AS geom_wgs84
FROM your_table;
```
Here `geom` is your geometry column, which PostGIS assumes is already in EPSG:2136. If your column doesn't have its SRID set (i.e. it reports SRID 0), you first need to tell PostGIS what it actually is before transforming:
```sql
SELECT ST_Transform(ST_SetSRID(geom, 2136), 4326) AS geom_wgs84
FROM your_table;
```
`ST_SetSRID` just labels the geometry as 2136 without changing coordinates; `ST_Transform` does the actual reprojection to 4326.
If you want to permanently update the column in place rather than just query it:
```sql
UPDATE your_table
SET geom = ST_Transform(geom, 4326);
```
Note that if the column has a type modifier locking it to SRID 2136 (e.g. `geometry(Point, 2136)`), you'll need to alter the column type first, otherwise the update will be rejected:
```sql
ALTER TABLE your_table
ALTER COLUMN geom TYPE geometry(Point, 4326)
USING ST_Transform(geom, 4326);
```
A couple of things worth knowing:
Both 2136 and 4326 must exist in the `spatial_ref_sys` table for the transform to work. 4326 (WGS 84) is always there in a standard install. EPSG:2136 is Accra / Ghana National Grid and is included in the standard EPSG dataset that PostGIS ships with, so it should be present. You can confirm with:
```sql
SELECT srid FROM spatial_ref_sys WHERE srid IN (2136, 4326);
```
If 2136 is somehow missing, you can insert its definition manually from spatialreference.org or epsg.io.
You can quickly test a single transformation without touching your table:
```sql
SELECT ST_AsText(ST_Transform(ST_SetSRID(ST_MakePoint(400000, 300000), 2136), 4326));
```
That returns the WGS 84 longitude/latitude for a sample projected coordinate, which is a good sanity check that the datum shift is being applied correctly.
Want me to tailor the exact command to your table and column names?

View File

@ -1,142 +0,0 @@
# ============================================================================
# LUPMIS2 — Apache hardening against source-code and secret disclosure
# ============================================================================
#
# WHY THIS EXISTS
# ---------------
# This is preventive hardening. No LUPMIS host is known to have been
# compromised — these rules are here so that the most common automated attack
# against a PHP deployment cannot succeed.
#
# Scanners continuously probe every public host for two things:
#
# /.env — application secrets: database passwords, API keys
# /.git/… — the repository, if a working copy was deployed
#
# The second is the more damaging of the two. Given /.git/index together with
# /.git/objects, an attacker can reconstruct the entire repository: every file
# and every past commit. That means any credential ever committed is exposed,
# even if it has since been changed, because it remains in the history.
#
# That last point is directly relevant to LUPMIS. The minio-uploads
# integration has had access keys written into upload.php, download.php and
# get_file_url.php, and those files are tracked in Gitea — so the keys are in
# the history regardless of later rotation. The exposure is only theoretical
# while that repository is private and its .git directory is not web-served;
# these rules keep the second half of that sentence true.
#
# WHICH HOSTS
# -----------
# Apply to EVERY vhost. The risk is not equal across them:
#
# • The PWA is not currently exposed — its document root is dist/, which
# contains neither .env nor .git.
# • Any app deployed by copying a working directory (a git clone) into the
# document root is exposed by construction. The minio-uploads project, for
# example, carries a .git directory and composer files.
#
# Checking is quick, from outside the network:
# curl -sS -o /dev/null -w '%{http_code}\n' https://HOST/.env
# curl -sS -o /dev/null -w '%{http_code}\n' https://HOST/.git/config
# Anything other than 403 or 404 needs attention.
#
# HOW TO USE
# ----------
# Preferred — in the vhost or server config, where it cannot be disabled by a
# stray AllowOverride and applies to the whole tree:
#
# <VirtualHost *:443>
# ...
# Include /etc/apache2/conf-available/lupmis-hardening.conf
# </VirtualHost>
#
# Then: a2enconf lupmis-hardening && apachectl configtest && systemctl reload apache2
#
# If you can only use .htaccess, the equivalent rules are already in the PWA's
# public/.htaccess — but note .htaccess needs AllowOverride to be enabled, so
# the vhost form is the safer of the two.
#
# THIS IS DEFENCE IN DEPTH, NOT THE FIX
# -------------------------------------
# The real fix is that .git and .env must not be inside a document root at all.
# Deploy build output (or `git archive`), not a working copy, and keep secrets
# in environment variables outside the served tree. These rules are what
# protects you when that goes wrong again.
# ============================================================================
# ---------------------------------------------------------------------------
# 1. Hidden directories and files (.git, .env, .svn, .hg, .DS_Store, …)
# ---------------------------------------------------------------------------
# <DirectoryMatch> is server-config only, which is exactly the context this
# file is meant for, and it covers the whole path — so unlike <FilesMatch> it
# does stop /.git/config.
#
# .well-known is exempt on purpose: blocking it breaks Let's Encrypt (ACME)
# certificate issuance and renewal.
<DirectoryMatch "/\.(?!well-known)">
Require all denied
</DirectoryMatch>
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# mod_rewrite equivalent — also covers odd forms such as /./.env, which the
# captured log shows being used, and returns 404 rather than 403 so a probe
# learns nothing about what is present.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule (^|/)\. - [R=404,L]
</IfModule>
# ---------------------------------------------------------------------------
# 2. Files that should never be served, if one lands in the document root
# ---------------------------------------------------------------------------
<FilesMatch "\.(sql|sqlite|sqlite3|db|bak|backup|old|orig|save|swp|swo|log|ini|conf|key|pem|crt|p12|pfx|yml|yaml)$">
Require all denied
</FilesMatch>
<FilesMatch "^(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml|Makefile|README\.md|\.htpasswd)$">
Require all denied
</FilesMatch>
# ---------------------------------------------------------------------------
# 3. Do not advertise the software version, and do not list directories
# ---------------------------------------------------------------------------
# ServerTokens / ServerSignature are global directives — put them in the main
# server config, not inside a <VirtualHost>.
#
# ServerTokens Prod
# ServerSignature Off
Options -Indexes
# ---------------------------------------------------------------------------
# 4. Proxy — make the logs usable
# ---------------------------------------------------------------------------
# Where Apache sits behind the openresty reverse proxy, it logs the proxy's
# Docker-internal address rather than the real caller. If that is the case
# here, an access log cannot tell you who probed the site, and the traffic
# cannot be blocked or reported. Have the proxy pass the caller through:
#
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Real-IP $remote_addr;
#
# and have Apache trust it, so %a in the log is the real address:
#
# RemoteIPHeader X-Forwarded-For
# RemoteIPInternalProxy 172.20.0.0/16
# LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
#
# (RemoteIPHeader needs mod_remoteip: a2enmod remoteip)
#
# Blocking at the proxy as well as here is worthwhile — it stops the request
# before it reaches the application at all:
#
# location ~ /\.(?!well-known) { return 404; }

View File

@ -1,446 +0,0 @@
-- ============================================================================
-- LUPMIS2 — external layer definitions
--
-- Stores the layers a user adds through the map's "Add External Layer" dialog,
-- so that they persist, can be shared within a district, and survive a
-- re-install of the application.
--
-- Two tables:
--
-- spatial.hlp_layer_types the kinds of layer that can be added
-- (wms, wfs, xyz, cog, and whatever comes next)
-- spatial.es_external_layers one row per layer a user has added
--
-- The four fields the dialog collects are layer_type, url, layer_name and
-- title. Everything else in es_external_layers exists for one of three reasons,
-- marked in the column comments:
--
-- [dialog] captured directly from the Add External Layer form
-- [parity] needed to describe the external layers the application already
-- hard-codes; without these, moving those layers into the database
-- would lose information the map currently relies on
-- [admin] ownership, scoping, ordering and audit
--
-- Target: PostgreSQL 10+ (uses GENERATED ALWAYS AS IDENTITY).
-- Schema: `spatial` is used for consistency with the rest of LUPMIS, although
-- neither table holds geometry — see the notes at the end.
--
-- NOTE ON SEQUENCING. The application still hard-codes the four layer types in
-- the Add External Layer dialog and will keep doing so until the endpoint in
-- note 4 exists. Until then hlp_layer_types is the database's own record, and
-- the two must be kept in step by hand: adding a row here does not make the
-- type appear in the dialog, and adding a radio button there without a row
-- here will be rejected by the foreign key.
-- ============================================================================
-- ============================================================================
-- 1. Helper table — the available layer types
--
-- This exists so a new kind of layer (WMTS, ArcGIS REST, vector tiles, a
-- GeoPackage URL) can be introduced by inserting a row, with no change to the
-- schema, no deployment and no code release on the database side.
--
-- `hlp_` marks it as a lookup/helper table rather than data.
-- ============================================================================
CREATE TABLE spatial.hlp_layer_types (
code text PRIMARY KEY,
-- the value stored in es_external_layers.layer_type and
-- sent in the API payload: lowercase, no spaces
label text NOT NULL,
-- what the dialog shows on the button, e.g. 'WMS'
description text,
-- one line explaining the type, for a tooltip or docs
-- ---- rules the client and the database both need -----------------------
requires_layer_name boolean NOT NULL DEFAULT false,
-- true for WMS/WFS, where a service publishes many
-- layers and one must be named. This is the rule that
-- used to be a hard-coded CHECK constraint.
layer_name_label text,
-- what to call that field for this type, e.g.
-- 'WMS LAYERS parameter' vs 'WFS typename'
url_placeholder text,
-- example URL shown in the empty form field
hint text,
-- extra guidance shown when the type is selected
-- ---- housekeeping ------------------------------------------------------
sort_order integer NOT NULL DEFAULT 0,
is_active boolean NOT NULL DEFAULT true,
-- false retires a type: no new layers may use it, but
-- existing layers of that type keep working
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- The code travels through URLs, JSON payloads and CSS class names, so it
-- is restricted to a plain lowercase identifier.
CONSTRAINT hlp_layer_types_code_chk
CHECK (code ~ '^[a-z][a-z0-9_]*$')
);
COMMENT ON TABLE spatial.hlp_layer_types IS
'Lookup of layer types available in the Add External Layer dialog. A new '
'type is added by inserting a row — no schema change required.';
COMMENT ON COLUMN spatial.hlp_layer_types.requires_layer_name IS
'Whether es_external_layers.layer_name is mandatory for this type. '
'Enforced by the trigger on that table, because a CHECK constraint cannot '
'read another table.';
COMMENT ON COLUMN spatial.hlp_layer_types.is_active IS
'false stops new layers being created with this type without invalidating '
'the ones that already exist.';
INSERT INTO spatial.hlp_layer_types
(code, label, description,
requires_layer_name, layer_name_label, url_placeholder, hint, sort_order)
VALUES
('wms', 'WMS', 'OGC Web Map Service — server-rendered map images.',
true, 'WMS LAYERS parameter (e.g. workspace:layer)',
'https://example.com/wms', NULL, 10),
('wfs', 'WFS', 'OGC Web Feature Service — vector features as GeoJSON.',
true, 'WFS typename (e.g. workspace:layer)',
'https://example.com/wfs', NULL, 20),
('xyz', 'XYZ', 'Pre-rendered tile pyramid addressed by z/x/y.',
false, NULL,
'https://example.com/tiles/{z}/{x}/{y}.png', NULL, 30),
('cog', 'COG', 'Cloud-Optimized GeoTIFF streamed by byte-range request.',
false, NULL,
'https://example.com/data/elevation_cog.tif',
'The server must allow CORS and byte-range requests.', 40);
-- ============================================================================
-- 2. The layers themselves
-- ============================================================================
CREATE TABLE spatial.es_external_layers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- ---- from the Add External Layer dialog -------------------------------
layer_type text NOT NULL
REFERENCES spatial.hlp_layer_types (code)
ON UPDATE CASCADE ON DELETE RESTRICT,
-- [dialog] see hlp_layer_types
url text NOT NULL, -- [dialog] service or file URL
layer_name text, -- [dialog] required when the type
-- says so; see the trigger
title text NOT NULL, -- [dialog] name in the layer switcher
-- ---- scoping and ownership -------------------------------------------
districtid integer, -- [admin] NULL = every district
userid integer NOT NULL, -- [admin] who added it
is_shared boolean NOT NULL DEFAULT false,
-- [admin] false = creator only;
-- true = the district
-- ---- rendering -------------------------------------------------------
layer_group_id integer NOT NULL DEFAULT 8,
-- [admin] 8 = "External Sources"
style_name text, -- [parity] WMS STYLES parameter
server_type text, -- [parity] geoserver | mapserver | qgis | NULL
opacity numeric(3,2) NOT NULL DEFAULT 1.00, -- [parity]
z_index integer, -- [parity] negative sits behind overlays
visible_by_default boolean NOT NULL DEFAULT false, -- [parity]
online_only boolean NOT NULL DEFAULT false,
-- [parity] warn when toggled on offline
attribution text, -- [parity] credit/licence; often required
legend_url text, -- [parity] legend shown while visible
min_zoom smallint, -- [parity]
max_zoom smallint, -- [parity]
-- ---- lifecycle and audit ---------------------------------------------
description text, -- [admin] free note about the source
is_active boolean NOT NULL DEFAULT true, -- [admin] soft delete
sort_order integer NOT NULL DEFAULT 0, -- [admin]
created_at timestamptz NOT NULL DEFAULT now(), -- [admin]
updated_at timestamptz NOT NULL DEFAULT now(), -- [admin]
updated_by integer, -- [admin]
-- ---- constraints ------------------------------------------------------
-- layer_type is validated by the foreign key above, and the conditional
-- layer_name rule by the trigger below, because a CHECK constraint cannot
-- read another table.
-- The PWA is served over HTTPS, so a browser blocks any http:// sub-resource
-- as mixed content. An http URL cannot render, it just fails silently — so
-- it is rejected here rather than stored and puzzled over later.
CONSTRAINT es_external_layers_https_chk
CHECK (url ~* '^https://'),
CONSTRAINT es_external_layers_opacity_chk
CHECK (opacity >= 0 AND opacity <= 1),
CONSTRAINT es_external_layers_zoom_chk
CHECK (min_zoom IS NULL OR max_zoom IS NULL OR min_zoom <= max_zoom)
);
-- ---------------------------------------------------------------------------
-- The rule that moved out of the schema and into the data
--
-- `requires_layer_name` now lives in hlp_layer_types, so it has to be enforced
-- somewhere that can read it. A CHECK constraint cannot; a trigger can.
--
-- It raises check_violation deliberately, so any API error handling that
-- already distinguishes constraint failures keeps working unchanged.
--
-- The is_active test applies to INSERT only: retiring a type should stop new
-- layers being created with it without freezing the ones that already exist.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION spatial.es_external_layers_validate()
RETURNS trigger AS $$
DECLARE
t spatial.hlp_layer_types%ROWTYPE;
BEGIN
SELECT * INTO t
FROM spatial.hlp_layer_types
WHERE code = NEW.layer_type;
-- The foreign key guarantees a row exists, so no NOT FOUND branch here.
IF TG_OP = 'INSERT' AND NOT t.is_active THEN
RAISE EXCEPTION 'Layer type "%" is retired and cannot be used for new layers',
NEW.layer_type
USING ERRCODE = 'check_violation';
END IF;
IF t.requires_layer_name
AND (NEW.layer_name IS NULL OR btrim(NEW.layer_name) = '') THEN
RAISE EXCEPTION 'Layer type "%" requires layer_name (%)',
NEW.layer_type, COALESCE(t.layer_name_label, 'layer name')
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER es_external_layers_validate_trg
BEFORE INSERT OR UPDATE ON spatial.es_external_layers
FOR EACH ROW EXECUTE FUNCTION spatial.es_external_layers_validate();
-- ---------------------------------------------------------------------------
-- Foreign keys to districts and users
--
-- Left commented because the referenced table and column names were not
-- verifiable from the application side — it only ever sees districts and users
-- through the API. Confirm them, then enable:
--
-- SELECT table_schema, table_name FROM information_schema.tables
-- WHERE table_name ILIKE '%district%' OR table_name ILIKE '%user%';
--
-- ON DELETE RESTRICT for the district: removing a district that still has
-- layers attached should be a decision, not a side effect.
-- ---------------------------------------------------------------------------
-- ALTER TABLE spatial.es_external_layers
-- ADD CONSTRAINT es_external_layers_district_fk
-- FOREIGN KEY (districtid) REFERENCES spatial.districts (districtid)
-- ON DELETE RESTRICT;
--
-- ALTER TABLE spatial.es_external_layers
-- ADD CONSTRAINT es_external_layers_user_fk
-- FOREIGN KEY (userid) REFERENCES public.users (id)
-- ON DELETE RESTRICT;
-- ---------------------------------------------------------------------------
-- Stop the same layer being added twice
--
-- COALESCE is needed because NULL never equals NULL: without it a duplicate
-- would be allowed whenever districtid or layer_name is NULL, which is exactly
-- the XYZ and COG case.
-- ---------------------------------------------------------------------------
CREATE UNIQUE INDEX es_external_layers_unique_idx
ON spatial.es_external_layers (
COALESCE(districtid, -1),
layer_type,
url,
COALESCE(layer_name, '')
)
WHERE is_active;
-- ---------------------------------------------------------------------------
-- Indexes for the read path
-- ---------------------------------------------------------------------------
-- The endpoint's main query: everything this district may see, which is the
-- district's own layers plus the global ones (districtid IS NULL).
CREATE INDEX es_external_layers_district_idx
ON spatial.es_external_layers (districtid, sort_order)
WHERE is_active;
CREATE INDEX es_external_layers_user_idx
ON spatial.es_external_layers (userid)
WHERE is_active;
-- Supports the ON DELETE RESTRICT check on hlp_layer_types, and "how many
-- layers use this type" questions.
CREATE INDEX es_external_layers_type_idx
ON spatial.es_external_layers (layer_type);
-- ---------------------------------------------------------------------------
-- Keep updated_at honest, on both tables
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION spatial.touch_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER es_external_layers_touch_trg
BEFORE UPDATE ON spatial.es_external_layers
FOR EACH ROW EXECUTE FUNCTION spatial.touch_updated_at();
CREATE TRIGGER hlp_layer_types_touch_trg
BEFORE UPDATE ON spatial.hlp_layer_types
FOR EACH ROW EXECUTE FUNCTION spatial.touch_updated_at();
-- ---------------------------------------------------------------------------
-- Documentation that travels with the schema
-- ---------------------------------------------------------------------------
COMMENT ON TABLE spatial.es_external_layers IS
'External map layers added by users through the Add External Layer dialog. '
'One row per layer. Holds no geometry — the data stays on the remote server '
'and is fetched by the client at display time.';
COMMENT ON COLUMN spatial.es_external_layers.layer_type IS
'References spatial.hlp_layer_types.code. Determines how the client builds '
'the request and which other columns apply.';
COMMENT ON COLUMN spatial.es_external_layers.url IS
'WMS/WFS service endpoint, XYZ template with {z}/{x}/{y}, or the URL of a '
'Cloud-Optimized GeoTIFF. Must be https — see the mixed-content constraint.';
COMMENT ON COLUMN spatial.es_external_layers.layer_name IS
'WMS LAYERS parameter or WFS typename, e.g. workspace:layer. Required only '
'for types whose hlp_layer_types.requires_layer_name is true.';
COMMENT ON COLUMN spatial.es_external_layers.districtid IS
'NULL means the layer is offered in every district. A value restricts it '
'to that district.';
COMMENT ON COLUMN spatial.es_external_layers.is_shared IS
'false: visible only to the user who created it. true: visible to everyone '
'within its district scope.';
COMMENT ON COLUMN spatial.es_external_layers.is_active IS
'Soft delete. The dialog marks user-added layers as removable, and a hard '
'delete would silently break the map for colleagues who rely on a shared '
'layer, with no way to tell what disappeared.';
COMMENT ON COLUMN spatial.es_external_layers.online_only IS
'The layer needs connectivity. The client uses this to explain, rather '
'than show an empty layer, when it is switched on in the field.';
COMMENT ON COLUMN spatial.es_external_layers.z_index IS
'Render order. Negative values push a layer behind the standard overlays — '
'used for background imagery such as slope or hillshade.';
-- ---------------------------------------------------------------------------
-- Examples
--
-- The first is a layer the application currently hard-codes. It is the reason
-- for the [parity] columns: without style_name, opacity, z_index, attribution,
-- legend_url and online_only, this layer could not be represented here.
-- ---------------------------------------------------------------------------
INSERT INTO spatial.es_external_layers
(layer_type, url, layer_name, title, districtid, userid, is_shared,
style_name, server_type, opacity, z_index, visible_by_default,
online_only, attribution, legend_url, description)
VALUES
('wms',
'https://ows.digitalearth.africa/wms',
'srtm_deriv',
'DEAfrica Slope (SRTM 30m)',
NULL, -- every district
1,
true,
'style_slope',
NULL, -- not a GeoServer; serverType must stay unset
0.50,
-50, -- behind the standard overlays
false,
true,
'&copy; <a href="https://www.digitalearthafrica.org/">Digital Earth Africa</a> — SRTM-derived Slope',
'https://ows.digitalearth.africa/legend/srtm_deriv/style_slope/legend.png',
'Terrain slope derived from SRTM, served by Digital Earth Africa.');
-- A raster published to the LUSPA object store: no layer name, single file.
INSERT INTO spatial.es_external_layers
(layer_type, url, title, districtid, userid, is_shared, description)
VALUES
('cog',
'https://minioapi.lupmis4luspa.org/raster-objects/dem/koforidua_dem.tif',
'Koforidua DEM',
1,
1,
true,
'Digital elevation model for the district, published as a Cloud-Optimized GeoTIFF.');
-- ============================================================================
-- Adding a layer type later
--
-- This is the whole point of the helper table. No schema change, no migration:
--
-- INSERT INTO spatial.hlp_layer_types
-- (code, label, description, requires_layer_name, layer_name_label,
-- url_placeholder, sort_order)
-- VALUES
-- ('wmts', 'WMTS', 'OGC Web Map Tile Service — pre-rendered tiles.',
-- true, 'WMTS layer identifier',
-- 'https://example.com/wmts', 50);
--
-- Retiring one, without invalidating the layers already using it:
--
-- UPDATE spatial.hlp_layer_types SET is_active = false WHERE code = 'wfs';
--
-- Deleting a type that is still in use is refused by the foreign key, which is
-- the intended behaviour — retire it instead.
-- ============================================================================
-- ============================================================================
-- Notes for the database team
--
-- 1. Schema choice. `spatial` matches the rest of LUPMIS, but neither table
-- holds geometry — they are configuration. If there is a schema for
-- application settings they belong there instead; only the qualifier changes.
--
-- 2. Prefixes. `hlp_` marks a helper/lookup table, as requested. `es_` follows
-- the existing convention of naming a table after its layer group (`be_`
-- biophysical, `lu_` land use, `pi_` physical infrastructure); group 8 is
-- "External Sources".
--
-- 3. The client still hard-codes the four types. Until the endpoint below
-- exists, the dialog in src/components/MapView.js and the rows in
-- hlp_layer_types must be changed together. Adding a row here alone will not
-- surface the type in the application; adding a radio button there alone
-- will fail the foreign key on save.
--
-- 4. Suggested endpoints, in the shape of the existing spatial_planning ones
-- ({ api_token, district_id } in, { success, data: [...] } out):
--
-- get_layer_types.php rows where is_active, ordered by sort_order
-- — this is what lets the dialog stop
-- hard-coding the list
-- get_external_layers.php rows where is_active
-- and (districtid = :district OR districtid IS NULL)
-- and (is_shared OR userid = :user)
-- ordered by sort_order, title
-- save_external_layer.php insert or update one row
-- delete_external_layer.php sets is_active = false, never DELETE
--
-- 5. Authorisation is the part worth deciding before this ships. The read query
-- above assumes a user may see their own layers plus shared ones in their
-- district. Who may edit or deactivate a *shared* layer — only its creator,
-- or any user in the district — is a policy question this table can support
-- either way, through userid.
--
-- 6. The url column holds a value the browser will fetch. Two consequences:
-- the https constraint is functional, not cosmetic; and the endpoint should
-- validate the URL server-side as well, since a stored URL is effectively an
-- instruction to every client that loads the layer.
-- ============================================================================

View File

@ -1,83 +0,0 @@
# LUPMIS2 raster ETL
One-off batch jobs that prepare raster data for LUPMIS2 and publish it to the
LUSPA MinIO object store, from where the PWA streams it as a Cloud-Optimized
GeoTIFF (COG).
This mirrors the existing OpenTopography contour workflow: a **preparation
step run occasionally**, not a live connection from the app.
```
DE Africa (Sandbox) → write_cog() → MinIO raster-objects → LUPMIS2 map
```
## Target infrastructure
| | |
|---|---|
| S3 API endpoint | `https://minioapi.lupmis4luspa.org` (path-style) |
| Bucket | `raster-objects` (anonymous read enabled) |
| Console | `https://minio.lupmis4luspa.org` — admin UI only, **not** the API |
Verified working for COG streaming: anonymous unsigned `GET` returns 200,
range requests return `206 Partial Content`, and CORS allows the PWA origin
with `Range` on preflight and `Content-Range` exposed.
## Credentials
Supplied via environment variables — **never** hardcode them:
```bash
export MINIO_KEY=...
export MINIO_SECRET=...
```
The PHP integration under `FromKwesi/minio-uploads` embeds its keys in source,
and an earlier key pair was committed to Gitea. Keep these out of git.
## `deafrica_dem_to_minio.py`
Exports a Digital Earth Africa DEM for an area of interest and uploads it.
Run it in the **DE Africa Sandbox** — the Open Data Cube is configured there.
Locally, DE Africa's docs note that `dc.load` / `load_ard` need additional
configuration (a database), so the Sandbox is the lower-maintenance choice.
```bash
# 1. confirm which DEM products exist before the first real run
python deafrica_dem_to_minio.py --list-products
# 2. export + upload a pilot district
python deafrica_dem_to_minio.py --district koforidua
# preferred for production: clip to the authoritative boundary
python deafrica_dem_to_minio.py --geojson koforidua.geojson --name koforidua
# inspect the output without uploading
python deafrica_dem_to_minio.py --district tamale --dry-run
```
The script writes `<name>_dem.tif`, uploads it to `dem/<name>_dem.tif`, then
verifies the public URL really is anonymously readable and range-capable. It
also emits `<name>_dem.manifest.json` recording the URL, product, bbox, CRS and
resolution.
### Loading the result in LUPMIS2
**Add External Layer → COG →** paste the object URL, e.g.
```
https://minioapi.lupmis4luspa.org/raster-objects/dem/koforidua_dem.tif
```
### Notes and caveats
- **Preset district bounding boxes are approximate.** Use `--geojson` with the
real district boundary from PostGIS for anything beyond a first test.
- **Output CRS defaults to EPSG:3857** so the browser does no reprojection.
For Stage 3 hydrology and slope, re-export in a metric CRS (UTM 30N =
`EPSG:32630` west, 31N = `EPSG:32631` east) — Web Mercator distorts distance
with latitude and will bias slope and flow-accumulation results.
- **Verify the product name** with `--list-products` first. The default is
`dem_srtm`; DE Africa also publishes derivatives (`dem_srtm_deriv`), which is
the source of the slope WMS layer LUPMIS2 already consumes.

View File

@ -1,298 +0,0 @@
#!/usr/bin/env python3
"""
deafrica_dem_to_minio.py
========================
One-off ETL: Digital Earth Africa DEM Cloud-Optimized GeoTIFF LUSPA MinIO.
This is Stage 1 of the LUPMIS2 GIS Analytical Tools concept: get a real raster
into the object store so the PWA can display it via MapView.addCOGLayer().
Designed to run inside the **DE Africa Sandbox** (which has the Open Data Cube
already configured). It can also run locally, but note DE Africa's own docs:
locally, `dc.load` / `load_ard` need extra configuration (a database) use the
Sandbox unless exports become routine.
# 0. see which DEM products actually exist (do this first)
python deafrica_dem_to_minio.py --list-products
# 1. export + upload for a pilot district
export MINIO_KEY=... # never hardcode these
export MINIO_SECRET=...
python deafrica_dem_to_minio.py --district koforidua
# or an explicit area / precise boundary
python deafrica_dem_to_minio.py --bbox -0.35 6.00 -0.15 6.20 --name koforidua
python deafrica_dem_to_minio.py --geojson district.geojson --name koforidua
Credentials come from the environment (MINIO_KEY / MINIO_SECRET) on purpose:
the existing PHP integration hardcodes them, and an earlier key pair ended up
committed to Gitea. Keep them out of this file and out of git.
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
MINIO_ENDPOINT = "https://minioapi.lupmis4luspa.org"
MINIO_REGION = "gh-greater-accra-luspa"
BUCKET = "raster-objects"
KEY_PREFIX = "dem" # objects land at dem/<name>_dem.tif
# Approximate bounding boxes (EPSG:4326: min_lon, min_lat, max_lon, max_lat).
# These are convenience defaults only — for production use --geojson with the
# authoritative district boundary from PostGIS so the clip matches the data.
DISTRICTS = {
"koforidua": (-0.35, 6.00, -0.15, 6.20), # New Juaben / Koforidua area
"tamale": (-1.10, 9.20, -0.60, 9.60), # Tamale metropolitan area
}
# DE Africa DEM product. Verify against --list-products before a real run:
# DE Africa publishes SRTM ('dem_srtm') and its derivatives ('dem_srtm_deriv',
# the source of the slope WMS layer LUPMIS2 already uses).
DEFAULT_PRODUCT = "dem_srtm"
# EPSG:3857 matches the map view, so the browser does no reprojection.
# NOTE: for Stage 3 hydrology/slope, prefer a metric CRS (UTM 30N = EPSG:32630
# for western Ghana, 31N = EPSG:32631 for the east) — Web Mercator distorts
# distance with latitude and will bias slope and flow calculations.
DEFAULT_CRS = "EPSG:3857"
DEFAULT_RES = 30 # metres — SRTM native resolution
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def list_products():
"""Print datacube products whose name mentions elevation/DEM/SRTM."""
import datacube
dc = datacube.Datacube(app="lupmis2_dem_discovery")
products = dc.list_products()
mask = products["name"].str.contains("dem|srtm|elev", case=False, na=False)
hits = products[mask][["name", "description"]]
if hits.empty:
print("No DEM-like products found. All available products:\n")
print(products[["name", "description"]].to_string())
else:
print("DEM-related products:\n")
print(hits.to_string(index=False))
def bbox_from_geojson(path):
"""Bounding box (min_lon, min_lat, max_lon, max_lat) of a GeoJSON file."""
with open(path) as fh:
gj = json.load(fh)
xs, ys = [], []
def walk(coords):
if isinstance(coords[0], (int, float)):
xs.append(coords[0]); ys.append(coords[1]); return
for c in coords:
walk(c)
feats = gj.get("features", [gj])
for f in feats:
geom = f.get("geometry", f)
if geom and geom.get("coordinates"):
walk(geom["coordinates"])
if not xs:
raise SystemExit(f"No coordinates found in {path}")
return (min(xs), min(ys), max(xs), max(ys))
def load_dem(bbox, product, crs, res):
"""Load the DEM for a bounding box and return a 2-D DataArray."""
import datacube
dc = datacube.Datacube(app="lupmis2_dem_etl")
min_lon, min_lat, max_lon, max_lat = bbox
print(f" loading '{product}' for bbox {bbox} at {res} m in {crs}")
ds = dc.load(
product=product,
x=(min_lon, max_lon),
y=(min_lat, max_lat),
output_crs=crs,
resolution=(-res, res),
# DEMs are static, so any single observation is the whole story.
dask_chunks={},
)
if not ds.data_vars:
raise SystemExit(
f"'{product}' returned no data for this area. "
f"Check the product name with --list-products and confirm coverage."
)
# Take the first band (SRTM DEMs expose a single elevation band) and drop
# the time dimension if the product carries one.
band = list(ds.data_vars)[0]
da = ds[band]
if "time" in da.dims:
da = da.isel(time=0)
print(f" band '{band}', shape {tuple(da.shape)}")
return da.compute() if hasattr(da, "compute") else da
def upload(local_path, key, endpoint, bucket, region):
"""Upload to MinIO. Credentials come from the environment."""
import boto3
from botocore.client import Config
access = os.environ.get("MINIO_KEY")
secret = os.environ.get("MINIO_SECRET")
if not access or not secret:
raise SystemExit(
"Set MINIO_KEY and MINIO_SECRET in the environment.\n"
" export MINIO_KEY=...\n export MINIO_SECRET=..."
)
s3 = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access,
aws_secret_access_key=secret,
region_name=region,
config=Config(s3={"addressing_style": "path"}), # MinIO is path-style
)
size_mb = os.path.getsize(local_path) / 1e6
print(f" uploading {size_mb:.1f} MB → s3://{bucket}/{key}")
s3.upload_file(
local_path, bucket, key,
# Serve a real raster content-type; the PWA's COG pre-flight check
# rejects text/html and reports the type it actually received.
ExtraArgs={"ContentType": "image/tiff"},
)
return f"{endpoint}/{bucket}/{key}"
def verify(url):
"""Confirm the object is anonymously readable and supports range requests."""
import urllib.request
import urllib.error
def probe(headers=None):
req = urllib.request.Request(url, headers=headers or {})
return urllib.request.urlopen(req, timeout=30)
try:
r = probe()
ok_get = r.status == 200
ctype = r.headers.get("content-type")
except urllib.error.HTTPError as e:
print(f" ✗ anonymous GET failed: HTTP {e.code} — is the bucket policy public?")
return False
except Exception as e: # noqa: BLE001
print(f" ✗ anonymous GET failed: {e}")
return False
try:
r2 = probe({"Range": "bytes=0-9"})
ok_range = r2.status == 206
except urllib.error.HTTPError as e:
ok_range = e.code == 206
except Exception: # noqa: BLE001
ok_range = False
print(f" {'' if ok_get else ''} anonymous GET (content-type: {ctype})")
print(f" {'' if ok_range else ''} range requests (206 Partial Content)")
return ok_get and ok_range
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--list-products", action="store_true",
help="list DEM-related datacube products and exit")
ap.add_argument("--district", choices=sorted(DISTRICTS),
help="use a preset (approximate) district bounding box")
ap.add_argument("--bbox", nargs=4, type=float,
metavar=("MIN_LON", "MIN_LAT", "MAX_LON", "MAX_LAT"))
ap.add_argument("--geojson", help="clip to the bounding box of this GeoJSON (preferred)")
ap.add_argument("--name", help="short name used in the object key")
ap.add_argument("--product", default=DEFAULT_PRODUCT)
ap.add_argument("--crs", default=DEFAULT_CRS)
ap.add_argument("--res", type=float, default=DEFAULT_RES, help="resolution in metres")
ap.add_argument("--outdir", default=".")
ap.add_argument("--dry-run", action="store_true",
help="write the COG locally but do not upload")
args = ap.parse_args()
if args.list_products:
list_products()
return
# ---- resolve the area of interest ----
if args.geojson:
bbox = bbox_from_geojson(args.geojson)
name = args.name or os.path.splitext(os.path.basename(args.geojson))[0]
elif args.district:
bbox = DISTRICTS[args.district]
name = args.name or args.district
print("NOTE: preset bounding boxes are approximate. For production use "
"--geojson with the authoritative district boundary.")
elif args.bbox:
bbox = tuple(args.bbox)
name = args.name or "aoi"
else:
ap.error("choose an area: --district, --bbox or --geojson")
name = name.lower().replace(" ", "_")
fname = os.path.join(args.outdir, f"{name}_dem.tif")
key = f"{KEY_PREFIX}/{name}_dem.tif"
print(f"\nLUPMIS2 · DE Africa DEM → MinIO")
print(f" area '{name}' bbox={bbox}")
# ---- load + write COG ----
from datacube.utils.cog import write_cog
da = load_dem(bbox, args.product, args.crs, args.res)
print(f" writing COG → {fname}")
out = write_cog(da, fname=fname, overwrite=True)
if hasattr(out, "compute"): # dask-backed writes are lazy
out.compute()
if args.dry_run:
print(f"\nDry run complete: {fname} ({os.path.getsize(fname)/1e6:.1f} MB)")
return
# ---- upload + verify ----
url = upload(fname, key, MINIO_ENDPOINT, BUCKET, MINIO_REGION)
print(f"\n object URL: {url}")
ok = verify(url)
manifest = {
"name": f"{name.title()} DEM (SRTM 30 m, DE Africa)",
"url": url,
"key": key,
"product": args.product,
"bbox_4326": list(bbox),
"crs": args.crs,
"resolution_m": args.res,
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"verified": bool(ok),
}
mpath = os.path.join(args.outdir, f"{name}_dem.manifest.json")
with open(mpath, "w") as fh:
json.dump(manifest, fh, indent=2)
print(f" manifest → {mpath}")
if ok:
print("\nReady. In LUPMIS2: Add External Layer → COG → paste the object URL above.")
else:
print("\nUploaded, but the public read/range check failed — the PWA will not "
"be able to stream it until that is resolved.")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -112,8 +112,6 @@
--destructive-foreground: #fff;
--border: #1e1a4b1f;
--ring: var(--brand-blue-strong);
/* Floating map panels (drawing rail, its option bars) */
--bar-bg: rgba(255, 255, 255, 0.94);
/* Typography */
--font-display: "Bebas Neue", sans-serif;
@ -875,30 +873,6 @@
font-family: var(--font-body);
}
/* Narrow screens: share the width evenly rather than relying on each
button's 60px minimum, which no longer fits once the dock is padded
(6 x 60 + 32 = 392px, against 390px on the common iPhone widths).
Equal division also keeps the row visually regular now that Add Location
has gone and the count changed. */
@media (max-width: 576px) {
.bottom-dock {
justify-content: space-between;
gap: 4px;
padding-left: 8px;
padding-right: 8px;
}
.dock-btn {
flex: 1 1 0;
min-width: 0;
padding: 6px 2px;
font-size: 1.25rem;
}
.dock-btn-label {
font-size: 0.58rem;
letter-spacing: -0.01em;
}
}
/* EditBar colour picker inline styling */
.ol-editbar .ol-colorpicker button {
padding: 2px;
@ -1080,8 +1054,6 @@
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
.gps-coords { font-weight: 600; }
/* Secondary UTM line — only shown in "Both" coordinate-format mode */
.gps-utm { font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0.92; }
.gps-meta { display: flex; gap: 5px; opacity: 0.85; }
/* Active fix: tint green-ish; colour-coded further from JS via quality class */
.gps-readout.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); }
@ -1097,7 +1069,6 @@
}
@media (max-width: 380px) {
.gps-readout .gps-meta { display: none; }
.gps-readout .gps-utm { display: none; }
}
/* ol-ext SearchNominatim styling */
@ -1542,353 +1513,18 @@
gap: 2px;
}
/* ================================================================
Drawing toolbar — docked left rail
----------------------------------------------------------------
Field officers reported three problems with the palette: it had no
background and vanished over light imagery, it sat centred at the top
across the area being drawn, and its icons did not match each other.
The bar is now a two-column rail docked to the left edge, on a solid
panel, with its controls banded into labelled groups by
MapView._buildToolbarRail(). Two columns rather than one because a
single file of 13 buttons plus labels runs past the bottom of a laptop
map view.
`ol-left` is ol-ext's own class: it docks the bar left AND makes option
bars (Select's Delete/Info, Split's sub-tools) fly out to the right
instead of dropping downwards. We keep that behaviour and override only
the layout.
================================================================ */
.ol-control.ol-bar.ol-editbar.ol-editbar-rail {
/* Clear of the left off-canvas toggle, which sits at left:10px and is
44px wide, vertically centred — exactly where ol-ext docks a left bar.
Officers can drag the rail anywhere from here; see the grip below. */
left: 66px;
display: grid;
grid-template-columns: repeat(2, 30px);
gap: 3px;
align-content: start;
padding: 5px;
background: var(--bar-bg);
border: 1px solid rgba(30, 26, 75, 0.14);
border-radius: 11px;
box-shadow:
0 6px 24px -6px rgba(30, 26, 75, 0.3),
0 1px 3px rgba(30, 26, 75, 0.14);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
/* Deliberately NOT scrollable. The rail measures 75 x 363 px, which fits
any map viewport we support, and setting overflow on either axis makes
the other compute to `auto` as well — which clips the option bars that
fly out past the right edge. */
overflow: visible;
}
/* Direct children only. A descendant selector here would also match the
nested option bars — which are themselves .ol-control — and override the
`display: none` that keeps them closed until their tool is active. */
.ol-editbar.ol-editbar-rail.ol-left > .ol-control {
margin: 0;
}
/* --- drag grip ---------------------------------------------------
The rail can be moved: officers work at different zoom levels and on
different hands, and a fixed panel always covers something. Dragging is
deliberately restricted to this grip so a mis-aimed tap on a tool never
moves the palette instead of selecting.
---------------------------------------------------------------- */
.ol-editbar-rail .ol-rail-grip {
grid-column: 1 / -1;
display: grid;
place-items: center;
height: 18px;
margin: -2px 0 2px;
border-radius: 6px;
color: var(--muted-foreground);
cursor: grab;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.ol-editbar-rail .ol-rail-grip:hover {
background: var(--muted);
color: var(--foreground);
}
.ol-editbar-rail .ol-rail-grip:active,
.ol-editbar-rail.is-dragging .ol-rail-grip {
cursor: grabbing;
}
.ol-editbar-rail .ol-rail-grip::before {
content: "";
width: 22px;
height: 4px;
border-radius: 2px;
background: currentColor;
opacity: 0.45;
}
.ol-editbar-rail.is-dragging {
opacity: 0.9;
user-select: none;
}
/* No dragging on phones — the strip is docked above the dock bar. */
@media (max-width: 576px), (max-height: 560px) {
.ol-editbar-rail .ol-rail-grip {
display: none;
}
}
/* Group heading — spans both columns. */
.ol-editbar-rail .ol-rail-label {
grid-column: 1 / -1;
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted-foreground);
text-align: center;
padding: 4px 0 1px;
user-select: none;
-webkit-user-select: none;
}
.ol-editbar-rail .ol-rail-label:first-child {
padding-top: 1px;
}
/* The action group is one control holding four buttons; let it span the
rail and lay its own buttons out on the same 2-column rhythm. */
.ol-editbar-rail > .ol-editbar-actions {
grid-column: 1 / -1;
}
.ol-editbar-rail > .ol-editbar-actions,
.ol-editbar-rail > .ol-editbar-actions .ol-bar {
display: grid !important;
grid-template-columns: repeat(2, 30px);
gap: 3px;
}
/* --- buttons ---------------------------------------------------- */
.ol-editbar-rail .ol-control button {
width: 30px;
height: 30px;
margin: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--foreground);
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: background-color 0.13s ease, color 0.13s ease;
}
.ol-editbar-rail .ol-control button:hover {
background: var(--muted);
}
/* The `.ol-left` is here for specificity, not layout: ol-ext paints the
active tool with `.ol-control.ol-bar .ol-toggle.ol-active > button`
(0,4,1) in its own brand cyan. Matching that and adding a class puts the
LUSPA blue back. */
.ol-editbar.ol-editbar-rail.ol-left .ol-control.ol-active > button,
.ol-editbar.ol-editbar-rail.ol-left .ol-toggle.ol-active > button,
.ol-editbar.ol-editbar-rail.ol-left .ol-toggle.ol-active > button:hover,
.ol-editbar.ol-editbar-rail.ol-left .ol-control button.ol-active,
.ol-editbar.ol-editbar-rail.ol-left .ol-snap-toggle.ol-active button {
background: var(--primary);
color: var(--primary-foreground);
}
.ol-editbar-rail .ol-control button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.ol-editbar-rail .ol-control button i {
pointer-events: none;
}
/* --- suppress ol-ext's CSS-drawn icons --------------------------
ol-ext has no icon font: each built-in tool is drawn with ::before and
::after shapes built from borders and box-shadows. Those are switched
off here so the Bootstrap Icon injected by _buildToolbarRail() is the
only thing in the button. The `> button` shapes and the `> button i`
shapes (Transform, Offset) both have to go.
---------------------------------------------------------------- */
.ol-editbar-rail .ol-control > button::before,
.ol-editbar-rail .ol-control > button::after {
content: none !important;
}
/* The injected glyph is a <span>, so ol-ext's `> button i` rules for
Transform and Offset cannot reach it — nothing to undo here, only the
sizing ol-ext applies to any icon holder. */
.ol-editbar-rail .ol-control > button > span.bi {
position: static;
width: auto;
height: auto;
overflow: visible;
transform: none;
display: block;
}
/* --- option bars (Select ▸ Delete/Info, Split ▸ sub-tools) -------
ol-ext anchors these to the control with `left: 100%`, which assumes a
one-column rail — in two columns that lands the flyout on top of the
neighbouring button. Making the controls static hands the containing
block to the rail itself, so `left: 100%` clears the whole panel. The
flyout then always appears at the same place (rail's right edge, centred)
instead of jumping to whichever button was pressed.
---------------------------------------------------------------- */
.ol-editbar.ol-editbar-rail > .ol-control {
position: static;
}
.ol-editbar-rail .ol-option-bar {
top: 50% !important;
bottom: auto !important;
left: 100% !important;
transform: translateY(-50%);
background: var(--bar-bg);
border-radius: 9px;
box-shadow:
0 4px 16px -4px rgba(30, 26, 75, 0.3),
0 0 0 1px rgba(30, 26, 75, 0.14);
padding: 4px;
margin: 0 0 0 8px !important;
white-space: nowrap;
}
/* ol-ext draws a little pointer triangle on the option bar. It reads as a
stray dark button next to the flyout, and it is declared at
`.ol-control.ol-bar.ol-left .ol-option-bar:before` (0,4,1) — so the
selector that hides it has to be at least as specific. */
.ol-control.ol-bar.ol-editbar-rail.ol-left .ol-option-bar::before,
.ol-control.ol-bar.ol-editbar-rail.ol-left .ol-option-bar::after {
display: none;
content: none;
}
.ol-editbar-rail .ol-option-bar .ol-control {
display: inline-block !important;
}
/* --- dark mode -------------------------------------------------- */
.dark-mode {
--bar-bg: rgba(28, 25, 64, 0.94);
}
.dark-mode .ol-editbar.ol-editbar-rail,
.dark-mode .ol-editbar-rail .ol-option-bar {
border-color: rgba(255, 255, 255, 0.14);
}
.dark-mode .ol-editbar-rail .ol-control button {
color: #eceaf7;
}
.dark-mode .ol-editbar-rail .ol-control button:hover {
background: rgba(255, 255, 255, 0.1);
}
/* --- fieldwork mode: larger touch targets ----------------------- */
.fieldwork-mode .ol-editbar.ol-editbar-rail {
grid-template-columns: repeat(2, 38px);
}
.fieldwork-mode .ol-editbar-rail > .ol-editbar-actions,
.fieldwork-mode .ol-editbar-rail > .ol-editbar-actions .ol-bar {
grid-template-columns: repeat(2, 38px);
}
.fieldwork-mode .ol-editbar-rail .ol-control button {
width: 38px;
height: 38px;
font-size: 18px;
}
/* ----------------------------------------------------------------
Phones: the rail becomes a horizontally scrolling strip sitting
above the bottom dock. Scrolling replaces the previous two-row
wrap, so every tool stays reachable at full touch size.
Small-screen drawing toolbar: the EditBar is a single nowrap row
by default and overflows narrow phones. Below the sm breakpoint we
let it wrap and push the action group (undo / redo / save / snap)
onto its own second row so every tool stays reachable.
---------------------------------------------------------------- */
/* Narrow OR short. A phone in landscape is 844px wide but leaves only
~270px of map height, where the 395px rail cannot fit — so the strip is
chosen on either axis, not on width alone. */
@media (max-width: 576px), (max-height: 560px) {
/* `.ol-left` is repeated here for specificity: ol-ext's own
`.ol-control.ol-bar.ol-left` sets top/transform, and without matching
it the rail stays vertically centred and stretches to `bottom`.
This layout is deliberately plain. An earlier version scrolled
horizontally and blurred its backdrop, and on iOS Safari it did not
paint at all — a scroll container that also has backdrop-filter is a
known way to lose an element entirely. Wrapping onto two rows needs
neither, and it is what shipped before the rail, on the same phones.
Nothing here should be made cleverer without testing on a device. */
.ol-control.ol-bar.ol-editbar.ol-editbar-rail.ol-left {
/* NOTE: display must NOT be !important — ol-ext toggles the bar with an
inline display:none/'' (setVisible). An !important here would beat
that and keep the toolbar visible even when Draw mode is off. */
display: flex;
grid-template-columns: none;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
row-gap: 3px;
top: auto;
/* Clear of the right-edge control stack, which the strip spans the full
width of and would otherwise sit under:
My Location bottom: 90px + 44px tall -> top at 134px
Base map switcher bottom: 144px + 44px tall -> top at 188px
so 188 + 8px gap. Keep these in step with .ls-locate-toggle and the
LayerSwitcher button in src/styles/layerswitcher.css. */
bottom: 196px;
left: 8px;
/* width rather than `right`, which is the more conventional pairing
with `left` and avoids another layout edge case. */
right: auto;
width: calc(100% - 16px);
transform: none;
overflow: visible;
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--card);
}
/* Group headings become hairline dividers: the words cost more width than
two rows can spare, but the grouping is still worth showing. */
.ol-editbar-rail .ol-rail-label {
grid-column: auto;
flex: none;
width: 1px;
height: 24px;
padding: 0;
margin: 0 5px;
font-size: 0;
background: var(--border);
}
.ol-editbar-rail .ol-rail-grip + .ol-rail-label {
/* Flex line-break used to start the toolbar's second row. Inert (removed
from flow) on wider screens; activated inside the sm media query. */
.ol-editbar-break {
display: none;
}
.ol-editbar-rail > .ol-editbar-actions,
.ol-editbar-rail > .ol-editbar-actions .ol-bar {
display: flex !important;
grid-template-columns: none;
flex: none;
}
.ol-editbar.ol-editbar-rail.ol-left .ol-control {
flex: none;
}
/* Touch targets stay at the recommended minimum on a phone. */
.ol-editbar-rail .ol-control button {
width: 36px;
height: 36px;
font-size: 17px;
}
/* Option bars open upward here — there is no room to the right, and
downward would land under the dock. */
.ol-editbar.ol-editbar-rail .ol-option-bar {
top: auto !important;
bottom: 100% !important;
left: 8px !important;
transform: none;
margin: 0 0 8px !important;
}
}
/* ----------------------------------------------------------------
Iframe embed mode (see public/embed.php + src/embed-bridge.js).
The body class is set by the inline script injected by embed.php
@ -1916,6 +1552,51 @@
height: 100dvh;
}
@media (max-width: 576px) {
.ol-editbar.ol-bar {
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
inline `display:none`/`''` (setVisible). A `!important` here would beat
that inline style and keep the toolbar permanently visible even when
Draw mode is off. Plain `display:flex` only applies when visible. */
display: flex;
flex-wrap: wrap !important;
justify-content: center;
align-items: center;
row-gap: 4px;
white-space: normal !important;
max-width: calc(100vw - 12px);
}
/* Full-width, zero-height break → forces everything after it (the
action group + Split + Merge) onto a shared second row. */
.ol-editbar.ol-bar > .ol-editbar-break {
display: block;
flex-basis: 100%;
width: 100%;
height: 0;
margin: 0;
padding: 0;
}
/* Right-align the second row (action group + Split + Merge). The auto
left-margin pushes them to the right end of the line, clearing the
far-left zone where an active tool's option bar (e.g. Select's
Delete/Info) drops down from row 1 — which otherwise overlaps them. */
.ol-editbar.ol-bar > .ol-editbar-actions {
/* !important: ol-ext's `.ol-control.ol-bar .ol-control { margin:0 }`
has equal specificity and loads later, so it would otherwise cancel
this auto-margin and the group would stay glued to the left. */
margin-left: auto !important;
}
/* Pull the second row (action group + Split + Merge — everything after
the line-break) up ~10px. The zero-height break sits on its own flex
line, stacking two row-gaps above the row; this negative top margin
closes that extra space so row 2 aligns nicely under row 1. */
.ol-editbar.ol-bar > .ol-editbar-break ~ .ol-control {
margin-top: -8px !important;
}
}
</style>
</head>
<body>
@ -1934,7 +1615,6 @@
<i class="bi bi-broadcast" aria-hidden="true"></i>
<span class="gps-readout-body">
<span class="gps-coords" id="gps-coords">GPS off</span>
<span class="gps-utm d-none" id="gps-utm"></span>
<span class="gps-meta">
<span id="gps-accuracy"></span>
<span class="gps-sep">·</span>
@ -2009,9 +1689,10 @@
<!-- Bottom Dock -->
<div class="bottom-dock">
<!-- The Add Location button used to sit here. Adding a point is now
the Draw > Point tool inside Digitise, so there is one gesture
for it rather than two. -->
<button class="dock-btn active" type="button" id="dock-btn-add-location" title="Add Location Mode">
<span>📍</span>
<span class="dock-btn-label">Add</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-measure-circle" title="Measure Circle">
<span></span>
<span class="dock-btn-label">Circle</span>
@ -2028,11 +1709,6 @@
<span>✏️</span>
<span class="dock-btn-label">Digitise</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-analysis"
title="Spatial Analysis — overlays and zonal statistics">
<span>🧮</span>
<span class="dock-btn-label">Analyse</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
<span>🗑️</span>
<span class="dock-btn-label">Clear</span>
@ -2135,11 +1811,6 @@
<label class="btn btn-outline-primary" for="export-gis-fmt-kml">
KML
</label>
<input type="radio" class="btn-check" name="export-gis-format"
id="export-gis-fmt-pdf" value="pdf">
<label class="btn btn-outline-primary" for="export-gis-fmt-pdf">
PDF report
</label>
</div>
<div class="form-text" id="export-gis-format-hint">
GeoJSON keeps all attributes as-is and is the safest default.
@ -2155,9 +1826,8 @@
value="area_analysis">
</div>
<!-- Field rename table (hidden for the PDF report, which exports the
analysis summary figures rather than feature attributes) -->
<div id="export-gis-fields-wrap">
<!-- 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
@ -2191,118 +1861,6 @@
</div>
</div>
<!-- Analysis panel — Stage 1 client-side spatial analysis (overlays + zonal
statistics). Runs in-browser via Turf.js, so it works offline. Results
are added as a new layer in the "Analysis" group. -->
<div class="modal fade" id="analysisModal" tabindex="-1"
aria-labelledby="analysisModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="analysisModalLabel">
<i class="bi bi-diagram-3 me-2"></i>Spatial Analysis
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Mode switch -->
<div class="btn-group w-100 mb-3" role="group" aria-label="Analysis type">
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-overlay"
value="overlay" checked>
<label class="btn btn-outline-primary" for="analysis-mode-overlay">
<i class="bi bi-intersect me-1"></i>Overlay
</label>
<input type="radio" class="btn-check" name="analysis-mode" id="analysis-mode-zonal"
value="zonal">
<label class="btn btn-outline-primary" for="analysis-mode-zonal">
<i class="bi bi-table me-1"></i>Zonal statistics
</label>
</div>
<!-- Scope: keeps big layers workable by restricting the inputs -->
<div class="mb-3">
<label for="analysis-scope" class="form-label fw-bold">Apply to</label>
<select class="form-select form-select-sm" id="analysis-scope">
<option value="all" selected>Whole layer</option>
<option value="view">Features in current map view</option>
<option value="selection">Selected features only</option>
</select>
<div class="form-text" id="analysis-scope-hint"></div>
</div>
<!-- ---------- Overlay ---------- -->
<div id="analysis-overlay-box">
<div class="mb-3">
<label for="analysis-op" class="form-label fw-bold">Operation</label>
<select class="form-select form-select-sm" id="analysis-op"></select>
<div class="form-text" id="analysis-op-hint"></div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="analysis-layer-a" class="form-label fw-bold">Layer A</label>
<select class="form-select form-select-sm" id="analysis-layer-a"></select>
</div>
<div class="col-md-6" id="analysis-layer-b-wrap">
<label for="analysis-layer-b" class="form-label fw-bold">Layer B</label>
<select class="form-select form-select-sm" id="analysis-layer-b"></select>
</div>
</div>
</div>
<!-- ---------- Zonal statistics ---------- -->
<div id="analysis-zonal-box" class="d-none">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-zone-layer" class="form-label fw-bold">Zone layer</label>
<select class="form-select form-select-sm" id="analysis-zone-layer"></select>
<div class="form-text">Polygons the results are summarised into.</div>
</div>
<div class="col-md-6">
<label for="analysis-input-layer" class="form-label fw-bold">Features to summarise</label>
<select class="form-select form-select-sm" id="analysis-input-layer"></select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="analysis-membership" class="form-label fw-bold">Counted when</label>
<select class="form-select form-select-sm" id="analysis-membership"></select>
<div class="form-text" id="analysis-membership-hint"></div>
</div>
<div class="col-md-6">
<label for="analysis-field" class="form-label fw-bold">Numeric field</label>
<select class="form-select form-select-sm" id="analysis-field"></select>
<div class="form-text">Needed for sum, mean, minimum and maximum.</div>
</div>
</div>
<label class="form-label fw-bold d-block mb-1">Statistics</label>
<div id="analysis-stats-box" class="mb-1"><!-- populated at runtime --></div>
</div>
<hr>
<div class="mb-3">
<label for="analysis-output-name" class="form-label fw-bold">Result layer name</label>
<input type="text" class="form-control form-control-sm" id="analysis-output-name">
</div>
<div id="analysis-result"><!-- summary / table / errors --></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link text-muted me-auto"
data-bs-dismiss="modal">Close</button>
<!-- Shown only after a zonal run — exports the results table plus the
parameters that produced it, as a two-sheet Excel workbook. -->
<button type="button" class="btn btn-outline-primary d-none" id="analysis-export-table">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export table (Excel)
</button>
<button type="button" class="btn btn-primary" id="analysis-run">
<i class="bi bi-play-fill me-1"></i>Run analysis
</button>
</div>
</div>
</div>
</div>
<!-- Import spinner — shown while a dropped file is being parsed and staged
(Shapefile decompression in particular can take several seconds).
Hidden once the mapping modal opens or an error occurs. -->
@ -2489,25 +2047,11 @@
<hr>
<!-- Back to Landing Page (stay signed in) — visible when authenticated.
The landing page is the dispatch hub for launching the other LUSPA
modules. Plain navigation: keeps the SSO session, no logout. -->
<a id="menu-landing-link"
href="https://lupmis4luspa.org/"
class="btn btn-primary w-100 d-none mb-2"
style="font-weight:600;">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Back to Landing Page
</a>
<!-- Logout (only visible when authenticated) — routes through the
server logout endpoint (/?logout=1) which destroys the PWA's PHP
session and hands off to the portal's /user-logout for full SSO
logout. Distinct from "Back to Landing Page" above, which stays
signed in. -->
<!-- Sign-out (only visible when authenticated) -->
<button type="button" id="menu-signout-btn"
class="btn btn-outline-danger w-100 d-none"
style="font-weight:600;">
<i class="bi bi-box-arrow-right me-2"></i>Logout
<i class="bi bi-box-arrow-right me-2"></i>Return to Landing Page
</button>
<!-- Sign-in prompt (only visible when NOT authenticated) -->
@ -2667,26 +2211,6 @@
</div>
</div>
</div>
<!-- GPS Coordinate Format -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">GPS Coordinate Format</h6>
<small class="text-muted">Format of the live GPS read-out in the top bar. UTM is shown in metres (zone, easting, northing).</small>
</div>
<div class="ms-3" style="min-width:140px;">
<select class="form-select form-select-sm" id="coord-format-select" aria-label="GPS coordinate format">
<option value="latlon">Lat / Lon</option>
<option value="utm">UTM</option>
<option value="both">Both</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Default Base Map -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">

376
main.js
View File

@ -97,7 +97,7 @@ import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers
// GPS live-position + trail recording (reusable engine + LUPMIS wiring)
import { geoTracker } from './src/geotracker-lupmis.js';
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality, formatUTM } from './src/geotracker/geo-utils.js';
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js';
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
import { createEmbedBridge } from './src/embed-bridge.js';
@ -126,9 +126,6 @@ function hideImportSpinner() {
// GIS export from the analysis popups (Area / Circle)
import { openExportGisModal } from './src/export-gis-modal.js';
// NOTE: the Analysis panel (and the Turf.js geometry library behind it) is
// imported dynamically on first use — see the dock button handler in initUI().
// Turf adds ~70 kB, which field users who never run an analysis shouldn't pay.
// Map instance (global for access across functions)
let mapView = null;
@ -143,13 +140,10 @@ let embedBridge = null;
const EMBED_CONFIG = (typeof window !== 'undefined' && window.LUPMIS_EMBED) || null;
const IS_EMBED_PERMIT = !!(EMBED_CONFIG && EMBED_CONFIG.mode === 'permit');
// Current interaction mode: 'none' | 'measureCircle' | 'measureLine' |
// 'measureArea' | 'draw' | 'embed-permit'.
// Default is 'none': map clicks select a marker and show its details, and
// nothing else. Adding a location is the Draw > Point tool inside Digitise,
// not a mode of its own. Measurement and Draw tools return to 'none' when
// toggled off.
let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'none';
// Current interaction mode: 'addLocation' | 'measureCircle' | 'measureLine' | 'measureArea'
// In embed permit mode we don't want the default Add-Location click to fire,
// so start the mode in a neutral state.
let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'addLocation';
// ============================================================================
// Application Initialization
@ -318,22 +312,21 @@ async function initApp() {
return;
}
// Tapping a marker opens its details. This used to require Add Location
// mode, which no longer exists — adding a location is now the Draw ▸ Point
// tool — so it happens in the neutral mode instead. The measurement and
// Digitise modes are excluded because there a click means something else.
if (currentMode !== 'none') {
// Non-parcel clicks (markers, empty space) only in addLocation mode
if (currentMode !== 'addLocation') {
return;
}
if (feature) {
// Clicked on existing marker - select it and show details
console.log('[MapClick] Clicked on marker:', feature.getId());
mapView.selectMarker(feature);
showLocationDetails(feature);
} else {
// Empty space in the neutral mode does nothing: a stray tap on the map
// should not open a form.
// Clicked on empty space - show add location popup at click position
console.log('[MapClick] Empty space → Add Location popup');
mapView.clearSelection();
mapView.showAddLocationPopup(evt.coordinate);
}
});
@ -374,17 +367,6 @@ async function initApp() {
}
});
// Placing a point with the Draw ▸ Point tool opens the Add Location form at
// that coordinate. This is the single route to a new point: the tool replaced
// the separate Add Location dock button, so there is one gesture rather than
// two ways to put a point on the map that behaved differently.
mapView.onDrawnPoint((coordinate) => {
if (IS_EMBED_PERMIT) return;
console.log('[App] Point drawn → Add Location popup');
mapView.clearSelection();
mapView.showAddLocationPopup(coordinate);
});
// Set up handler for the map add location popup form
mapView.onAddLocation(async (data) => {
console.log('[App] Add location from map popup:', data);
@ -487,14 +469,6 @@ async function initApp() {
// Now dbReady should be resolved
console.log('[App] Database ready');
// Guard against a stale district: if the authenticated user's district
// has changed since the last load (e.g. an admin reassigned them in the
// remote DB), every locally-cached, district-scoped layer is now wrong.
// Wipe them so the fresh-fetch path repopulates from the correct
// district instead of showing the previous one. Must run BEFORE any
// district-scoped load below.
await enforceDistrictConsistency();
// Show database status
const status = await getDatabaseStatus();
console.log('[App] Database status:', status);
@ -581,9 +555,6 @@ async function initApp() {
// 9. Measurement system toggle (metric / imperial)
initMeasurementSystem();
// 9b. GPS coordinate format (lat/lon · UTM · both)
initCoordinateFormat();
// 10. Dark mode
initDarkMode();
@ -671,30 +642,16 @@ function initUI() {
// Mode Selector & Measurement Tools (Bottom Dock)
// ============================================
const addLocationBtn = document.getElementById('dock-btn-add-location');
const measureCircleBtn = document.getElementById('dock-btn-measure-circle');
const measureLineBtn = document.getElementById('dock-btn-measure-line');
const measureAreaBtn = document.getElementById('dock-btn-measure-area');
const drawBtn = document.getElementById('dock-btn-draw');
const clearBtn = document.getElementById('dock-btn-clear');
const analysisBtn = document.getElementById('dock-btn-analysis');
// Spatial Analysis panel — opens a dialog rather than switching map mode, so
// it is deliberately NOT part of modeButtons (it doesn't own map clicks).
if (analysisBtn) {
analysisBtn.addEventListener('click', async () => {
if (!mapView) return;
try {
const { openAnalysisModal } = await import('./src/analysis-modal.js');
openAnalysisModal(mapView);
} catch (err) {
console.error('[App] Could not load the Analysis panel:', err);
showError('Analysis tools could not be loaded — check your connection and try again.');
}
});
}
// Debug: Check if buttons are found
console.log('[initUI] Buttons found:', {
addLocation: !!addLocationBtn,
measureCircle: !!measureCircleBtn,
measureLine: !!measureLineBtn,
measureArea: !!measureAreaBtn,
@ -703,7 +660,7 @@ function initUI() {
});
// All mode buttons (mutually exclusive)
const modeButtons = [measureCircleBtn, measureLineBtn, measureAreaBtn, drawBtn];
const modeButtons = [addLocationBtn, measureCircleBtn, measureLineBtn, measureAreaBtn, drawBtn];
// Helper to set active mode and update button states
// Note: This updates the module-level currentMode variable
@ -725,9 +682,8 @@ function initUI() {
mapView?.setEditMode(false);
}
// The Add Location form is raised by the Draw ▸ Point tool, so it belongs
// to Digitise mode and is dismissed on the way out of it.
if (mode !== 'draw') {
// Hide add location popup when leaving addLocation mode
if (mode !== 'addLocation') {
mapView?.hideAddLocationPopup();
}
@ -745,17 +701,25 @@ function initUI() {
case 'draw':
mapView?.setEditMode(true);
break;
// addLocation mode doesn't need tool activation
}
};
// Add Location mode button
if (addLocationBtn) {
addLocationBtn.addEventListener('click', () => {
console.log('[Button] Add Location clicked');
setMode('addLocation', addLocationBtn);
});
}
// Circle measurement button
if (measureCircleBtn) {
measureCircleBtn.addEventListener('click', () => {
console.log('[Button] Circle clicked, currentMode is:', currentMode);
if (currentMode === 'measureCircle') {
// Toggle off — return to the neutral 'none' mode (no implicit
// Add-Location re-activation).
setMode('none', null);
// Toggle off - return to addLocation mode
setMode('addLocation', addLocationBtn);
} else {
setMode('measureCircle', measureCircleBtn);
}
@ -767,7 +731,7 @@ function initUI() {
measureLineBtn.addEventListener('click', () => {
console.log('[Button] Line clicked, currentMode is:', currentMode);
if (currentMode === 'measureLine') {
setMode('none', null);
setMode('addLocation', addLocationBtn);
} else {
setMode('measureLine', measureLineBtn);
}
@ -779,7 +743,7 @@ function initUI() {
measureAreaBtn.addEventListener('click', () => {
console.log('[Button] Area clicked, currentMode is:', currentMode);
if (currentMode === 'measureArea') {
setMode('none', null);
setMode('addLocation', addLocationBtn);
} else {
setMode('measureArea', measureAreaBtn);
}
@ -791,7 +755,7 @@ function initUI() {
drawBtn.addEventListener('click', () => {
console.log('[Button] Draw clicked, currentMode is:', currentMode);
if (currentMode === 'draw') {
setMode('none', null);
setMode('addLocation', addLocationBtn);
} else {
setMode('draw', drawBtn);
}
@ -879,13 +843,6 @@ async function loadLocations() {
mapView.clearMarkers();
if (locations.length > 0) {
mapView.addMarkers(locations);
// Show the layer whenever there is something in it. It is created
// hidden so an empty map opens clean, but a district that HAS locations
// should show them — otherwise every reload hides the markers again and
// the officer has to find the layer in the switcher to see work they
// already did. Doing it here covers startup and every reload, not just
// the moment a location is saved.
mapView.showMarkers();
console.log('[App] Added', locations.length, 'markers to map');
}
}
@ -975,7 +932,6 @@ function renderLocations(locations) {
const id = parseInt(item.dataset.id);
// Zoom to location on map
mapView?.showMarkers();
mapView?.zoomTo(lon, lat, 14);
// Select the marker
@ -1374,58 +1330,13 @@ function zonesToGeoJSON(zones) {
return { type: 'FeatureCollection', features };
}
/** localStorage key tracking the district the local caches were filled for. */
const LAST_DISTRICT_KEY = 'lupmis-last-district';
/** Cache key for the district boundary, scoped per district id. */
function districtBoundaryCacheKey(districtId) {
return districtId == null || String(districtId).length === 0
? 'district_boundary' // dev fallback (no session)
: `district_boundary_${districtId}`;
}
/**
* Detect a district change between page loads and wipe district-scoped local
* caches when it happens, so we never display a previous district's boundary,
* parcels, zones or UPN grid after an admin reassigns the user.
*
* The comparison is against the authenticated session district only in local
* dev (no session) there is no district to enforce, so this is a no-op.
*/
async function enforceDistrictConsistency() {
const session = getSession();
// Only meaningful for an authenticated user with a district.
const current = session?.district_id;
if (current == null || String(current).length === 0) return;
const currentStr = String(current);
let previous = null;
try { previous = localStorage.getItem(LAST_DISTRICT_KEY); } catch { /* ignore */ }
if (previous !== null && previous !== currentStr) {
console.warn(`[App] District changed ${previous}${currentStr}; clearing cached layers.`);
try {
await clearAllCachedLayers();
showWarning('Your district was updated — refreshing map data.');
} catch (err) {
console.error('[App] Failed to clear caches on district change:', err);
}
}
try { localStorage.setItem(LAST_DISTRICT_KEY, currentStr); } catch { /* ignore */ }
}
/**
* Load district boundary with local-first strategy:
* 1. Always read from local SQLite cache (GeoJSON) first instant, works offline
* 2. If online, fetch from API, convert WKT GeoJSON, cache and display
*
* The cache is keyed by district id, so a boundary cached for one district can
* never be served for another even if the stale-district guard is bypassed.
*/
async function loadDistrictBoundary() {
const districtId = getSession()?.district_id ?? null;
const CACHE_KEY = districtBoundaryCacheKey(districtId);
const CACHE_KEY = 'district_boundary';
const ADMIN_GROUP_ID = 1; // Administration layer group
const boundaryStyle = {
strokeColor: '#e11d48',
@ -1467,22 +1378,16 @@ async function loadDistrictBoundary() {
}
try {
// Will we be fetching fresh data below? If so, the cached layer is shown
// for an instant paint but we DON'T zoom to it — the fresh fetch performs
// the single authoritative zoom, avoiding a visible "fly to old extent
// then re-fly" when the cached geometry differs.
const willFetchFresh = isOnline() && isServerReachable();
// Step 1: Load from local cache (already stored as GeoJSON)
const cached = await getRemoteData(CACHE_KEY);
if (cached) {
console.log('[App] District boundary loaded from local cache');
const layer = mapView?.addGeoJSONLayer(cached, 'District Boundary', boundaryStyle, adminGroup);
if (!willFetchFresh) zoomToBoundary(layer);
zoomToBoundary(layer);
}
// Step 2: If online and server reachable, fetch fresh data from the API
if (willFetchFresh) {
if (isOnline() && isServerReachable()) {
console.log('[App] Fetching district boundary from API...');
const apiResponse = await getDistrictBoundary();
@ -2121,10 +2026,6 @@ async function loadContoursHillshade() {
strokeWidth: 0.8,
typeDescription: 'Vector / Line',
fillColor: 'rgba(0,0,0,0)',
// Elevation labels are dense — declutter drops overlapping ones so the
// map shows a readable scattering rather than a solid block of text.
// Must be set at construction time (OpenLayers has no setDeclutter).
declutter: true,
};
const biophysGroup = mapView?.getLayerGroupByTitle('Biophysical Environment');
@ -2139,40 +2040,6 @@ async function loadContoursHillshade() {
}
contoursLayer.setVisible(false);
// Style: the contour line, plus its elevation as a label.
//
// The API returns one feature per contour segment — roughly 2,700 per
// district, most of them only ~50 m across — so a label per feature is far
// larger than the feature itself. Decluttering (set above) drops the
// overlaps, which gives the conventional cartographic result: an occasional
// elevation value along the contours rather than one on every segment.
// The resolution gate exists so we don't build text styles for every feature
// when zoomed out to regional or national level.
const CONTOUR_LABEL_MAX_RESOLUTION = 10; // m/px, ≈ 1:35,000
contoursLayer.setStyle((feature, resolution) => {
const styles = [
new Style({
stroke: new Stroke({ color: '#78716c', width: 0.8 }),
}),
];
if (resolution <= CONTOUR_LABEL_MAX_RESOLUTION) {
const elev = feature.get('elev');
if (elev !== null && elev !== undefined && elev !== '') {
styles.push(new Style({
text: new OlText({
text: String(elev),
font: '600 11px Arial, sans-serif',
fill: new Fill({ color: '#57534e' }),
stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3 }),
overflow: true, // label short segments too
}),
}));
}
}
return styles;
});
// Warn when the user enables the layer but it has no data
contoursLayer.on('change:visible', () => {
if (contoursLayer.getVisible() && contoursLayer.getSource().getFeatures().length === 0) {
@ -2190,17 +2057,12 @@ async function loadContoursHillshade() {
console.log('[App] Fetching contours_hillshade from API...');
const apiResponse = await getContoursHillshade();
// Accept either the {success, data:[…]} envelope used by the
// /spatial_planning endpoints or a bare array, since the contours endpoint
// is a newer module and documented only by example.
const rows = Array.isArray(apiResponse) ? apiResponse
: Array.isArray(apiResponse?.data) ? apiResponse.data
: null;
if (!rows) {
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
console.warn('[App] getContoursHillshade API response invalid:', apiResponse);
return;
}
const rows = apiResponse.data;
console.log('[App] Contours hillshade from API:', rows.length, 'rows');
if (rows.length > 0) {
console.log('[App] First row keys:', Object.keys(rows[0]));
@ -2768,29 +2630,6 @@ window.addEventListener('lupmis:export-gis', (e) => {
openExportGisModal(e.detail || {});
});
// "Analyse" on the Circle/Area analysis popup — open the Analysis panel scoped
// to just the features the drawn shape caught, so overlays and zonal statistics
// run on that area of interest rather than the whole layer.
window.addEventListener('lupmis:analyse-features', async (e) => {
if (!mapView) return;
const ctx = e.detail || {};
const scopeFeatures = [
...(ctx.parcelFeatures || []),
...(ctx.zoneFeatures || []),
...Object.values(ctx.otherByLayer || {}).flat(),
];
try {
const { openAnalysisModal } = await import('./src/analysis-modal.js');
openAnalysisModal(mapView, {
scopeFeatures,
scopeLabel: ctx.title || 'drawn area',
});
} catch (err) {
console.error('[App] Could not load the Analysis panel:', err);
showError('Analysis tools could not be loaded — check your connection and try again.');
}
});
// 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.
@ -3264,48 +3103,6 @@ function initMessageLog() {
// events to the navbar readout and the map's render/control hooks.
// ============================================================================
// Last GPS fix rendered into the navbar read-out. Kept at module scope so the
// Coordinate-Format setting can re-render the current position immediately when
// the user switches format, without waiting for the next fix.
let _lastGpsFix = null;
/** Read the user's coordinate-format preference ('latlon' | 'utm' | 'both'). */
function getCoordFormat() {
const v = localStorage.getItem('coord-format');
return (v === 'utm' || v === 'both') ? v : 'latlon';
}
/**
* Paint the navbar GPS coordinate spans from `_lastGpsFix` according to the
* current coordinate-format preference. Safe to call any time (no-op when
* there is no fix yet, leaving the "GPS off" placeholder in place).
*/
function renderGpsCoords() {
const coordsEl = document.getElementById('gps-coords');
const utmEl = document.getElementById('gps-utm');
if (!coordsEl) return;
if (!_lastGpsFix) return;
const { lat, lon } = _lastGpsFix;
const latlon = `${formatCoord(lat)}, ${formatCoord(lon)}`;
const utm = formatUTM(lat, lon);
const fmt = getCoordFormat();
if (fmt === 'utm') {
coordsEl.textContent = utm;
if (utmEl) utmEl.classList.add('d-none');
} else if (fmt === 'both') {
coordsEl.textContent = latlon;
if (utmEl) { utmEl.textContent = utm; utmEl.classList.remove('d-none'); }
} else {
coordsEl.textContent = latlon;
if (utmEl) utmEl.classList.add('d-none');
}
// Keep the alternate format reachable on hover regardless of mode.
const readout = document.getElementById('gps-readout');
if (readout) readout.title = `Lat/Lon: ${latlon}\nUTM: ${utm}`;
}
function initGpsTracking() {
const readout = document.getElementById('gps-readout');
const coordsEl = document.getElementById('gps-coords');
@ -3319,8 +3116,7 @@ function initGpsTracking() {
// Live navbar readout — fires for every fix (one-shot Locate or watch).
geoTracker.on('position', (fix) => {
_lastGpsFix = fix;
renderGpsCoords();
if (coordsEl) coordsEl.textContent = `${formatCoord(fix.lat)}, ${formatCoord(fix.lon)}`;
if (accEl) accEl.textContent = formatAccuracy(fix.accuracy);
if (satsEl) satsEl.textContent = `${fix.satellites != null ? fix.satellites : '—'} sat`;
if (readout) {
@ -3515,25 +3311,6 @@ function initMeasurementSystem() {
});
}
// ============================================================================
// GPS Coordinate Format (Lat/Lon · UTM · Both)
// ============================================================================
function initCoordinateFormat() {
const select = document.getElementById('coord-format-select');
if (!select) return;
// Restore saved preference (default: latlon)
select.value = getCoordFormat();
select.addEventListener('change', () => {
const fmt = (select.value === 'utm' || select.value === 'both') ? select.value : 'latlon';
localStorage.setItem('coord-format', fmt);
renderGpsCoords(); // repaint the current fix immediately
console.log('[Settings] GPS coordinate format:', fmt);
});
}
/**
* Default base map selector persisted in localStorage.
* Keys must match those handled by MapView.setBaseMap().
@ -4014,7 +3791,6 @@ function initAccountCard() {
const emailEl = document.getElementById('menu-user-email');
const detailEl = document.getElementById('menu-user-detail');
const signoutBtn = document.getElementById('menu-signout-btn');
const landingLink = document.getElementById('menu-landing-link');
const signinLink = document.getElementById('menu-signin-link');
const noSessNote = document.getElementById('menu-no-session-note');
@ -4043,7 +3819,6 @@ function initAccountCard() {
signoutBtn.classList.remove('d-none');
signoutBtn.addEventListener('click', () => handleSignOut(session), { once: false });
landingLink?.classList.remove('d-none'); // stay-signed-in route to the hub
signinLink?.classList.add('d-none');
noSessNote?.classList.add('d-none');
menuBtn.removeAttribute('data-state');
@ -4057,7 +3832,6 @@ function initAccountCard() {
detailEl.textContent = '';
signoutBtn.classList.add('d-none');
landingLink?.classList.add('d-none');
signinLink?.classList.add('d-none');
noSessNote?.classList.remove('d-none');
menuBtn.dataset.state = 'no-session';
@ -4071,7 +3845,6 @@ function initAccountCard() {
detailEl.textContent = '';
signoutBtn.classList.add('d-none');
landingLink?.classList.add('d-none'); // sign-in link below already points to the hub
signinLink?.classList.remove('d-none');
noSessNote?.classList.add('d-none');
menuBtn.dataset.state = 'unauthenticated';
@ -4083,55 +3856,50 @@ function initAccountCard() {
// right-side menuOffcanvas. See initAccountCard above.
/**
* Logout flow:
* 1. Refuse if offline (login needs the server see guard below).
* 2. Confirm with the user.
* 3. Wipe district-scoped local caches so the next (possibly different) user
* on this device can't briefly see the previous user's cached map data.
* 4. Navigate to the PWA's OWN logout endpoint (/?logout=1). That endpoint
* runs session_destroy() clearing the PHPSESSID session that holds the
* user's district_id — and then redirects to the central portal's
* /user-logout, which performs the full SSO logout (invalidates the token
* and clears sso_auth_token). We let the server chain own the SSO side, so
* there is no separate client-side token-invalidation call.
* Sign-out flow:
* 1. Confirm with the user.
* 2. Best-effort fire-and-forget call to the SSO logout endpoint so the
* server-side token is invalidated (no-cors mode tolerates CORS issues).
* 3. Expire the local sso_auth_token cookie on the parent domain so the
* browser stops sending it.
* 4. Redirect to the SSO login page leaves the user on familiar ground
* (and on next visit, index.php sees no session and serves a fresh
* page with no LUPMIS_SESSION).
*/
async function handleSignOut(session) {
// Guard: logging back in requires the SSO server (a session can only be
// created by validating the token online). If we let the user log out while
// offline, they would be stranded on this device — unable to sign back in
// until connectivity returns — and the server-side session_destroy() at
// /?logout=1 could not run anyway. So we refuse offline logout outright,
// which also protects against an accidental tap wiping the working session
// during fieldwork. The app keeps functioning offline on the cached session,
// so there is no need to log out until back online.
if (!isOnline()) {
alert(
'You appear to be offline.\n\n' +
'Logging out needs a connection to the sign-in server, and you would not ' +
'be able to sign back in until you are online again.\n\n' +
'Please try again once you have a connection. You can keep working — the ' +
'app stays signed in while offline.'
);
if (!confirm(`Return to Landing Page, ${session?.full_name || session?.username || 'user'}?`)) {
return;
}
if (!confirm(`Log out, ${session?.full_name || session?.username || 'user'}?`)) {
return;
}
// Drop locally-cached, district-scoped layers and the last-district marker
// so a different next user starts clean.
// 1. Best-effort: invalidate the SSO token server-side
const cookieToken = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('sso_auth_token='))
?.split('=')[1];
if (cookieToken) {
try {
await clearAllCachedLayers();
localStorage.removeItem(LAST_DISTRICT_KEY);
// no-cors swallows CORS errors; we don't read the response
await fetch('https://lupmis4luspa.org/sso/logout?token=' + encodeURIComponent(cookieToken), {
method: 'GET',
mode: 'no-cors',
credentials: 'include',
cache: 'no-store',
});
} catch (err) {
console.warn('[Logout] Cache clear failed (continuing):', err);
console.warn('[Signout] Best-effort SSO logout call failed:', err);
}
}
// Hand off to the server logout endpoint: it destroys the PWA's PHP session,
// then redirects to https://lupmis4luspa.org/user-logout for the full SSO
// logout (token invalidation + sso_auth_token cleared by the portal).
window.location.href = '/?logout=1';
// 2. Clear the cookie on the shared parent domain
// Set with both leading-dot and no-dot variants; browsers vary on which sticks.
const past = 'Thu, 01 Jan 1970 00:00:00 GMT';
document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=.lupmis4luspa.org`;
document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=lupmis4luspa.org`;
document.cookie = `sso_auth_token=; expires=${past}; path=/`;
// 3. Redirect to the central LUSPA login
window.location.href = 'https://lupmis4luspa.org/';
}
// ============================================================================

274
package-lock.json generated
View File

@ -9,14 +9,6 @@
"version": "1.0.0",
"dependencies": {
"@popperjs/core": "^2.11.8",
"@turf/area": "^7.3.5",
"@turf/boolean-intersects": "^7.3.5",
"@turf/boolean-point-in-polygon": "^7.3.5",
"@turf/centroid": "^7.3.5",
"@turf/difference": "^7.3.5",
"@turf/helpers": "^7.3.5",
"@turf/intersect": "^7.3.5",
"@turf/union": "^7.3.5",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"jspdf": "^4.2.1",
@ -809,205 +801,6 @@
"sqlite-wasm": "bin/index.js"
}
},
"node_modules/@turf/area": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.5.tgz",
"integrity": "sha512-sSn80wPT7XfBIDN3vurCPxhk9W4U8ozS/XImSqeLN8qveTICOxzZkhsGDMp0CuncaN+plWut4a2TdNM7mzZB6Q==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/boolean-disjoint": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/boolean-disjoint/-/boolean-disjoint-7.3.5.tgz",
"integrity": "sha512-Pz1GGUC6iL6xGVqhyo+fYg35kl4j/HONMEoC1voN3DcBOCcVlzq+ljvMpvE+oQiR7Q38xLhIxZneLCqMp5YrQA==",
"license": "MIT",
"dependencies": {
"@turf/boolean-point-in-polygon": "7.3.5",
"@turf/helpers": "7.3.5",
"@turf/line-intersect": "7.3.5",
"@turf/meta": "7.3.5",
"@turf/polygon-to-line": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/boolean-intersects": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/boolean-intersects/-/boolean-intersects-7.3.5.tgz",
"integrity": "sha512-Z6GPYjozrmTuzWQD0x7o8RPm+4HC7hz9q23hdB3U1+Qahesv8Mtc+wo82tO4CG6/NRnJ9u79DlEhmR1DxUU4iQ==",
"license": "MIT",
"dependencies": {
"@turf/boolean-disjoint": "7.3.5",
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/boolean-point-in-polygon": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz",
"integrity": "sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/invariant": "7.3.5",
"@types/geojson": "^7946.0.10",
"point-in-polygon-hao": "^1.1.0",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/centroid": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.5.tgz",
"integrity": "sha512-hkWaqwGFdOn6Tf0EWfn2yn1XZ1FWE1h2C5ZWstDMu/FxYO5DB+YjlmOFPl4K6SmSOEgdV07eK2vDCyPeTHqKGA==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/difference": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/difference/-/difference-7.3.5.tgz",
"integrity": "sha512-iDyWYCvgS3vgB8W1ai2cvfJaTq9bOt1QghiVkCNIyccF97CgtYJzenREVVUlp8qU9lJlbQ/ameyKvXPA1uOAlA==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"polyclip-ts": "^0.16.8",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/helpers": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz",
"integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/intersect": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-7.3.5.tgz",
"integrity": "sha512-v11Do9ySbsE08ffiwboQeFKvYByyxzvAz0ls837A9T3rSC+8vKMmK815S+C5v8CBMLNhuBCSgqnOIV3zonNICQ==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"polyclip-ts": "^0.16.8",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/invariant": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.5.tgz",
"integrity": "sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/line-intersect": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-7.3.5.tgz",
"integrity": "sha512-2Cl4oPsjaDdfIwz/5IRDdG2fNdfp3W6atICm81vnzl/GwURoVP+CLjXJ64QWWzpzIbgX2XprJQTmamByDt5MDw==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"sweepline-intersections": "^1.5.0",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/meta": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.5.tgz",
"integrity": "sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/polygon-to-line": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/polygon-to-line/-/polygon-to-line-7.3.5.tgz",
"integrity": "sha512-Mat5tvJcW3grpXCNFcMvjHL3d8hO4eoIgF3qYpXj25BHx/S7SJUOgyCV5x3arC0rCfM/cB71VmNDm9k57ec7bw==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/invariant": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/union": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/union/-/union-7.3.5.tgz",
"integrity": "sha512-/FSKhl+LX4+M7L/Trmiln0CDPWS8vCneGnQktt1o5XbCY/zIpH1JdxHEBFXhFZg4beAyXCz0uuxRyW9N/DH+KA==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"polyclip-ts": "^0.16.8",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1015,12 +808,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
@ -1069,15 +856,6 @@
"node": ">= 0.6.0"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@ -1497,25 +1275,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/point-in-polygon-hao": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz",
"integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/polyclip-ts": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz",
"integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.1.0",
"splaytree-ts": "^1.0.2"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -1633,12 +1392,6 @@
"node": ">= 0.8.15"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.55.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz",
@ -1716,12 +1469,6 @@
"node": ">=0.10.0"
}
},
"node_modules/splaytree-ts": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz",
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
"license": "BDS-3-Clause"
},
"node_modules/sqlocal": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/sqlocal/-/sqlocal-0.16.0.tgz",
@ -1784,15 +1531,6 @@
"node": ">=12.0.0"
}
},
"node_modules/sweepline-intersections": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz",
"integrity": "sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==",
"license": "MIT",
"dependencies": {
"tinyqueue": "^2.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@ -1820,18 +1558,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyqueue": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
"integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==",
"license": "ISC"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",

View File

@ -10,14 +10,6 @@
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"@turf/area": "^7.3.5",
"@turf/boolean-intersects": "^7.3.5",
"@turf/boolean-point-in-polygon": "^7.3.5",
"@turf/centroid": "^7.3.5",
"@turf/difference": "^7.3.5",
"@turf/helpers": "^7.3.5",
"@turf/intersect": "^7.3.5",
"@turf/union": "^7.3.5",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"jspdf": "^4.2.1",

View File

@ -13,58 +13,9 @@ DirectoryIndex index.php index.html
SetHandler application/x-httpd-php
</FilesMatch>
# Second line of defence for hidden FILES (.env, .htpasswd, …), in case
# mod_rewrite is not loaded. This does not cover hidden directories such as
# /.git/config — see the mod_rewrite block below, which does.
# Both Apache 2.4 and 2.2 syntaxes are given so this works either way.
#
# Access control runs before mod_rewrite, so a hidden file answers 403 here
# while a hidden directory answers 404 from the rewrite below. Both refuse to
# serve the content, which is the point; the differing codes are cosmetic.
<FilesMatch "^\.">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
<IfModule mod_rewrite.c>
RewriteEngine On
# ------------------------------------------------------------------
# Block hidden paths (.git, .env, .svn, …) — MUST stay first
# ------------------------------------------------------------------
# Scanners routinely fetch /.env and /.git/config, and if /.git/index
# plus /.git/objects/… can be read the whole repository — including its
# history and any credential ever committed — can be reconstructed.
#
# Two things make this easy to get wrong:
#
# 1. <FilesMatch "^\."> does NOT stop /.git/config. FilesMatch tests
# the basename only, and the basename there is "config". Blocking a
# hidden *directory* needs a rule that sees the whole path, which in
# .htaccess means mod_rewrite (<DirectoryMatch> is server-config only).
#
# 2. The SPA fallback further down only rewrites paths that do NOT
# exist (!-f / !-d). A real .env on disk therefore skips the
# fallback and gets served as a plain file. So this rule has to come
# before it, not after.
#
# .well-known is deliberately exempt: blocking it breaks Let's Encrypt
# (ACME) certificate issuance and renewal.
#
# 404 rather than 403, so a probe learns nothing about what exists.
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule (^|/)\. - [R=404,L]
# Files that are never meant to be fetched over HTTP, if one is ever
# deployed into the document root by mistake.
RewriteRule \.(sql|sqlite|sqlite3|db|bak|backup|old|orig|save|swp|log|ini|conf|key|pem|crt|p12|pfx)$ - [R=404,L,NC]
RewriteRule (^|/)(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml|\.dockerignore)$ - [R=404,L,NC]
# Clean URL for the iframe embed endpoint: /embed → embed.php
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
# index.php. Query strings (?mode=permit&...) pass through automatically.

View File

@ -24,49 +24,8 @@
session_start();
// ────────────────────────────────────────────────────────────────────────────
// Logout — end the PWA's OWN session, then hand off to the central SSO logout
// SSO authentication — validate the cookie if we don't already have a session
// ────────────────────────────────────────────────────────────────────────────
// The PWA keeps its own PHP session (PHPSESSID on this host), independent of
// the central SSO portal, so the previous user's fields (incl. district_id)
// persist until this session is destroyed. We do that here, then redirect to
// the portal's `/user-logout`, which performs the FULL SSO logout (invalidates
// the token server-side and clears the sso_auth_token cookie itself).
//
// Important: we deliberately do NOT clear `sso_auth_token` here. `/user-logout`
// needs that cookie to identify which SSO session to terminate — expiring it
// first would leave the SSO session alive on the server. Redirecting to the
// bare landing page (the old behaviour) only dropped the user on a page that
// then blocked them; it never logged them out of SSO.
//
// Triggered by `/?logout=1` from the in-app menu.
if (isset($_GET['logout'])) {
$_SESSION = [];
// Expire the PWA's own session cookie (PHPSESSID).
if (ini_get('session.use_cookies')) {
$cp = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
}
session_destroy();
// Hand off to the central SSO logout (full logout: invalidates the token
// and clears sso_auth_token). Keep the cookie intact so it can do so.
header('Location: https://lupmis4luspa.org/user-logout', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate once per session, at login
// ────────────────────────────────────────────────────────────────────────────
// A user's district changes only when they are transferred — a rare event — so
// the validated fields (district_id, region_id, name, …) are valid for the
// whole session. We therefore validate the SSO token only when there is no
// session yet (i.e. at login) and do NOT re-poll SSO on every load.
//
// A transfer is picked up the next time the user logs out and back in: the
// Logout endpoint above destroys the PHP session, so the subsequent login has
// no session and re-validates, fetching the new district_id. This is correct
// precisely because logout now tears the session down (previously it only
// cleared the SSO cookie, which left the stale district pinned).
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);

View File

@ -45,92 +45,7 @@
// tagging, geometry-edit + delete persistence to the staging tables,
// sample values + Unicode-bold field names in the mapping dropdown).
// New hashed bundle + updated embed.php shell.
// v12: Session/district correctness — a real /?logout=1 endpoint that destroys
// the PWA's own PHP session and then hands off to the portal's
// /user-logout for a FULL SSO logout (token invalidated + sso_auth_token
// cleared by the portal). Previously logout left the PHP session intact,
// pinning a stale district_id. SSO is validated once per session at login;
// a transfer is picked up on the next logout→login.
// Client-side stale-district guard (wipes district-scoped caches when the
// session district changes) + district-keyed boundary cache; GPS read-out
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
// devices. New hashed bundle + updated index.php shell.
// v13: Analytical tools Stage 1 — client-side spatial analysis. New "Analyse"
// dock button opens an Analysis panel offering vector overlays
// (intersect / clip / difference / union, via Turf.js) and vector-in-vector
// zonal statistics (count / sum / mean / min / max / area per zone); results
// are added as layers in a new "Analysis" group and work fully offline.
// Analysis can be scoped via "Apply to": whole layer, current map view,
// selected features, or the catch of a drawn Circle/Area. The Circle/Area
// analysis popup now has one "Export" button (PDF folded into the export
// modal as a fourth format) plus an "Analyse" button that opens the panel
// pre-scoped to the intersecting features.
// Raster display gains Cloud-Optimized GeoTIFF support (MapView.addCOGLayer).
// Turf, the analysis panel, geotiff.js and pako are all code-split and
// fetched on first use, so the eager bundle is unchanged.
// v14: Zonal statistics results can be exported as a two-sheet Excel workbook
// (Results + the Parameters that produced them) via a dependency-free
// XLSX writer in src/analysis/xlsx.js; Add External Layer gains a COG
// option with a URL pre-flight check.
// v15: Drawing toolbar reworked into a docked left rail, after field officers
// reported the palette had no background, sat centred over the area being
// drawn, and mixed two icon styles. It is now a two-column panel on the
// left edge with its tools banded into Draw / Edit / Shape / File, and
// every icon — including ol-ext's own CSS-drawn ones — is a Bootstrap
// Icon. On phones it becomes a horizontally scrolling strip above the
// dock, which retires the previous two-row wrapping workaround.
// New hashed bundle + updated index.html shell.
// v16: Three fixes to the v15 rail, all reported from the field. The phone
// layout stretched down the whole screen because its rule only tied with
// ol-ext's on specificity, so stylesheet order decided the winner and the
// built app ordered them the other way round from the test harness. A
// stray dark shape beside the option bar was ol-ext's pointer triangle,
// hidden by a selector that was likewise not specific enough. And the rail
// covered the left off-canvas toggle, which sits at exactly the position
// ol-ext docks a left bar to — so the rail now starts clear of it and can
// be dragged anywhere by its grip, with the position remembered.
// v17: iPhone fixes. In portrait the toolbar did not paint at all: on iOS an
// element that both blurs its backdrop and scrolls often renders blank,
// and v16's strip did both. The strip now uses an opaque background and no
// blur. In landscape the phone is 844px wide, so the width-only breakpoint
// handed it the 396px-tall rail inside a ~270px map; the layout now
// switches on height as well as width.
// v18: The phone toolbar still did not paint on iPhone. Reverted the strip from
// a horizontally scrolling row back to the two-row wrap that shipped
// before the rail and was known to work on those phones — removing the
// overflow, and with it the whole class of iOS scroll-container paint
// bugs, rather than only the backdrop-filter half of it. Group headings
// become hairline dividers so the grouping survives the narrower layout.
// Building the rail is now also wrapped in a fallback: if it throws, the
// default ol-ext bar is shown instead of no bar at all.
// v19: The phone strip sat at the very bottom edge, under the My Location and
// base-map buttons on the right and clipped by the dock. It now sits above
// that whole right-edge stack (bottom: 196px = the switcher's 144px anchor
// + its 44px height + an 8px gap).
// v20: One way to add a point instead of two. The Add Location dock button is
// gone; placing a point with Draw > Point now opens the same Add Location
// form. The sketch is discarded so the marker is the only record, and the
// add/remove pair is kept off the undo stack. Marker details, which used
// to need Add Location mode, now open in the neutral mode. The dock keeps
// its six remaining buttons and divides them evenly on narrow screens.
// v21: Location markers were saved but never drawn. The Markers layer is built
// hidden and nothing ever switched it on except the LayerSwitcher, so a
// point placed with Draw > Point was written to the database and the map
// panned to it with nothing to see. Creating a location, or picking one
// from the Locations list, now switches the layer on.
// v22: The point placed with Draw > Point now stays on screen while the Add
// Location form is open, so it visibly becomes the marker on save instead
// of vanishing and a marker appearing from nowhere. It is discarded when
// the form closes, either way. Marker loading is also kept out of the undo
// history: entering edit mode makes UndoRedo watch every source, so Undo
// after saving a location used to strip the markers off the map while
// leaving the row in the database.
// v23: Showing the Markers layer moved into loadLocations(), so it holds
// whenever the district has locations — at startup and on every reload,
// not only in the moment a location is saved. Previously a reload put the
// layer back to hidden and every marker vanished. An empty district still
// opens with the layer off.
const CACHE_VERSION = 'v23';
const CACHE_VERSION = 'v11';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;

View File

@ -1,448 +0,0 @@
/**
* Analysis panel controller Stage 1 of the GIS Analytical Tools concept.
*
* openAnalysisModal(mapView)
*
* Drives two families of client-side analysis:
* Overlays intersect / clip / difference / union between layers
* Zonal statistics summarise features (or a numeric field) per zone
*
* Both run entirely in the browser via Turf.js, so they work offline. Results
* are added to the map as a new styled layer in an "Analysis" group, from
* where they can be exported with the existing GIS export tools.
*
* Same Bootstrap modal pattern as src/export-gis-modal.js and src/import-modal.js.
*/
import { Modal } from 'bootstrap';
import { runOverlay, OVERLAY_OPS } from './analysis/overlay.js';
import { zonalStatistics, listNumericFields, STATS, MEMBERSHIP_MODES } from './analysis/zonal.js';
import { showToast } from './toast.js';
const ANALYSIS_GROUP_ID = 9001;
const ANALYSIS_GROUP_TITLE = 'Analysis';
const els = {};
let modal = null;
let mapView = null;
let layerChoices = []; // [{ layer, title, count }]
let drawnScope = null; // { features: Set, label } when opened from a Circle/Area popup
let lastZonal = null; // { rows, params } — the most recent zonal run, for export
// ---------------------------------------------------------------------------
// Element wiring
// ---------------------------------------------------------------------------
function cacheEls() {
if (els.root) return;
els.root = document.getElementById('analysisModal');
els.mode = Array.from(document.querySelectorAll('input[name="analysis-mode"]'));
els.overlayBox = document.getElementById('analysis-overlay-box');
els.zonalBox = document.getElementById('analysis-zonal-box');
els.op = document.getElementById('analysis-op');
els.opHint = document.getElementById('analysis-op-hint');
els.layerA = document.getElementById('analysis-layer-a');
els.layerB = document.getElementById('analysis-layer-b');
els.layerBWrap = document.getElementById('analysis-layer-b-wrap');
els.zoneLayer = document.getElementById('analysis-zone-layer');
els.inputLayer = document.getElementById('analysis-input-layer');
els.membership = document.getElementById('analysis-membership');
els.memberHint = document.getElementById('analysis-membership-hint');
els.field = document.getElementById('analysis-field');
els.statsBox = document.getElementById('analysis-stats-box');
els.outName = document.getElementById('analysis-output-name');
els.result = document.getElementById('analysis-result');
els.btnRun = document.getElementById('analysis-run');
els.btnExport = document.getElementById('analysis-export-table');
els.scope = document.getElementById('analysis-scope');
els.scopeHint = document.getElementById('analysis-scope-hint');
if (!els.root.dataset.wired) {
els.root.dataset.wired = '1';
els.mode.forEach((r) => r.addEventListener('change', onModeChange));
els.op.addEventListener('change', onOpChange);
els.membership.addEventListener('change', onMembershipChange);
els.inputLayer.addEventListener('change', refreshFieldOptions);
els.scope.addEventListener('change', onScopeChange);
els.btnRun.addEventListener('click', onRun);
els.btnExport.addEventListener('click', onExportTable);
}
}
// ---------------------------------------------------------------------------
// Population
// ---------------------------------------------------------------------------
function layerOptionsHtml(selectedIdx = null) {
return layerChoices.map((c, i) =>
`<option value="${i}"${i === selectedIdx ? ' selected' : ''}>` +
`${escapeHtml(c.title)} (${c.count.toLocaleString()})</option>`
).join('');
}
function populateOps() {
els.op.innerHTML = OVERLAY_OPS
.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('');
els.membership.innerHTML = MEMBERSHIP_MODES
.map((m) => `<option value="${m.key}">${escapeHtml(m.label)}</option>`).join('');
els.statsBox.innerHTML = STATS.map((s) => `
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" value="${s.key}"
id="analysis-stat-${s.key}"${s.key === 'count' ? ' checked' : ''}>
<label class="form-check-label" for="analysis-stat-${s.key}">${escapeHtml(s.label)}</label>
</div>`).join('');
}
function refreshFieldOptions() {
const choice = layerChoices[Number(els.inputLayer.value)];
const feats = choice ? choice.layer.getSource().getFeatures() : [];
const fields = listNumericFields(feats);
els.field.innerHTML = '<option value="">(none — count/area only)</option>' +
fields.map((f) => `<option value="${escapeHtml(f)}">${escapeHtml(f)}</option>`).join('');
els.field.disabled = fields.length === 0;
}
// ---------------------------------------------------------------------------
// UI reactions
// ---------------------------------------------------------------------------
function currentMode() {
return (els.mode.find((r) => r.checked) || {}).value || 'overlay';
}
function onModeChange() {
// A previous zonal table doesn't belong to a new overlay run.
lastZonal = null; syncExportButton();
const overlay = currentMode() === 'overlay';
els.overlayBox.classList.toggle('d-none', !overlay);
els.zonalBox.classList.toggle('d-none', overlay);
suggestName();
}
function onOpChange() {
const op = OVERLAY_OPS.find((o) => o.key === els.op.value);
els.opHint.textContent = op ? op.hint : '';
// Union can run on a single layer; the others need a second one.
const needsB = op ? op.needsB : true;
els.layerBWrap.querySelector('label').textContent =
needsB ? 'Layer B' : 'Layer B (optional)';
suggestName();
}
function onMembershipChange() {
const m = MEMBERSHIP_MODES.find((x) => x.key === els.membership.value);
els.memberHint.textContent = m ? m.hint : '';
}
function suggestName() {
if (els.outName.dataset.touched === '1') return;
if (currentMode() === 'overlay') {
const op = els.op.value || 'overlay';
els.outName.value = `${op}_result`;
} else {
els.outName.value = 'zonal_stats';
}
}
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
/**
* Features of the chosen layer, narrowed by the "Apply to" scope.
* 'view' uses the source's spatial index, so it stays fast on large layers.
*/
function featuresOf(select) {
const choice = layerChoices[Number(select.value)];
if (!choice) return [];
const source = choice.layer.getSource();
const scope = els.scope ? els.scope.value : 'all';
if (scope === 'view') {
const map = mapView.getMap();
const extent = map.getView().calculateExtent(map.getSize());
return source.getFeaturesInExtent(extent);
}
if (scope === 'selection') {
const selected = new Set(mapView.getSelectedFeatures());
return source.getFeatures().filter((f) => selected.has(f));
}
if (scope === 'drawn' && drawnScope) {
return source.getFeatures().filter((f) => drawnScope.features.has(f));
}
return source.getFeatures();
}
function onScopeChange() {
const scope = els.scope.value;
if (scope === 'drawn' && drawnScope) {
els.scopeHint.textContent =
`${drawnScope.features.size.toLocaleString()} feature(s) caught by the ${drawnScope.label}.`;
} else if (scope === 'selection') {
const n = mapView ? mapView.getSelectedFeatures().length : 0;
els.scopeHint.textContent = n === 0
? 'Nothing selected yet — use the Digitise tool: shift-click features, or hold Ctrl/Cmd and drag a box.'
: `${n.toLocaleString()} feature(s) currently selected.`;
} else if (scope === 'view') {
els.scopeHint.textContent = 'Only features inside the current map view are analysed — zoom to the area of interest first.';
} else {
els.scopeHint.textContent = 'Every feature in the chosen layer. Narrow this if a large layer is slow.';
}
}
function ensureAnalysisGroup() {
let group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);
if (!group) {
mapView.addLayerGroup(ANALYSIS_GROUP_ID, ANALYSIS_GROUP_TITLE,
'Results of client-side spatial analysis');
group = mapView.getLayerGroupByTitle(ANALYSIS_GROUP_TITLE);
}
return group;
}
function onRun() {
els.result.innerHTML = '';
els.btnRun.disabled = true;
// Yield a frame so the button state paints before a heavy synchronous run.
setTimeout(() => {
try {
const mode = currentMode();
const name = (els.outName.value || '').trim() ||
(mode === 'overlay' ? 'overlay_result' : 'zonal_stats');
let geojson, summary;
let zonalWarnings = [];
if (mode === 'overlay') {
lastZonal = null;
const op = els.op.value;
const a = featuresOf(els.layerA);
const b = featuresOf(els.layerB);
const needsB = (OVERLAY_OPS.find((o) => o.key === op) || {}).needsB;
if (a.length === 0) throw new Error('Layer A has no features.');
if (needsB && b.length === 0) throw new Error('This operation needs a second layer with features.');
geojson = runOverlay(op, a, b);
summary = `${geojson.features.length.toLocaleString()} feature(s) produced.`;
} else {
const zones = featuresOf(els.zoneLayer);
const inputs = featuresOf(els.inputLayer);
const stats = Array.from(els.statsBox.querySelectorAll('input:checked')).map((i) => i.value);
const field = els.field.value || null;
if (stats.length === 0) throw new Error('Select at least one statistic.');
const needsField = stats.some((s) => (STATS.find((x) => x.key === s) || {}).needsField);
if (needsField && !field) throw new Error('Sum / mean / min / max need a numeric field.');
const res = zonalStatistics({
zoneFeatures: zones, inputFeatures: inputs,
stats, field, membership: els.membership.value,
});
geojson = res.geojson;
zonalWarnings = res.warnings || [];
summary = renderRows(res.rows);
// Keep the table AND the settings that produced it, so the Excel export
// is self-describing — a table of numbers with no record of how it was
// produced can't be checked or reproduced later.
const membershipLabel = (MEMBERSHIP_MODES.find((m) => m.key === els.membership.value) || {}).label;
lastZonal = {
rows: res.rows,
warnings: res.warnings || [],
params: {
'Analysis': 'Zonal statistics',
'Zone layer': labelOfSelect(els.zoneLayer),
'Features summarised': labelOfSelect(els.inputLayer),
'Applied to': scopeLabel(),
'Zones analysed': zones.length,
'Features analysed': inputs.length,
'Counted when': membershipLabel || els.membership.value,
'Statistics': stats.map((k) => (STATS.find((x) => x.key === k) || {}).label || k).join(', '),
'Numeric field': field || '(none)',
'Result layer': name,
},
};
}
if (!geojson.features.length) {
els.result.innerHTML =
`<div class="alert alert-warning py-2 px-3 mb-0">No result features — the layers may not overlap.</div>`;
return;
}
const group = ensureAnalysisGroup();
mapView.addGeoJSONLayer(geojson, name, {
strokeColor: '#d97706', strokeWidth: 2, fillColor: 'rgba(217,119,6,0.18)',
}, group);
syncExportButton();
const warnHtml = zonalWarnings.length
? `<div class="alert alert-warning py-2 px-3 mb-2" style="font-size:0.85rem;">` +
zonalWarnings.map((w) => `<div>${escapeHtml(w)}</div>`).join('') + `</div>`
: '';
els.result.innerHTML =
`<div class="alert alert-success py-2 px-3 mb-2">Added layer “${escapeHtml(name)}”.</div>` +
warnHtml +
(typeof summary === 'string' && summary.startsWith('<') ? summary : `<div class="small text-muted">${escapeHtml(summary)}</div>`);
showToast(`Analysis complete — “${name}” added to the Analysis group.`, 'success', 4000);
} catch (err) {
console.error('[Analysis] run failed:', err);
els.result.innerHTML =
`<div class="alert alert-danger py-2 px-3 mb-0">${escapeHtml(err.message || String(err))}</div>`;
} finally {
els.btnRun.disabled = false;
}
}, 20);
}
/** Human label of a layer <select>, e.g. "Parcels (25,004)". */
function labelOfSelect(select) {
const opt = select.options[select.selectedIndex];
return opt ? opt.textContent : '(none)';
}
/** Human label of the current "Apply to" scope. */
function scopeLabel() {
const opt = els.scope.options[els.scope.selectedIndex];
return opt ? opt.textContent : 'Whole layer';
}
/**
* Export the most recent zonal table to .xlsx two sheets, the figures plus
* the parameters that produced them. The writer is loaded on demand.
*/
async function onExportTable() {
if (!lastZonal) return;
els.btnExport.disabled = true;
try {
const { exportXlsx } = await import('./analysis/xlsx.js');
const { rows, params, warnings } = lastZonal;
const keys = Object.keys(rows[0] || {}).filter((k) => k !== 'zone');
const resultRows = [
{ cells: ['Zone', ...keys], bold: true },
...rows.map((r) => ['zone' in r ? r.zone : '', ...keys.map((k) => (r[k] === null || r[k] === undefined ? '' : r[k]))]),
];
const paramRows = [
{ cells: ['LUPMIS2 — Zonal statistics'], bold: true },
[],
{ cells: ['Parameter', 'Value'], bold: true },
...Object.entries(params).map(([k, v]) => [k, v]),
['Exported', new Date().toLocaleString()],
// Carry any caveats into the workbook, so a figure cannot be read out of
// context once it leaves the application.
...((warnings && warnings.length)
? [[], { cells: ['Notes'], bold: true }, ...warnings.map((w) => ['', w])]
: []),
];
const base = (params['Result layer'] || 'zonal_stats')
.toString().trim().replace(/[^A-Za-z0-9_\-]+/g, '_') || 'zonal_stats';
exportXlsx(
[{ name: 'Parameters', rows: paramRows }, { name: 'Results', rows: resultRows }],
`${base}.xlsx`
);
showToast('Table exported to Excel.', 'success', 3000);
} catch (err) {
console.error('[Analysis] table export failed:', err);
showToast(`Could not export the table: ${err.message}`, 'error', 6000);
} finally {
els.btnExport.disabled = false;
}
}
/** Show or hide the export button depending on whether a table exists. */
function syncExportButton() {
if (!els.btnExport) return;
els.btnExport.classList.toggle('d-none', !lastZonal);
}
function renderRows(rows) {
if (!rows || !rows.length) return '';
const keys = Object.keys(rows[0]).filter((k) => k !== 'zone');
const head = ['Zone', ...keys.map((k) => k.replace(/^zs_/, ''))]
.map((h) => `<th class="text-nowrap">${escapeHtml(h)}</th>`).join('');
const body = rows.map((r) =>
`<tr><td class="text-nowrap">${escapeHtml(String(r.zone))}</td>` +
keys.map((k) => `<td>${r[k] === null || r[k] === undefined ? '—' : escapeHtml(String(r[k]))}</td>`).join('') +
'</tr>').join('');
return `<div class="table-responsive" style="max-height:220px;overflow:auto;">
<table class="table table-sm table-striped mb-0" style="font-size:0.85rem;">
<thead class="table-light" style="position:sticky;top:0;"><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table></div>`;
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
/**
* Open the Analysis panel.
* @param {import('./components/MapView.js').default} view
*/
export function openAnalysisModal(view, opts = {}) {
mapView = view;
cacheEls();
// When launched from a Circle/Area analysis popup, offer that catch as a
// scope and pre-select it — the whole point of starting from a drawn shape.
drawnScope = (opts.scopeFeatures && opts.scopeFeatures.length)
? { features: new Set(opts.scopeFeatures), label: opts.scopeLabel || 'drawn area' }
: null;
syncDrawnScopeOption();
layerChoices = mapView.listVectorLayers({ nonEmptyOnly: true });
if (layerChoices.length === 0) {
showToast('No vector layers with features are loaded — nothing to analyse yet.', 'warning', 5000);
return;
}
populateOps();
const optsA = layerOptionsHtml(0);
const optsB = layerOptionsHtml(layerChoices.length > 1 ? 1 : 0);
els.layerA.innerHTML = optsA;
els.layerB.innerHTML = optsB;
els.zoneLayer.innerHTML = optsA;
els.inputLayer.innerHTML = optsB;
els.outName.dataset.touched = '';
els.outName.addEventListener('input', () => { els.outName.dataset.touched = '1'; }, { once: true });
els.result.innerHTML = '';
lastZonal = null;
els.scope.value = drawnScope ? 'drawn' : 'all';
refreshFieldOptions();
onOpChange();
onMembershipChange();
onScopeChange();
onModeChange();
modal = modal || new Modal(els.root);
modal.show();
}
/**
* Add or remove the "From drawn area" scope option depending on whether the
* panel was opened from a Circle/Area popup.
* @private
*/
function syncDrawnScopeOption() {
const existing = els.scope.querySelector('option[value="drawn"]');
if (!drawnScope) {
if (existing) existing.remove();
return;
}
const label = `From ${drawnScope.label} (${drawnScope.features.size.toLocaleString()} features)`;
if (existing) {
existing.textContent = label;
} else {
const opt = document.createElement('option');
opt.value = 'drawn';
opt.textContent = label;
els.scope.insertBefore(opt, els.scope.firstChild);
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

View File

@ -1,300 +0,0 @@
/**
* overlay.js client-side vector overlay operations (Stage 1 of the
* LUPMIS2 GIS Analytical Tools concept).
*
* Pure geometry helpers built on Turf.js. No DOM, no map the caller hands
* in OpenLayers features and gets a GeoJSON FeatureCollection back, ready for
* MapView.addGeoJSONLayer().
*
* All Turf work happens in EPSG:4326 (Turf's area/length functions assume
* WGS84), so features are reprojected out of the map's EPSG:3857 on the way
* in and the result is handed back as WGS84 GeoJSON the same convention
* gis-export.js uses.
*
* intersectFeatures(a, b) parts of A that fall inside B (attrs: A + B)
* clipFeatures(a, b) parts of A that fall inside B (attrs: A only)
* differenceFeatures(a, b) parts of A that fall OUTSIDE B (attrs: A)
* unionFeatures(a[, b]) dissolved outline of A (and B)
*/
import GeoJSON from 'ol/format/GeoJSON';
import intersect from '@turf/intersect';
import union from '@turf/union';
import difference from '@turf/difference';
import { featureCollection } from '@turf/helpers';
/**
* Guard: only *candidate* pairs (those whose bounding boxes actually overlap)
* reach the expensive geometry test, so this ceiling is about real work, not
* the raw n×m product. District-scale data (tens of thousands of parcels
* against a few dozen zones) sits far below it.
*/
export const MAX_OVERLAY_CANDIDATES = 400000;
const fmt = new GeoJSON();
/**
* Convert OpenLayers features (map projection) to WGS84 GeoJSON features.
* @param {Array<import('ol/Feature').default>} features
* @param {string} [mapProjection='EPSG:3857']
* @returns {Array<Object>} GeoJSON Features
*/
export function toGeoJSONFeatures(features, mapProjection = 'EPSG:3857') {
const out = [];
for (const f of features || []) {
const geom = f.getGeometry && f.getGeometry();
if (!geom) continue;
const gj = fmt.writeFeatureObject(f, {
dataProjection: 'EPSG:4326',
featureProjection: mapProjection,
});
if (gj && gj.geometry) out.push(gj);
}
return out;
}
/**
* Bounding box of a GeoJSON feature as [minX, minY, maxX, maxY].
* Walks the coordinate arrays directly no extra dependency, and fast enough
* to run over tens of thousands of features.
* @param {Object} feature GeoJSON Feature
* @returns {number[]|null}
*/
export function bboxOf(feature) {
const geom = feature && feature.geometry;
if (!geom || !geom.coordinates) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const walk = (c) => {
if (typeof c[0] === 'number') {
if (c[0] < minX) minX = c[0];
if (c[0] > maxX) maxX = c[0];
if (c[1] < minY) minY = c[1];
if (c[1] > maxY) maxY = c[1];
return;
}
for (const sub of c) walk(sub);
};
walk(geom.coordinates);
return Number.isFinite(minX) ? [minX, minY, maxX, maxY] : null;
}
/** Do two bounding boxes overlap? Cheap pre-filter before real geometry work. */
export function bboxOverlaps(a, b) {
if (!a || !b) return false;
return !(a[2] < b[0] || b[2] < a[0] || a[3] < b[1] || b[3] < a[1]);
}
/** Is a point [x, y] inside a bounding box? */
export function bboxContainsPoint(box, x, y) {
if (!box) return false;
return x >= box[0] && x <= box[2] && y >= box[1] && y <= box[3];
}
/**
* Reject a non-polygon input with a message that says what is actually wrong.
*
* Overlay operations are polygon-only, but the inputs are silently filtered by
* polygonsOnly() so a line layer (contours, roads) used to produce an empty
* result and the misleading "the layers may not overlap". Failing loudly here
* tells the user the real reason.
*
* @param {Array} polygons the surviving polygonal features
* @param {Array} original everything that was passed in
* @param {string} role 'Layer A' / 'Layer B'
*/
function assertPolygons(polygons, original, role) {
if (polygons.length > 0 || original.length === 0) return;
const types = [...new Set(original.map((f) => f?.geometry?.type).filter(Boolean))];
const found = types.length ? types.join(', ') : 'no geometry';
throw new Error(
`${role} contains no polygons (found ${found}). Overlay operations work on ` +
`polygon layers only — line layers such as contours or roads cannot be used here. ` +
`To summarise a line layer per zone, use Zonal statistics instead.`
);
}
/** Keep only polygonal features — overlay is undefined for points/lines here. */
function polygonsOnly(gjFeatures) {
return (gjFeatures || []).filter((f) => {
const t = f?.geometry?.type;
return t === 'Polygon' || t === 'MultiPolygon';
});
}
/** Merge two property bags, prefixing B's keys on collision so nothing is lost. */
function mergeProps(propsA, propsB, prefixB = 'b_') {
const out = { ...(propsA || {}) };
for (const [k, v] of Object.entries(propsB || {})) {
out[Object.prototype.hasOwnProperty.call(out, k) ? `${prefixB}${k}` : k] = v;
}
return out;
}
function fc(features) {
return { type: 'FeatureCollection', features };
}
/**
* Intersection: every piece of A that also lies inside B.
* Result carries A's attributes merged with the matching B feature's.
*
* @param {Array} aFeatures OL features (layer A)
* @param {Array} bFeatures OL features (layer B)
* @param {Object} [opts]
* @param {boolean} [opts.keepBAttributes=true] merge B's properties into the result
* @returns {Object} GeoJSON FeatureCollection (WGS84)
*/
export function intersectFeatures(aFeatures, bFeatures, opts = {}) {
const { keepBAttributes = true } = opts;
const gjA = toGeoJSONFeatures(aFeatures);
const gjB = toGeoJSONFeatures(bFeatures);
const A = polygonsOnly(gjA);
const B = polygonsOnly(gjB);
assertPolygons(A, gjA, 'Layer A');
assertPolygons(B, gjB, 'Layer B');
// Pre-compute B's boxes once, then only test pairs that can possibly meet.
const bBoxes = B.map(bboxOf);
let candidates = 0;
const out = [];
for (const a of A) {
const aBox = bboxOf(a);
for (let i = 0; i < B.length; i++) {
if (!bboxOverlaps(aBox, bBoxes[i])) continue;
if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);
let piece = null;
try { piece = intersect(featureCollection([a, B[i]])); } catch { piece = null; }
if (!piece || !piece.geometry) continue;
piece.properties = keepBAttributes
? mergeProps(a.properties, B[i].properties)
: { ...(a.properties || {}) };
out.push(piece);
}
}
return fc(out);
}
/**
* Clip: same geometry result as intersect, but the output keeps ONLY layer A's
* attributes the usual "cut this layer to that boundary" operation.
*/
export function clipFeatures(aFeatures, clipFeatures_, opts = {}) {
return intersectFeatures(aFeatures, clipFeatures_, { ...opts, keepBAttributes: false });
}
/**
* Difference: the parts of A that do NOT fall inside any feature of B.
* Each A feature is reduced by every overlapping B feature in turn; features
* fully covered by B drop out of the result.
*
* @returns {Object} GeoJSON FeatureCollection (WGS84)
*/
export function differenceFeatures(aFeatures, bFeatures) {
const gjA = toGeoJSONFeatures(aFeatures);
const gjB = toGeoJSONFeatures(bFeatures);
const A = polygonsOnly(gjA);
const B = polygonsOnly(gjB);
assertPolygons(A, gjA, 'Layer A');
assertPolygons(B, gjB, 'Layer B');
const bBoxes = B.map(bboxOf);
let candidates = 0;
const out = [];
for (const a of A) {
const aBox = bboxOf(a);
let remainder = a;
for (let i = 0; i < B.length; i++) {
if (!remainder) break;
// Only subtract a B feature that can actually touch what's left.
if (!bboxOverlaps(aBox, bBoxes[i])) continue;
if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);
try {
const next = difference(featureCollection([remainder, B[i]]));
remainder = next && next.geometry ? next : null;
} catch {
// A malformed pair shouldn't kill the whole run — keep what we have.
}
}
if (remainder && remainder.geometry) {
remainder.properties = { ...(a.properties || {}) };
out.push(remainder);
}
}
return fc(out);
}
/**
* Union / dissolve: merge everything into a single outline. Pass only `a` to
* dissolve one layer, or both to merge two layers together.
*
* @returns {Object} GeoJSON FeatureCollection with 0 or 1 feature (WGS84)
*/
export function unionFeatures(aFeatures, bFeatures = []) {
// Check each layer separately rather than the combined set: if only the
// second layer had polygons, a combined check would pass and the first
// layer's features would be dropped without the user being told.
const gjA = toGeoJSONFeatures(aFeatures);
const gjB = toGeoJSONFeatures(bFeatures);
const polyA = polygonsOnly(gjA);
const polyB = polygonsOnly(gjB);
assertPolygons(polyA, gjA, 'Layer A');
assertPolygons(polyB, gjB, 'Layer B'); // no-op when no second layer is given
const all = [...polyA, ...polyB];
if (all.length === 0) return fc([]);
if (all.length === 1) return fc([all[0]]);
let merged = null;
try {
merged = union(featureCollection(all));
} catch {
// Fall back to a pairwise fold if the bulk union chokes on bad geometry.
merged = all[0];
for (let i = 1; i < all.length; i++) {
try {
const next = union(featureCollection([merged, all[i]]));
if (next && next.geometry) merged = next;
} catch { /* skip the offending feature */ }
}
}
if (!merged || !merged.geometry) return fc([]);
merged.properties = { parts: all.length };
return fc([merged]);
}
/** @private Raised only when genuinely overlapping work exceeds the budget. */
function candidateOverflow(count) {
return new Error(
`This overlay has more than ${count.toLocaleString()} genuinely overlapping feature pairs, ` +
`which is too much to run in the browser. Narrow the inputs using “Apply to” ` +
`(current map view or selected features), or run it server-side.`
);
}
/** Operation registry used by the Analysis panel. */
export const OVERLAY_OPS = [
{ key: 'intersect', label: 'Intersect', needsB: true,
hint: 'Keep the parts of layer A that fall inside layer B (attributes from both).' },
{ key: 'clip', label: 'Clip', needsB: true,
hint: 'Cut layer A to the boundary of layer B (keeps only As attributes).' },
{ key: 'difference', label: 'Difference', needsB: true,
hint: 'Keep the parts of layer A that fall outside layer B.' },
{ key: 'union', label: 'Union / Dissolve', needsB: false,
hint: 'Merge features into a single outline (optionally with a second layer).' },
];
/**
* Run an overlay operation by key.
* @param {string} op one of OVERLAY_OPS keys
* @param {Array} aFeatures
* @param {Array} bFeatures
* @returns {Object} GeoJSON FeatureCollection (WGS84)
*/
export function runOverlay(op, aFeatures, bFeatures) {
switch (op) {
case 'intersect': return intersectFeatures(aFeatures, bFeatures);
case 'clip': return clipFeatures(aFeatures, bFeatures);
case 'difference': return differenceFeatures(aFeatures, bFeatures);
case 'union': return unionFeatures(aFeatures, bFeatures);
default: throw new Error(`Unknown overlay operation: ${op}`);
}
}

View File

@ -1,222 +0,0 @@
/**
* xlsx.js a minimal, dependency-free XLSX writer.
*
* An .xlsx file is a ZIP archive of XML parts. This module builds the few
* parts Excel actually requires and packs them with a small STORED (uncompressed)
* ZIP writer readers accept stored entries, and an analysis table is far too
* small for compression to matter.
*
* Why not a library: SheetJS's npm package is stale and carries advisories,
* ExcelJS is heavy for an offline-first field app, and the JSZip in node_modules
* is only a transitive dependency of shp-write (so it could disappear). This is
* ~200 lines we control, adds nothing to the dependency tree, and works offline.
*
* buildXlsxBlob([{ name: 'Results', rows: [['Zone','Count'], ['North', 3]] }])
*
* Values are written as numbers when typeof === 'number' (and finite), otherwise
* as inline strings which avoids needing a sharedStrings part. Passing a row
* as { cells: [...], bold: true } renders that row in bold.
*/
// ---------------------------------------------------------------------------
// XML helpers
// ---------------------------------------------------------------------------
function esc(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;')
// Strip control characters Excel rejects outside tab/newline.
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
}
/** 0 → A, 25 → Z, 26 → AA … */
function colName(index) {
let s = '';
let n = index;
while (n >= 0) {
s = String.fromCharCode((n % 26) + 65) + s;
n = Math.floor(n / 26) - 1;
}
return s;
}
function normaliseRow(row) {
if (row && !Array.isArray(row) && Array.isArray(row.cells)) {
return { cells: row.cells, bold: !!row.bold };
}
return { cells: Array.isArray(row) ? row : [row], bold: false };
}
function sheetXml(rows) {
const out = [];
rows.forEach((raw, r) => {
const { cells, bold } = normaliseRow(raw);
const style = bold ? ' s="1"' : '';
const cellXml = cells.map((v, c) => {
const ref = `${colName(c)}${r + 1}`;
if (v === null || v === undefined || v === '') return `<c r="${ref}"${style}/>`;
if (typeof v === 'number' && Number.isFinite(v)) {
return `<c r="${ref}"${style}><v>${v}</v></c>`;
}
return `<c r="${ref}"${style} t="inlineStr"><is><t xml:space="preserve">${esc(v)}</t></is></c>`;
}).join('');
out.push(`<row r="${r + 1}">${cellXml}</row>`);
});
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${out.join('')}</sheetData></worksheet>`;
}
/** Excel requires fills[0]=none and fills[1]=gray125; keep the rest minimal. */
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`;
// ---------------------------------------------------------------------------
// Minimal STORED-ZIP writer
// ---------------------------------------------------------------------------
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
/** Pack files (name → string) into an uncompressed ZIP Blob. */
function zipStore(files) {
const enc = new TextEncoder();
const entries = Object.entries(files).map(([name, text]) => ({
name, nameBytes: enc.encode(name), data: enc.encode(text),
}));
// DOS time/date — a fixed timestamp keeps output deterministic.
const time = 0, date = ((2020 - 1980) << 9) | (1 << 5) | 1;
const chunks = [];
const central = [];
let offset = 0;
const u16 = (v) => [v & 0xFF, (v >>> 8) & 0xFF];
const u32 = (v) => [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
for (const e of entries) {
const crc = crc32(e.data);
const size = e.data.length;
const local = [
...u32(0x04034b50), ...u16(20), ...u16(0), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0),
];
chunks.push(new Uint8Array(local), e.nameBytes, e.data);
central.push([
...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(0), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u32(offset),
]);
central.push(e.nameBytes);
offset += local.length + e.nameBytes.length + size;
}
const cdParts = [];
let cdSize = 0;
for (const part of central) {
const arr = part instanceof Uint8Array ? part : new Uint8Array(part);
cdParts.push(arr); cdSize += arr.length;
}
const end = new Uint8Array([
...u32(0x06054b50), ...u16(0), ...u16(0),
...u16(entries.length), ...u16(entries.length),
...u32(cdSize), ...u32(offset), ...u16(0),
]);
return new Blob([...chunks, ...cdParts, end],
{ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Build an .xlsx Blob from one or more sheets.
* @param {Array<{name: string, rows: Array}>} sheets
* @returns {Blob}
*/
export function buildXlsxBlob(sheets) {
const list = (sheets || []).filter((s) => s && Array.isArray(s.rows));
if (list.length === 0) throw new Error('No sheets to export.');
const files = {};
const sheetEntries = list.map((s, i) => {
const file = `xl/worksheets/sheet${i + 1}.xml`;
files[file] = sheetXml(s.rows);
// Excel sheet names: max 31 chars, and : \ / ? * [ ] are illegal.
const safe = String(s.name || `Sheet${i + 1}`).replace(/[:\\/?*[\]]/g, ' ').slice(0, 31);
return { id: i + 1, name: safe, file };
});
files['[Content_Types].xml'] =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${sheetEntries.map((s) => `<Override PartName="/${s.file}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('\n')}
</Types>`;
files['_rels/.rels'] =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`;
files['xl/workbook.xml'] =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>${sheetEntries.map((s) => `<sheet name="${esc(s.name)}" sheetId="${s.id}" r:id="rId${s.id}"/>`).join('')}</sheets>
</workbook>`;
files['xl/_rels/workbook.xml.rels'] =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${sheetEntries.map((s) => `<Relationship Id="rId${s.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${s.id}.xml"/>`).join('\n')}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;
files['xl/styles.xml'] = STYLES_XML;
return zipStore(files);
}
/** Trigger a browser download of a Blob. */
export 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(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** Convenience: build and download in one call. */
export function exportXlsx(sheets, filename) {
downloadBlob(buildXlsxBlob(sheets), filename);
}

View File

@ -1,231 +0,0 @@
/**
* zonal.js vector-in-vector zonal statistics (Stage 1 of the LUPMIS2 GIS
* Analytical Tools concept).
*
* Answers questions like "how many parcels are in each collector zone?" or
* "what is the mean plot size per zone?" the classic *summarize within*
* operation. Raster × vector zonal statistics arrives with the raster pipeline
* in Stage 2; this module is pure vector and runs fully offline.
*
* Membership rules:
* 'centroid' a feature belongs to the zone containing its centroid
* (default; each feature counts exactly once)
* 'intersects' a feature belongs to every zone it touches
* (features straddling a boundary are counted more than once)
*/
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import booleanIntersects from '@turf/boolean-intersects';
import centroid from '@turf/centroid';
import area from '@turf/area';
import { toGeoJSONFeatures, bboxOf, bboxOverlaps, bboxContainsPoint } from './overlay.js';
/**
* Guard on *candidate* pairs only a bounding-box pre-filter means a feature
* is geometry-tested against a zone only when it could plausibly fall in it.
* A district of 25,000 parcels across 40-odd zones produces roughly one
* candidate per parcel, so real workloads sit far below this ceiling.
*/
export const MAX_ZONAL_CANDIDATES = 400000;
export const MEMBERSHIP_MODES = [
{ key: 'centroid', label: 'Centroid inside zone',
hint: 'Each feature is counted once, in the zone containing its centre.' },
{ key: 'intersects', label: 'Any overlap with zone',
hint: 'A feature is counted in every zone it touches (may double-count).' },
];
/** Statistics offered by the Analysis panel. */
export const STATS = [
{ key: 'count', label: 'Count', needsField: false },
{ key: 'sum', label: 'Sum', needsField: true },
{ key: 'mean', label: 'Mean', needsField: true },
{ key: 'min', label: 'Minimum', needsField: true },
{ key: 'max', label: 'Maximum', needsField: true },
{ key: 'area', label: 'Total area (m²)', needsField: false },
];
/**
* List the numeric attribute names available across a set of OL features, so
* the panel can offer only fields that can actually be summed or averaged.
* @param {Array} features OL features
* @returns {string[]}
*/
export function listNumericFields(features) {
const counts = new Map();
for (const f of features || []) {
const props = f.getProperties ? f.getProperties() : {};
for (const [k, v] of Object.entries(props)) {
if (k === 'geometry' || k.startsWith('_')) continue;
const n = typeof v === 'number' ? v : (v !== '' && v != null && !Number.isNaN(Number(v)) ? Number(v) : null);
if (n === null) continue;
counts.set(k, (counts.get(k) || 0) + 1);
}
}
return Array.from(counts.keys()).sort();
}
/** Coerce a property to a finite number, or null when it isn't numeric. */
function num(value) {
if (value === null || value === undefined || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* Compute zonal statistics.
*
* @param {Object} params
* @param {Array} params.zoneFeatures OL features defining the zones (polygons)
* @param {Array} params.inputFeatures OL features to summarise
* @param {string[]} params.stats keys from STATS
* @param {string} [params.field] numeric attribute (for sum/mean/min/max)
* @param {string} [params.membership] 'centroid' | 'intersects'
* @param {string} [params.prefix='zs_'] prefix for the new attribute names
* @returns {{ geojson: Object, rows: Array<Object> }}
* geojson the zones with statistics attached (WGS84), ready to add as a layer
* rows a plain table of the same numbers, for display/export
* warnings figures that are valid but easy to misread (e.g. area over a
* line layer); shown to the user rather than thrown
*/
export function zonalStatistics({
zoneFeatures,
inputFeatures,
stats = ['count'],
field = null,
membership = 'centroid',
prefix = 'zs_',
}) {
const zones = (toGeoJSONFeatures(zoneFeatures) || []).filter((z) => {
const t = z?.geometry?.type;
return t === 'Polygon' || t === 'MultiPolygon';
});
const inputs = toGeoJSONFeatures(inputFeatures) || [];
if (zones.length === 0) throw new Error('The zone layer contains no polygons.');
// Warnings are returned alongside the result rather than thrown: the run is
// still valid and useful, but some figures would be silently meaningless
// without an explanation.
const warnings = [];
const inputTypes = new Set(inputs.map((f) => f?.geometry?.type).filter(Boolean));
const inputsHavePolygons = inputTypes.has('Polygon') || inputTypes.has('MultiPolygon');
// Lines and points have no area, so "Total area" would report a silent 0.
if (stats.includes('area') && !inputsHavePolygons && inputs.length > 0) {
warnings.push(
`Total area is 0 because the features being summarised are ` +
`${[...inputTypes].join('/')} — only polygons have an area.`
);
}
// Line inputs only. Averaging a field over line segments is not an
// area-weighted average of the surface those lines describe — the classic
// trap being "mean elevation per parcel" from contours, where segment count
// has no relation to the area at each elevation. (Averaging over points is a
// normal statistic, so it is not flagged.)
const inputsAreLines = inputTypes.has('LineString') || inputTypes.has('MultiLineString');
if (stats.includes('mean') && inputsAreLines && !inputsHavePolygons && field) {
warnings.push(
`Mean is the average of “${field}” across the individual line features in ` +
`each zone, not an area-weighted average of the surface they describe. ` +
`For contour data, Minimum and Maximum (the elevation range) are the ` +
`figures you can rely on.`
);
}
// Pre-compute once: each input's centroid (centroid mode) or bbox
// (intersects mode), and every zone's bbox. The bbox comparisons below are
// cheap arithmetic; only survivors reach the real geometry test.
const centroids = membership === 'centroid'
? inputs.map((f) => { try { return centroid(f); } catch { return null; } })
: null;
const inputBoxes = membership === 'centroid' ? null : inputs.map(bboxOf);
const zoneBoxes = zones.map(bboxOf);
let candidates = 0;
const rows = [];
for (let z = 0; z < zones.length; z++) {
const zone = zones[z];
const zBox = zoneBoxes[z];
const members = [];
for (let i = 0; i < inputs.length; i++) {
let inside = false;
try {
if (membership === 'centroid') {
const c = centroids[i];
if (!c) continue;
const [cx, cy] = c.geometry.coordinates;
if (!bboxContainsPoint(zBox, cx, cy)) continue; // cheap reject
if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);
inside = booleanPointInPolygon(c, zone);
} else {
if (!bboxOverlaps(zBox, inputBoxes[i])) continue; // cheap reject
if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);
inside = booleanIntersects(inputs[i], zone);
}
} catch { inside = false; }
if (inside) members.push(inputs[i]);
}
const values = field
? members.map((f) => num(f.properties ? f.properties[field] : null)).filter((v) => v !== null)
: [];
const result = {};
for (const s of stats) {
switch (s) {
case 'count':
result[`${prefix}count`] = members.length; break;
case 'sum':
result[`${prefix}sum`] = values.length ? round(values.reduce((a, b) => a + b, 0)) : 0; break;
case 'mean':
result[`${prefix}mean`] = values.length
? round(values.reduce((a, b) => a + b, 0) / values.length) : null; break;
case 'min':
result[`${prefix}min`] = values.length ? round(Math.min(...values)) : null; break;
case 'max':
result[`${prefix}max`] = values.length ? round(Math.max(...values)) : null; break;
case 'area': {
let total = 0;
for (const m of members) { try { total += area(m); } catch { /* skip */ } }
result[`${prefix}area_m2`] = Math.round(total); break;
}
default: break;
}
}
zone.properties = { ...(zone.properties || {}), ...result };
rows.push({ ...labelOf(zone), ...result });
}
return { geojson: { type: 'FeatureCollection', features: zones }, rows, warnings };
}
/** @private Raised only when genuinely plausible pairs exceed the budget. */
function candidateOverflow(count) {
return new Error(
`More than ${count.toLocaleString()} features fall within the zones' bounding boxes, ` +
`which is too much to summarise in the browser. Narrow the inputs using “Apply to” ` +
`(current map view or selected features).`
);
}
/** Round to 3 decimals to keep attribute tables readable. */
function round(n) {
return Math.round(n * 1000) / 1000;
}
/**
* Pick a human label for a zone row first of the usual naming fields,
* else a generic index.
*/
function labelOf(zone) {
const p = zone.properties || {};
for (const k of ['zone_name', 'name', 'ZONE_NAME', 'NAME', 'upn', 'UPN', 'id', 'ID']) {
if (p[k] !== undefined && p[k] !== null && String(p[k]).trim() !== '') {
return { zone: String(p[k]) };
}
}
return { zone: '(unnamed)' };
}

View File

@ -37,11 +37,6 @@ export class MapTools {
style: this.getMeasureStyle(),
title: 'Measurements',
zIndex: 100,
// Measurements are read-only. Without this the Digitise tool treats a
// measured circle like any other geometry: it shows vertex handles, and
// dragging one silently reshapes the measurement — the intersection test
// follows the new shape while the reported radius and area do not.
selectable: false,
});
// Create drawing layer (utility layer for temporary draw interactions;
@ -225,9 +220,6 @@ export class MapTools {
// Tag the circle feature so the dblclick handler can identify it
feature.set('_layerType', 'measure_circle');
// Snapshot only — NOT the source of truth. Anything reporting the radius
// must read it from the geometry (getRadius()), otherwise the figure can
// drift from the shape actually being analysed.
feature.set('_radius', radius);
feature.set('_center', center);

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,6 @@ function cacheEls() {
els.filename = document.getElementById('export-gis-filename');
els.tbody = document.getElementById('export-gis-fields-tbody');
els.fmtHint = document.getElementById('export-gis-format-hint');
els.fieldsWrap = document.getElementById('export-gis-fields-wrap');
els.btnGo = document.getElementById('export-gis-go');
els.fmtInputs = Array.from(document.querySelectorAll('input[name="export-gis-format"]'));
@ -128,14 +127,7 @@ function onFormatChange() {
'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.',
pdf: 'A one-page <strong>summary report</strong> of the analysis figures — not the ' +
'feature geometry. Field renaming does not apply.',
}[fmt];
// The PDF is a report of the summary figures, so the per-field rename table
// and the geometry-oriented filename are irrelevant to it.
const isPdf = fmt === 'pdf';
if (els.fieldsWrap) els.fieldsWrap.classList.toggle('d-none', isPdf);
renderFieldsTable();
}
@ -153,22 +145,12 @@ async function onExportClick() {
els.btnGo.disabled = true;
try {
if (format === 'pdf') {
// The PDF is the analysis summary report. jspdf is imported on demand.
const rows = state.pdfRows || [];
if (rows.length === 0) {
throw new Error('No summary figures available for a PDF report.');
}
const { exportAnalysisPDF } = await import('./pdf-export.js');
await exportAnalysisPDF({ title: state.title || 'Analysis', rows });
} else {
await exportFeaturesToGis({
features: state.features,
rename: state.rename,
format,
filenameBase,
});
}
modal.hide();
} catch (err) {
console.error('[ExportGIS] failed:', err);
@ -197,9 +179,7 @@ export function openExportGisModal(ctx) {
const keys = unionAttributeKeys(features);
const rename = Object.fromEntries(keys.map((k) => [k, k]));
// pdfRows / title travel with the popup event so the PDF report option can
// be offered here instead of a separate button on the analysis popup.
state = { features, keys, rename, pdfRows: ctx.pdfRows || [], title: ctx.title };
state = { features, keys, rename };
els.summary.textContent =
`${features.length} feature${features.length === 1 ? '' : 's'} ` +
`intersecting the ${ctx.kind === 'circle' ? 'circle' : 'area'}`;

View File

@ -52,101 +52,6 @@ export function formatCoord(value, decimals = 5) {
return value.toFixed(decimals);
}
// ---------------------------------------------------------------------------
// UTM (Universal Transverse Mercator) — WGS84 forward projection
//
// Self-contained implementation of the standard USGS series-expansion forward
// formulas (Snyder, "Map Projections — A Working Manual"). Accurate to a few
// millimetres within a zone, which is far beyond what a consumer GPS fix
// needs. Ghana spans UTM zones 30N and 31N, but this handles the whole globe.
// No proj4 / OpenLayers dependency so it stays usable from the pure GeoTracker
// layer and from plain display code.
// ---------------------------------------------------------------------------
const UTM_A = 6378137.0; // WGS84 semi-major axis (metres)
const UTM_F = 1 / 298.257223563; // WGS84 flattening
const UTM_K0 = 0.9996; // UTM scale factor on the central meridian
const UTM_E2 = UTM_F * (2 - UTM_F); // first eccentricity squared
const UTM_EP2 = UTM_E2 / (1 - UTM_E2); // second eccentricity squared (e'^2)
/**
* Convert WGS84 latitude/longitude to UTM.
*
* @param {number} lat latitude in decimal degrees (80 84 is the UTM band)
* @param {number} lon longitude in decimal degrees (180 180)
* @returns {{zone:number, hemisphere:'N'|'S', easting:number, northing:number}|null}
* null when the input is missing / not finite.
*/
export function latLonToUTM(lat, lon) {
if (lat == null || lon == null || Number.isNaN(lat) || Number.isNaN(lon)) return null;
// Normalise longitude to [180, 180) so the zone maths is well-defined.
let lonNorm = ((lon + 180) % 360 + 360) % 360 - 180;
const zone = Math.floor((lonNorm + 180) / 6) + 1;
const lon0 = (zone - 1) * 6 - 180 + 3; // central meridian of the zone (deg)
const phi = lat * DEG2RAD;
const lambda = lonNorm * DEG2RAD;
const lambda0 = lon0 * DEG2RAD;
const sinPhi = Math.sin(phi);
const cosPhi = Math.cos(phi);
const tanPhi = Math.tan(phi);
const N = UTM_A / Math.sqrt(1 - UTM_E2 * sinPhi * sinPhi);
const T = tanPhi * tanPhi;
const C = UTM_EP2 * cosPhi * cosPhi;
const A = cosPhi * (lambda - lambda0);
// Meridional arc length from the equator to the given latitude.
const M = UTM_A * (
(1 - UTM_E2 / 4 - 3 * UTM_E2 ** 2 / 64 - 5 * UTM_E2 ** 3 / 256) * phi
- (3 * UTM_E2 / 8 + 3 * UTM_E2 ** 2 / 32 + 45 * UTM_E2 ** 3 / 1024) * Math.sin(2 * phi)
+ (15 * UTM_E2 ** 2 / 256 + 45 * UTM_E2 ** 3 / 1024) * Math.sin(4 * phi)
- (35 * UTM_E2 ** 3 / 3072) * Math.sin(6 * phi)
);
let easting = UTM_K0 * N * (
A
+ (1 - T + C) * A ** 3 / 6
+ (5 - 18 * T + T * T + 72 * C - 58 * UTM_EP2) * A ** 5 / 120
) + 500000; // false easting
let northing = UTM_K0 * (
M + N * tanPhi * (
A * A / 2
+ (5 - T + 9 * C + 4 * C * C) * A ** 4 / 24
+ (61 - 58 * T + T * T + 600 * C - 330 * UTM_EP2) * A ** 6 / 720
)
);
const hemisphere = lat >= 0 ? 'N' : 'S';
if (hemisphere === 'S') northing += 10000000; // false northing (southern hemi)
return {
zone,
hemisphere,
easting: Math.round(easting),
northing: Math.round(northing),
};
}
/**
* Format a latitude/longitude as a compact UTM string for display, e.g.
* `30N 706123E 619876N`. Returns the em-dash placeholder when the position
* is unavailable.
*
* @param {number} lat
* @param {number} lon
* @returns {string}
*/
export function formatUTM(lat, lon) {
const u = latLonToUTM(lat, lon);
if (!u) return '—';
return `${u.zone}${u.hemisphere} ${u.easting}E ${u.northing}N`;
}
/**
* Format a distance in metres into a friendly string (m below 1 km, km above).
* @param {number} meters

View File

@ -370,16 +370,11 @@ export class PolygonDivideInteraction extends ol_interaction_Interaction {
this.dispatchEvent(evtData);
source.dispatchEvent({ ...evtData });
// Replace original with pieces. Group the remove + all the adds into a
// single UndoRedo block (map-level undoblockstart/undoblockend) so the
// whole divide is reversed by ONE undo press regardless of piece count.
const divideMap = this.getMap();
divideMap?.dispatchEvent('undoblockstart');
// Replace original with pieces
source.removeFeature(feature);
for (const f of newFeatures) {
source.addFeature(f);
}
divideMap?.dispatchEvent('undoblockend');
// Dispatch afterdivide
const afterEvt = {

View File

@ -386,15 +386,10 @@ export class PolygonMergeInteraction extends ol_interaction_Interaction {
sourceB.dispatchEvent({ ...evtData });
}
// Replace originals with merged. Group the two removes + one add into a
// single UndoRedo block (map-level undoblockstart/undoblockend) so the
// whole merge — across both source layers — is reversed by ONE undo press.
const mergeMap = this.getMap();
mergeMap?.dispatchEvent('undoblockstart');
// Replace originals with merged
sourceA.removeFeature(featureA);
sourceB.removeFeature(featureB);
sourceA.addFeature(mergedFeature);
mergeMap?.dispatchEvent('undoblockend');
// Dispatch aftermerge events
const afterEvt = {

View File

@ -315,16 +315,10 @@ export class PolygonSplitInteraction extends ol_interaction_Interaction {
features: splitFeatures,
});
// Replace the original feature. Group the remove + two adds into a single
// UndoRedo block (via the map-level undoblockstart/undoblockend events the
// ol-ext UndoRedo interaction listens for) so the whole split is reversed
// by ONE undo press instead of three.
const splitMap = this.getMap();
splitMap?.dispatchEvent('undoblockstart');
// Replace the original feature
source.removeFeature(feature);
source.addFeature(featureA);
source.addFeature(featureB);
splitMap?.dispatchEvent('undoblockend');
// Dispatch aftersplit
this.dispatchEvent({

View File

@ -14,22 +14,7 @@
// Configuration
// ============================================================================
// Most endpoints live under the /spatial_planning module, but not all — the
// contours endpoint sits in its own module. Endpoint names containing a slash
// are resolved against API_ROOT instead (see resolveEndpoint below).
const API_ROOT = 'https://api.lupmis4luspa.org/api';
const API_BASE = `${API_ROOT}/spatial_planning`;
/**
* Build the full URL for an endpoint.
* A bare filename ("get_layers.php") resolves inside /spatial_planning;
* a path ("contours/get_by_district.php") resolves against the API root.
* @param {string} endpoint
* @returns {string}
*/
function resolveEndpoint(endpoint) {
return endpoint.includes('/') ? `${API_ROOT}/${endpoint}` : `${API_BASE}/${endpoint}`;
}
const API_BASE = 'https://api.lupmis4luspa.org/api/spatial_planning';
/**
* Per-request credentials sent with every API call.
@ -207,7 +192,7 @@ function withTimeout(options, ms = REQUEST_TIMEOUT) {
* @returns {Promise<Object>} Parsed JSON response
*/
export async function remoteGet(endpoint, params = {}, options = {}) {
const url = new URL(resolveEndpoint(endpoint));
const url = new URL(`${API_BASE}/${endpoint}`);
// Attach credentials and any extra params as query string
const allParams = { ...API_CREDENTIALS, ...params };
@ -259,7 +244,7 @@ export async function remoteGet(endpoint, params = {}, options = {}) {
* @returns {Promise<Object>} Parsed JSON response
*/
export async function remotePost(endpoint, body = {}, options = {}) {
const url = resolveEndpoint(endpoint);
const url = `${API_BASE}/${endpoint}`;
const payload = { ...API_CREDENTIALS, ...body };
@ -362,26 +347,18 @@ export async function getBuildingFootprints() {
/**
* Fetch the Contours hillshade elevation layer from the server.
*
* Endpoint: POST /api/contours/get_by_district.php
* Source: table `spatial.be_contours_hillshade` elevation contours produced
* with gdal_contour from OpenTopography's SRTM 30 m / Copernicus 30 m
* DEM, scoped per district.
* Source: table `be_contour_hillside` in the local PostgreSQL `public` schema
* (imported from OpenTopography's viz.hh_hillshade).
*
* Response rows carry the elevation in `elev` (metres) and the geometry as WKT
* in `geom`:
* { fid, id, elev: 180, districtid, geom: "LINESTRING(lon lat, …)" }
* The current district_id is passed automatically via API_CREDENTIALS.
*
* Expected response:
* { success: true, data: [{ id, elevation, geom: "LINESTRING(...)" | "MULTILINESTRING(...)" | "POLYGON(...)", ... }, ...] }
*
* @returns {Promise<Object>} Contours hillshade list
*/
export async function getContoursHillshade() {
// This endpoint lives in its own /contours module and names the district
// parameter `districtid` (no underscore, numeric), unlike the
// /spatial_planning endpoints which use `district_id`. remotePost still
// merges in the standard credentials, so api_token is sent as usual.
const districtId = API_CREDENTIALS.district_id;
return remotePost('contours/get_by_district.php', {
districtid: districtId == null ? null : Number(districtId),
});
return remotePost('get_contours_hillshade.php');
}
/**

View File

@ -46,20 +46,6 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks(id) {
// pako (zlib) is pulled in by BOTH geotiff and jspdf (via fast-png).
// Without its own chunk, Rollup assigns it to one of them and the two
// chunks reference each other ("Circular chunk: jspdf -> geotiff").
if (id.includes('node_modules/pako/')) return 'pako';
// Turf.js — only needed when the Analysis panel is opened, which
// main.js imports dynamically. Keep it out of the eager bundle.
if (id.includes('node_modules/@turf/')) return 'turf';
// geotiff.js + the OL modules that use it — only needed when the user
// opens a Cloud-Optimized GeoTIFF layer, which MapView.addCOGLayer()
// imports dynamically. Must be tested BEFORE the generic ol/ rule so
// it isn't swallowed into the eagerly-loaded openlayers chunk.
if (id.includes('node_modules/geotiff/') ||
id.includes('node_modules/ol/source/GeoTIFF') ||
id.includes('node_modules/ol/layer/WebGLTile')) return 'geotiff';
if (id.includes('node_modules/ol/')) return 'openlayers';
if (id.includes('node_modules/ol-ext/')) return 'ol-ext';
if (id.includes('node_modules/bootstrap/')) return 'bootstrap';