Offline tile cache, polygon Divide, topographic layer integrations
Major feature batch covering drawing-tool improvements, layer additions,
and offline-first capabilities. Largest changes in MapView.js (+1700),
main.js (+1500), public/sw.js (+367), and new modules under src/.
Drawing & editing toolkit
* Polygon Divide tool — sub-button under Split, divides a polygon into
N equal-area pieces via binary search; user picks the cutting edge
* UPN pick phase after Split and Divide — non-picked pieces have their
identifier fields cleared automatically
* Improved Merge algorithm — vertex-to-edge proximity (5 m tol.) with
hybrid lockstep extension; bold A/B labels on selected polygons
* Persistent vertex highlights — all vertices of the selected polygon
rendered as dots while edit mode is on, without subclassing ol-ext
* Toast notifications for merge/split/divide outcomes
* Shapefile import — addGeoJSONLayer now includes an image style so
Point features render (previously invisible)
Background & overlay layers
* DEAfrica Coastlines v0.4 (WMS) in Biophysical Environment
* DEAfrica Slope (SRTM 30m, style_slope) — semi-transparent background
* Contours hillshade — get_contours_hillshade.php → local SQLite cache
* OSM_roads — get_osm_roads.php → local SQLite cache, casing-stroke
style (black 3.5 px outer, #F0F1F0 1.5 px inner)
* External Source dialog — green + button in LayerSwitcher lets users
add WMS / WFS / XYZ layers at runtime
* Generic addWMSLayer / addXYZLayer with style, opacity, zIndex,
legendUrl, onlineOnly options
* TileWMS replaces ImageWMS (fixes 'Width exceeds 512' WMS errors)
* Legend panel — bottom-right, auto-shown for visible layers that
register a legendUrl
* Default base map setting in Settings, persisted in localStorage;
setBaseMap() on MapView
Offline tile cache (Phase 1 + 2)
* Service worker: per-host tile caches (osm / topo / satellite /
carto-light / carto-dark), counter-based eviction to prevent
iOS Safari memory-pressure reloads, GET_TILE_STATS /
CLEAR_TILE_CACHES message API
* pwa.js helpers: getActiveServiceWorker, onServiceWorkerControllerChange,
getTileCacheStats, clearTileCaches, getStorageEstimate
* Settings: Offline Map Tiles card with per-provider stats + clear
* Phase 2 download dialog: form to pick base map, area (current view /
district / Ghana), zoom range; live tile-count + size estimate;
progress bar with cancel; OfflineTileDownloader class with
concurrency + throttling
Local database management
* osm_roads table + saveOSMRoads / getLocalOSMRoads helpers
* CACHED_LAYER_TABLES allow-list with clearTable / clearAllCachedLayers
* Local Database Tables card: per-row Clear button (cached layers
only) + 'Refresh cached layers' header button with reload prompt
Build & infrastructure
* Shpjs lazy-loaded via dynamic import (saves ~140 kB from initial JS)
* chunkSizeWarningLimit raised to 900 kB (openlayers + sqlite3.wasm
can't be split further)
* Toast notification module (src/toast.js)
* Units module (src/units.js) for metric / imperial conversions
* PDF export module (src/pdf-export.js)
Documentation & SQL
* Topographic_Background_Layers_for_LUPMIS2.docx — research report
* OpenTopography_Workflow.svg/.png — ETL pipeline diagram
* LUPMIS2_Development_Status_Report.docx — April update section
* sql/create_landuse_parcels.sql — PostgreSQL schema for the LUSPA
land-use parcel specification (Feb 2026, revised), with PostGIS
geometry column and standard indices
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BIN
LUPMIS2_Development_Status_Report.docx
Normal file
BIN
OpenTopography_Workflow.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
181
OpenTopography_Workflow.svg
Normal file
@ -0,0 +1,181 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 900" font-family="Arial, Helvetica, sans-serif">
|
||||
<!-- ====================================================== -->
|
||||
<!-- OpenTopography ETL Workflow for LUPMIS2 -->
|
||||
<!-- A one-off data pipeline: download → process → serve -->
|
||||
<!-- ====================================================== -->
|
||||
|
||||
<defs>
|
||||
<!-- Arrowhead -->
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#4b5563"/>
|
||||
</marker>
|
||||
<!-- Drop shadow -->
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="1" dy="2"/>
|
||||
<feComponentTransfer><feFuncA type="linear" slope="0.18"/></feComponentTransfer>
|
||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="960" height="900" fill="#f9fafb"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="480" y="42" text-anchor="middle" font-size="22" font-weight="700" fill="#1e1a4b">
|
||||
OpenTopography → LUPMIS2 Topographic Workflow
|
||||
</text>
|
||||
<text x="480" y="66" text-anchor="middle" font-size="13" fill="#6b7280">
|
||||
One-off ETL pipeline: download DEM → generate products → serve to the PWA
|
||||
</text>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- STAGE 1 — SOURCE (blue) -->
|
||||
<!-- ====================================================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="330" y="100" width="300" height="80" rx="10" ry="10" fill="#0ea5e9" stroke="#0369a1" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="480" y="128" text-anchor="middle" font-size="15" font-weight="700" fill="#fff">OpenTopography API</text>
|
||||
<text x="480" y="148" text-anchor="middle" font-size="12" fill="#e0f2fe">SRTM 30m / Copernicus 30m DEM</text>
|
||||
<text x="480" y="166" text-anchor="middle" font-size="11" font-style="italic" fill="#bae6fd">API key required · 50 calls/24h (non-academic)</text>
|
||||
|
||||
<!-- Arrow 1 -->
|
||||
<path d="M480,180 L480,220" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
<text x="495" y="205" font-size="11" fill="#6b7280">one-off download · Ghana bbox ≈ 240,000 km²</text>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- STAGE 2 — RAW DATA (grey) -->
|
||||
<!-- ====================================================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="340" y="230" width="280" height="70" rx="10" ry="10" fill="#fff" stroke="#6b7280" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
</g>
|
||||
<text x="480" y="258" text-anchor="middle" font-size="14" font-weight="700" fill="#1f2937">DEM GeoTIFF</text>
|
||||
<text x="480" y="278" text-anchor="middle" font-size="12" fill="#4b5563">Ghana elevation raster (single file)</text>
|
||||
<text x="480" y="294" text-anchor="middle" font-size="10" font-style="italic" fill="#6b7280">EPSG:4326 · ≈ 1 – 3 GB</text>
|
||||
|
||||
<!-- Split arrows -->
|
||||
<path d="M480,300 L480,340 L230,340 L230,380" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
<path d="M480,340 L730,340 L730,380" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
<text x="420" y="332" text-anchor="end" font-size="11" fill="#6b7280">contours path</text>
|
||||
<text x="540" y="332" font-size="11" fill="#6b7280">hillshade path</text>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- STAGE 3 — PROCESSING (green) -->
|
||||
<!-- ====================================================== -->
|
||||
<!-- Left: gdal_contour -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="90" y="390" width="280" height="80" rx="10" ry="10" fill="#10b981" stroke="#047857" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="230" y="418" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">gdal_contour</text>
|
||||
<text x="230" y="438" text-anchor="middle" font-size="11" fill="#d1fae5">extract contour polylines at fixed intervals</text>
|
||||
<text x="230" y="456" text-anchor="middle" font-size="10" font-family="Menlo, monospace" fill="#ecfccb">-i 10 (10 m) or -i 20 (20 m)</text>
|
||||
|
||||
<!-- Right: gdaldem hillshade -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="590" y="390" width="280" height="80" rx="10" ry="10" fill="#10b981" stroke="#047857" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="730" y="418" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">gdaldem hillshade</text>
|
||||
<text x="730" y="438" text-anchor="middle" font-size="11" fill="#d1fae5">render shaded relief PNG</text>
|
||||
<text x="730" y="456" text-anchor="middle" font-size="10" font-family="Menlo, monospace" fill="#ecfccb">-z 2 -az 315 -alt 45</text>
|
||||
|
||||
<!-- Arrows 3 → 4 -->
|
||||
<path d="M230,470 L230,510" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
<path d="M730,470 L730,510" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- STAGE 4 — DERIVATIVES (orange) -->
|
||||
<!-- ====================================================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="90" y="520" width="280" height="70" rx="10" ry="10" fill="#fff" stroke="#f59e0b" stroke-width="2"/>
|
||||
</g>
|
||||
<text x="230" y="548" text-anchor="middle" font-size="14" font-weight="700" fill="#92400e">Contour polylines</text>
|
||||
<text x="230" y="568" text-anchor="middle" font-size="11" fill="#78350f">Shapefile / GeoPackage / GeoJSON</text>
|
||||
<text x="230" y="584" text-anchor="middle" font-size="10" font-style="italic" fill="#b45309">vector</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="590" y="520" width="280" height="70" rx="10" ry="10" fill="#fff" stroke="#f59e0b" stroke-width="2"/>
|
||||
</g>
|
||||
<text x="730" y="548" text-anchor="middle" font-size="14" font-weight="700" fill="#92400e">Hillshade raster</text>
|
||||
<text x="730" y="568" text-anchor="middle" font-size="11" fill="#78350f">GeoTIFF / PNG tile pyramid</text>
|
||||
<text x="730" y="584" text-anchor="middle" font-size="10" font-style="italic" fill="#b45309">raster</text>
|
||||
|
||||
<!-- Arrows 4 → 5 -->
|
||||
<path d="M230,590 L230,630" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
<path d="M730,590 L730,630" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- STAGE 5 — SERVE (purple) -->
|
||||
<!-- ====================================================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="40" y="640" width="180" height="80" rx="10" ry="10" fill="#8b5cf6" stroke="#5b21b6" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="130" y="668" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">GeoServer</text>
|
||||
<text x="130" y="688" text-anchor="middle" font-size="11" fill="#ede9fe">WMS endpoint</text>
|
||||
<text x="130" y="706" text-anchor="middle" font-size="10" font-style="italic" fill="#ddd6fe">on-demand rendering</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="240" y="640" width="180" height="80" rx="10" ry="10" fill="#8b5cf6" stroke="#5b21b6" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="330" y="668" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">MBTiles</text>
|
||||
<text x="330" y="688" text-anchor="middle" font-size="11" fill="#ede9fe">XYZ tile server</text>
|
||||
<text x="330" y="706" text-anchor="middle" font-size="10" font-style="italic" fill="#ddd6fe">pre-rendered, fast</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="640" y="640" width="180" height="80" rx="10" ry="10" fill="#8b5cf6" stroke="#5b21b6" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="730" y="668" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">Tile pyramid</text>
|
||||
<text x="730" y="688" text-anchor="middle" font-size="11" fill="#ede9fe">XYZ / WMTS</text>
|
||||
<text x="730" y="706" text-anchor="middle" font-size="10" font-style="italic" fill="#ddd6fe">gdal2tiles.py</text>
|
||||
|
||||
<!-- Split arrow from contour derivatives to BOTH GeoServer and MBTiles -->
|
||||
<path d="M180,610 L130,610 L130,640" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
<path d="M280,610 L330,610 L330,640" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
<path d="M230,590 L230,610" stroke="#4b5563" stroke-width="2" fill="none"/>
|
||||
<line x1="130" y1="610" x2="330" y2="610" stroke="#4b5563" stroke-width="2"/>
|
||||
|
||||
<!-- Label the "OR" on the left side -->
|
||||
<text x="230" y="627" text-anchor="middle" font-size="10" font-weight="700" fill="#6b7280">serve as WMS or XYZ</text>
|
||||
|
||||
<!-- Arrows 5 → 6 — converging to LUPMIS2 -->
|
||||
<path d="M130,720 L130,770 L460,770 L460,800" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
<path d="M330,720 L330,770" stroke="#4b5563" stroke-width="2" fill="none"/>
|
||||
<line x1="330" y1="770" x2="460" y2="770" stroke="#4b5563" stroke-width="2"/>
|
||||
<path d="M730,720 L730,770 L500,770 L500,800" stroke="#4b5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- STAGE 6 — CONSUMER (brand) -->
|
||||
<!-- ====================================================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="280" y="800" width="400" height="70" rx="10" ry="10" fill="#1e1a4b" stroke="#0f0c2a" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="480" y="828" text-anchor="middle" font-size="15" font-weight="700" fill="#fff">LUPMIS2 PWA</text>
|
||||
<text x="480" y="848" text-anchor="middle" font-size="11" fill="#c7d2fe">OpenLayers · addWMSLayer() / addXYZLayer()</text>
|
||||
<text x="480" y="863" text-anchor="middle" font-size="10" font-style="italic" fill="#a5b4fc">"Biophysical Environment" group</text>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- LEGEND -->
|
||||
<!-- ====================================================== -->
|
||||
<g transform="translate(20,100)">
|
||||
<rect width="140" height="180" rx="6" ry="6" fill="#fff" stroke="#d1d5db" stroke-width="1"/>
|
||||
<text x="70" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#1f2937">Legend</text>
|
||||
<rect x="10" y="32" width="20" height="14" rx="2" fill="#0ea5e9"/>
|
||||
<text x="36" y="43" font-size="10" fill="#374151">External source</text>
|
||||
<rect x="10" y="54" width="20" height="14" rx="2" fill="#fff" stroke="#6b7280" stroke-dasharray="3,2"/>
|
||||
<text x="36" y="65" font-size="10" fill="#374151">Raw data file</text>
|
||||
<rect x="10" y="76" width="20" height="14" rx="2" fill="#10b981"/>
|
||||
<text x="36" y="87" font-size="10" fill="#374151">Processing step</text>
|
||||
<rect x="10" y="98" width="20" height="14" rx="2" fill="#fff" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<text x="36" y="109" font-size="10" fill="#374151">Derived product</text>
|
||||
<rect x="10" y="120" width="20" height="14" rx="2" fill="#8b5cf6"/>
|
||||
<text x="36" y="131" font-size="10" fill="#374151">Serving layer</text>
|
||||
<rect x="10" y="142" width="20" height="14" rx="2" fill="#1e1a4b"/>
|
||||
<text x="36" y="153" font-size="10" fill="#374151">Consumer</text>
|
||||
<text x="70" y="172" text-anchor="middle" font-size="9" font-style="italic" fill="#6b7280">Run once · serve forever</text>
|
||||
</g>
|
||||
|
||||
<!-- Footer note -->
|
||||
<text x="480" y="888" text-anchor="middle" font-size="10" font-style="italic" fill="#6b7280">
|
||||
Prepared for LUSPA · April 2026 · One-off ETL job — no runtime OpenTopography API calls from the PWA
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
BIN
Topographic_Background_Layers_for_LUPMIS2.docx
Normal file
23
dist/assets/html2canvas.esm-B0tyYwQk.js
vendored
Normal file
1
dist/assets/html2canvas.esm-B0tyYwQk.js.map
vendored
Normal file
393
dist/assets/index-2WHoRhxp.js
vendored
1
dist/assets/index-2WHoRhxp.js.map
vendored
636
dist/assets/index-B4XzHtZX.js
vendored
Normal file
1
dist/assets/index-B4XzHtZX.js.map
vendored
Normal file
19
dist/assets/index.es-CRPDPo17.js
vendored
Normal file
1
dist/assets/index.es-CRPDPo17.js.map
vendored
Normal file
172
dist/assets/jspdf-Cu-2SCgw.js
vendored
Normal file
1
dist/assets/jspdf-Cu-2SCgw.js.map
vendored
Normal file
2
dist/assets/ol-ext-CSk2UikI.js
vendored
Normal file
1
dist/assets/ol-ext-CSk2UikI.js.map
vendored
Normal file
2
dist/assets/ol-ext-DytxBANR.js
vendored
1
dist/assets/ol-ext-DytxBANR.js.map
vendored
573
dist/assets/openlayers-CUDtI0S3.js
vendored
Normal file
1
dist/assets/openlayers-CUDtI0S3.js.map
vendored
Normal file
573
dist/assets/openlayers-D2I-bVN2.js
vendored
1
dist/assets/openlayers-D2I-bVN2.js.map
vendored
2
dist/assets/pdf-export-Vpiz8VA4.js
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
import{E as f,a as b}from"./jspdf-Cu-2SCgw.js";import"./openlayers-CUDtI0S3.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="./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"}),y=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${y}`,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 S=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:S,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 p=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,p);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-Vpiz8VA4.js.map
|
||||
1
dist/assets/pdf-export-Vpiz8VA4.js.map
vendored
Normal file
3
dist/assets/purify.es-BgtpMKW3.js
vendored
Normal file
1
dist/assets/purify.es-BgtpMKW3.js.map
vendored
Normal file
5
dist/assets/shpjs-CNrRgkgn.js
vendored
Normal file
1
dist/assets/shpjs-CNrRgkgn.js.map
vendored
Normal file
BIN
dist/fonts/bebas-neue-latin-ext.woff2
vendored
Normal file
BIN
dist/fonts/bebas-neue-latin.woff2
vendored
Normal file
BIN
dist/fonts/exo-latin-ext.woff2
vendored
Normal file
BIN
dist/fonts/exo-latin.woff2
vendored
Normal file
BIN
dist/fonts/exo-vietnamese.woff2
vendored
Normal file
1
dist/icons/README.txt
vendored
@ -1 +0,0 @@
|
||||
Place PWA icons here (icon-72.png, icon-96.png, icon-128.png, icon-144.png, icon-152.png, icon-192.png, icon-384.png, icon-512.png)
|
||||
BIN
dist/icons/luspa-128x128.png
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
dist/icons/luspa-144x144.png
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
dist/icons/luspa-152x152.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
dist/icons/luspa-384x384.png
vendored
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
dist/icons/luspa-72x72.png
vendored
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
dist/icons/luspa-96x96.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
dist/icons/luspa-pdf.jpg
vendored
Normal file
|
After Width: | Height: | Size: 47 KiB |
745
dist/index.html
vendored
@ -2,19 +2,64 @@
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#005eb8">
|
||||
<meta name="description" content="LUPMIS2 Drawing Tools">
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/icons/luspa.icon">
|
||||
<link rel="icon" href="/icons/luspa.icon">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.png">
|
||||
|
||||
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Exo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
|
||||
<style>
|
||||
/* Bebas Neue 400 — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Bebas Neue';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/bebas-neue-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* Bebas Neue 400 — latin */
|
||||
@font-face {
|
||||
font-family: 'Bebas Neue';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/bebas-neue-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* Exo 300-800 — vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Exo';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/exo-vietnamese.woff2') format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* Exo 300-800 — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Exo';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/exo-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* Exo 300-800 — latin */
|
||||
@font-face {
|
||||
font-family: 'Exo';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/exo-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>LUPMIS2 Drawing Tools</title>
|
||||
|
||||
@ -106,6 +151,295 @@
|
||||
--radius-2xl: 1rem;
|
||||
}
|
||||
|
||||
/* ─── Fieldwork Mode ─── high-contrast + larger touch targets ─── */
|
||||
.fieldwork-mode {
|
||||
--foreground: #000;
|
||||
--background: #fff;
|
||||
--card: #fff;
|
||||
--card-foreground: #000;
|
||||
--primary: #0044aa;
|
||||
--primary-foreground: #fff;
|
||||
--primary-hover: #003080;
|
||||
--muted: #e0e0e0;
|
||||
--muted-foreground: #333;
|
||||
--accent: #cce0ff;
|
||||
--accent-foreground: #000;
|
||||
--border: rgba(0,0,0,0.25);
|
||||
--success: #005a00;
|
||||
--success-foreground: #fff;
|
||||
--warning: #b36b00;
|
||||
--warning-foreground: #000;
|
||||
--destructive: #b80000;
|
||||
--destructive-foreground: #fff;
|
||||
--ring: #0044aa;
|
||||
--bs-body-color: #000;
|
||||
}
|
||||
|
||||
/* Fieldwork: larger dock buttons */
|
||||
.fieldwork-mode .dock-btn {
|
||||
min-width: 72px;
|
||||
min-height: 58px;
|
||||
font-size: 1.6rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
.fieldwork-mode .dock-btn-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Fieldwork: bolder navbar */
|
||||
.fieldwork-mode .navbar {
|
||||
border-bottom-width: 4px;
|
||||
}
|
||||
.fieldwork-mode .navbar .navbar-brand {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: larger offcanvas toggle buttons */
|
||||
.fieldwork-mode .offcanvas-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: thicker bottom dock border */
|
||||
.fieldwork-mode .bottom-dock {
|
||||
border-top-width: 4px;
|
||||
}
|
||||
|
||||
/* Fieldwork: larger text in cards / lists */
|
||||
.fieldwork-mode .card-header h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.fieldwork-mode .list-group-item {
|
||||
font-size: 0.95rem;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: larger buttons globally */
|
||||
.fieldwork-mode .btn {
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fieldwork-mode .btn-sm {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: stronger borders on inputs / form controls */
|
||||
.fieldwork-mode .form-control,
|
||||
.fieldwork-mode .form-select {
|
||||
border-width: 2px;
|
||||
border-color: #555;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: bolder map controls (ol-ext) */
|
||||
.fieldwork-mode .ol-control button {
|
||||
font-size: 1.3rem;
|
||||
width: 2.2em;
|
||||
height: 2.2em;
|
||||
}
|
||||
|
||||
/* Fieldwork: scale bar text legibility */
|
||||
.fieldwork-mode .ol-scale-bar .ol-scale-step-text,
|
||||
.fieldwork-mode .ol-scale-bar .ol-scale-text {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 4px #fff, 0 0 8px #fff;
|
||||
}
|
||||
|
||||
/* ─── Dark Mode ─── reversed colour scheme ─── */
|
||||
.dark-mode {
|
||||
--foreground: #e0dff0;
|
||||
--background: #131325;
|
||||
--card: #1e1e38;
|
||||
--card-foreground: #e0dff0;
|
||||
--primary: #4d9de6;
|
||||
--primary-foreground: #fff;
|
||||
--primary-hover: #6fb3f0;
|
||||
--muted: #272745;
|
||||
--muted-foreground: #9594a8;
|
||||
--accent: #1e3a5f;
|
||||
--accent-foreground: #e0dff0;
|
||||
--border: rgba(255,255,255,0.12);
|
||||
--ring: #4d9de6;
|
||||
--success: #2dd46a;
|
||||
--success-foreground: #131325;
|
||||
--warning: #ffb84d;
|
||||
--warning-foreground: #131325;
|
||||
--destructive: #f04040;
|
||||
--destructive-foreground: #fff;
|
||||
--bs-body-color: #e0dff0;
|
||||
--bs-body-bg: #131325;
|
||||
--bs-tertiary-bg: #1e1e38;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Dark: navbar */
|
||||
.dark-mode .navbar {
|
||||
background-color: #1a1a30 !important;
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Dark: bottom dock */
|
||||
.dark-mode .bottom-dock {
|
||||
background-color: #1a1a30;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
.dark-mode .dock-btn {
|
||||
border-color: var(--primary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.dark-mode .dock-btn:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
.dark-mode .dock-btn.active {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
/* Dark: offcanvas panels */
|
||||
.dark-mode .offcanvas {
|
||||
background-color: var(--background) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
.dark-mode .offcanvas-header {
|
||||
border-bottom-color: var(--border) !important;
|
||||
}
|
||||
.dark-mode .btn-close {
|
||||
filter: invert(1) grayscale(100%) brightness(200%);
|
||||
}
|
||||
|
||||
/* Dark: cards */
|
||||
.dark-mode .card {
|
||||
background-color: var(--card) !important;
|
||||
color: var(--card-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Dark: offcanvas toggle buttons */
|
||||
.dark-mode .offcanvas-toggle {
|
||||
background-color: var(--card);
|
||||
color: var(--foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.dark-mode .offcanvas-toggle:hover {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
/* Dark: form controls */
|
||||
.dark-mode .form-control,
|
||||
.dark-mode .form-select {
|
||||
background-color: var(--muted) !important;
|
||||
color: var(--foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.dark-mode .form-check-input {
|
||||
background-color: var(--muted);
|
||||
border-color: var(--muted-foreground);
|
||||
}
|
||||
.dark-mode .form-check-input:checked {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Dark: list groups */
|
||||
.dark-mode .list-group-item {
|
||||
background-color: var(--card) !important;
|
||||
color: var(--card-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Dark: buttons */
|
||||
.dark-mode .btn-outline-primary {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.dark-mode .btn-outline-danger {
|
||||
color: var(--destructive);
|
||||
border-color: var(--destructive);
|
||||
}
|
||||
|
||||
/* Dark: text utilities */
|
||||
.dark-mode .text-muted {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
/* Dark: measurement tooltips */
|
||||
.dark-mode .measure-tooltip {
|
||||
background: rgba(30, 30, 56, 0.95);
|
||||
color: var(--foreground);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.dark-mode .measure-tooltip::before {
|
||||
border-right-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Dark: OL controls */
|
||||
.dark-mode .ol-control button {
|
||||
background-color: var(--card) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
.dark-mode .ol-control button:hover {
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
}
|
||||
.dark-mode .ol-attribution,
|
||||
.dark-mode .ol-attribution a {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
/* Dark: scale bar */
|
||||
.dark-mode .ol-scale-bar .ol-scale-step-text,
|
||||
.dark-mode .ol-scale-bar .ol-scale-text {
|
||||
color: #fff !important;
|
||||
text-shadow: 0 0 4px #000, 0 0 8px #000 !important;
|
||||
}
|
||||
.dark-mode .ol-scale-bar .ol-scale-singlebar-even {
|
||||
background-color: #fff !important;
|
||||
}
|
||||
.dark-mode .ol-scale-bar .ol-scale-singlebar-odd {
|
||||
background-color: #999 !important;
|
||||
}
|
||||
|
||||
/* Dark: map drop overlay */
|
||||
.dark-mode .map-drop-overlay {
|
||||
background: rgba(19, 19, 37, 0.85);
|
||||
border-color: var(--primary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Dark: ol-ext LayerSwitcher */
|
||||
.dark-mode .ol-layerswitcher {
|
||||
background-color: var(--card) !important;
|
||||
}
|
||||
.dark-mode .ol-layerswitcher .panel {
|
||||
background-color: var(--card) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
.dark-mode .ol-layerswitcher .panel li {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.dark-mode .ol-layerswitcher .ol-switchertopdiv,
|
||||
.dark-mode .ol-layerswitcher .ol-switcherbottomdiv {
|
||||
background: var(--card) !important;
|
||||
}
|
||||
|
||||
/* Dark: alert boxes */
|
||||
.dark-mode .alert-danger {
|
||||
background-color: rgba(240, 64, 64, 0.15) !important;
|
||||
color: var(--destructive) !important;
|
||||
border-color: var(--destructive) !important;
|
||||
}
|
||||
.dark-mode .alert-success {
|
||||
background-color: rgba(45, 212, 106, 0.15) !important;
|
||||
color: var(--success) !important;
|
||||
border-color: var(--success) !important;
|
||||
}
|
||||
|
||||
/* Full height layout */
|
||||
html, body {
|
||||
height: 100%;
|
||||
@ -214,9 +548,12 @@
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* Main container - full height */
|
||||
/* Main container - full height.
|
||||
100dvh accounts for mobile browser chrome and OS nav bars.
|
||||
Falls back to 100vh for older browsers. */
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -244,6 +581,35 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Drag-and-drop overlay shown when files are dragged over the map */
|
||||
.map-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 94, 184, 0.15);
|
||||
border: 3px dashed var(--primary, #005eb8);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.map-container.drag-over .map-drop-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.map-drop-overlay span {
|
||||
font-family: var(--font-body, 'Exo', sans-serif);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary, #005eb8);
|
||||
background: var(--card, #fff);
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* Offline indicator */
|
||||
#offline-indicator {
|
||||
display: none;
|
||||
@ -301,9 +667,14 @@
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
/* Fix ol-ext LayerSwitcher z-index */
|
||||
.ol-layerswitcher {
|
||||
z-index: 100;
|
||||
/* OL controls stacking context fix — OpenLayers sets z-index:0 on
|
||||
.ol-overlaycontainer-stopevent, trapping all controls below the
|
||||
offcanvas-toggle buttons (z-index:500). Raising the container
|
||||
to 501 lets the LayerSwitcher dropdown render above the toggles.
|
||||
pointer-events:none on the container still lets clicks through
|
||||
to the toggle buttons underneath. */
|
||||
.ol-overlaycontainer-stopevent {
|
||||
z-index: 501 !important;
|
||||
}
|
||||
|
||||
/* Alert hint box */
|
||||
@ -419,7 +790,7 @@
|
||||
}
|
||||
|
||||
.offcanvas-toggle-bottom {
|
||||
bottom: 80px; /* Above the dock */
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); /* Above the dock */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
@ -432,7 +803,10 @@
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
/* Bottom Dock — white card style with blue-strong accent */
|
||||
/* Bottom Dock — white card style with blue-strong accent.
|
||||
env(safe-area-inset-bottom) adds padding on devices with a
|
||||
home indicator / gesture bar (e.g. iPhone notch models).
|
||||
The value is 0 on devices without an inset. */
|
||||
.bottom-dock {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@ -441,7 +815,7 @@
|
||||
z-index: 600;
|
||||
background-color: var(--card);
|
||||
border-top: 3px solid var(--primary);
|
||||
padding: 8px 16px;
|
||||
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
@ -503,6 +877,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Snap-guides toggle — highlighted when active */
|
||||
.ol-snap-toggle.ol-active button {
|
||||
background: var(--primary) !important;
|
||||
color: var(--primary-foreground, #fff) !important;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Touch-friendly improvements for forms and buttons */
|
||||
.form-control, .form-select {
|
||||
min-height: 44px;
|
||||
@ -541,6 +922,18 @@
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Message log in the right panel */
|
||||
.message-log {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-log-entry {
|
||||
font-size: 0.82rem;
|
||||
border-color: var(--border, #eee) !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ol-ext GeolocationButton styling */
|
||||
.ol-geobt {
|
||||
top: auto !important;
|
||||
@ -830,6 +1223,7 @@
|
||||
/* Locations list in offcanvas - can be taller now without form */
|
||||
.offcanvas-end .locations-list {
|
||||
max-height: calc(100vh - 280px);
|
||||
max-height: calc(100dvh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@ -921,17 +1315,32 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ScaleLine - position above the bottom dock */
|
||||
.ol-scale-line {
|
||||
bottom: 76px !important;
|
||||
/* ScaleBar - position above the bottom dock with 4px gap */
|
||||
.ol-scale-bar {
|
||||
bottom: calc(85px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
left: 10px !important;
|
||||
}
|
||||
|
||||
.ol-scale-line-inner {
|
||||
border-color: var(--foreground) !important;
|
||||
.ol-scale-bar .ol-scale-step-text {
|
||||
color: var(--foreground) !important;
|
||||
font-family: var(--font-body) !important;
|
||||
font-size: 11px !important;
|
||||
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
|
||||
}
|
||||
|
||||
.ol-scale-bar .ol-scale-text {
|
||||
color: var(--foreground) !important;
|
||||
font-family: var(--font-body) !important;
|
||||
font-size: 11px !important;
|
||||
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
|
||||
}
|
||||
|
||||
.ol-scale-bar .ol-scale-singlebar-even {
|
||||
background-color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.ol-scale-bar .ol-scale-singlebar-odd {
|
||||
background-color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
/* ol-ext Bar overrides */
|
||||
@ -950,12 +1359,12 @@
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-2WHoRhxp.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B4XzHtZX.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-CUDtI0S3.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-D2I-bVN2.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-DytxBANR.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-CSk2UikI.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/ol-ext-BgKrOIxx.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BnwqsTiD.css">
|
||||
</head>
|
||||
@ -988,6 +1397,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<div class="map-drop-overlay"><span><i class="bi bi-file-earmark-arrow-up me-2"></i>Drop file to import (.shp .geojson .kml)</span></div>
|
||||
|
||||
<!-- Offcanvas toggle buttons -->
|
||||
<button class="offcanvas-toggle offcanvas-toggle-left"
|
||||
@ -1112,17 +1522,40 @@
|
||||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="local-data-btn">
|
||||
<i class="bi bi-database me-2"></i>Local Data
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-shp-btn">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import .shp
|
||||
</button>
|
||||
<input type="file" id="shp-file-input" accept=".zip,.shp,.dbf,.shx,.prj" multiple class="d-none">
|
||||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-geojson-btn">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import GeoJSON
|
||||
</button>
|
||||
<input type="file" id="geojson-file-input" accept=".geojson,.json" class="d-none">
|
||||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-kml-btn">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import KML
|
||||
</button>
|
||||
<input type="file" id="kml-file-input" accept=".kml,.kmz" class="d-none">
|
||||
<div id="file-import-alert" class="alert alert-danger alert-dismissible fade show d-none mb-3" role="alert">
|
||||
<small class="message-text"></small>
|
||||
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="imported-layers-info" class="d-none mb-3"></div>
|
||||
<div id="local-data-stats" class="d-none">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary py-2">
|
||||
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="bi bi-database me-2"></i>Local Database Tables</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-light"
|
||||
id="clear-all-cached-btn"
|
||||
title="Delete all cached map layers. They will be re-downloaded on next app start.">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh cached layers
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-3">Table</th>
|
||||
<th class="text-end pe-3">Records</th>
|
||||
<th class="text-end">Records</th>
|
||||
<th class="text-end pe-3" style="width:3rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="local-data-tbody">
|
||||
@ -1150,6 +1583,10 @@
|
||||
<span class="message-text"></span>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="warning-message" class="alert alert-warning alert-dismissible fade show d-none" role="alert">
|
||||
<span class="message-text"></span>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<!-- Tip -->
|
||||
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
|
||||
@ -1189,20 +1626,268 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Log -->
|
||||
<div class="card mt-3" id="message-log-card">
|
||||
<div class="card-header bg-transparent py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0" style="font-family:var(--font-body);font-weight:700;"><i class="bi bi-journal-text me-1"></i> Messages</h6>
|
||||
<button class="btn btn-sm btn-link text-muted p-0" id="clear-message-log" title="Clear messages">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="message-log" class="message-log list-group list-group-flush">
|
||||
<div class="text-center text-muted py-3">
|
||||
<small>No messages yet.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Offcanvas -->
|
||||
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-chevron-down me-2"></i>Bottom Panel</h5>
|
||||
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-gear me-2"></i>Settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<p>This is the bottom offcanvas panel.</p>
|
||||
<p>You can add a data table, charts, or other wide content here.</p>
|
||||
<div class="row g-3">
|
||||
<!-- Fieldwork Mode -->
|
||||
<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>
|
||||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Fieldwork Mode</h6>
|
||||
<small class="text-muted">High-contrast colours and larger touch targets for bright sunlight and field conditions.</small>
|
||||
</div>
|
||||
<div class="form-check form-switch ms-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="fieldwork-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Dark Mode -->
|
||||
<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>
|
||||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Dark Mode</h6>
|
||||
<small class="text-muted">Reduce glare and save battery with a dark colour scheme.</small>
|
||||
</div>
|
||||
<div class="form-check form-switch ms-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="dark-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Measurement System -->
|
||||
<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>
|
||||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Measurement System</h6>
|
||||
<small class="text-muted">Switch between Metric (m, km) and Imperial (ft, mi, acres) units.</small>
|
||||
</div>
|
||||
<div class="form-check form-switch ms-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="measurement-system-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||||
<label class="form-check-label ms-1" id="measurement-system-label" for="measurement-system-toggle" style="font-size:0.8rem;font-weight:600;min-width:55px;">Metric</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Default Base Map -->
|
||||
<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;">Default Base Map</h6>
|
||||
<small class="text-muted">Base map shown on app start. Saved on this device.</small>
|
||||
</div>
|
||||
<div class="ms-3" style="min-width:140px;">
|
||||
<select class="form-select form-select-sm" id="default-basemap-select" aria-label="Default base map">
|
||||
<option value="topo">Topographic</option>
|
||||
<option value="osm">OpenStreetMap</option>
|
||||
<option value="satellite">Satellite</option>
|
||||
<option value="googlesat">Google Sat</option>
|
||||
<option value="carto-light">Carto Light</option>
|
||||
<option value="carto-dark">Carto Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Offline Map Tiles -->
|
||||
<div class="col-12 col-md-6 col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">
|
||||
<i class="bi bi-map me-1"></i>Offline Map Tiles
|
||||
</h6>
|
||||
<small class="text-muted">
|
||||
Map tiles you've already viewed are cached on this device so they work offline.
|
||||
Tiles are cached automatically as you browse, or you can pre-download a region.
|
||||
</small>
|
||||
</div>
|
||||
<div class="ms-3 d-flex gap-2 flex-shrink-0">
|
||||
<button type="button" class="btn btn-sm btn-primary"
|
||||
id="download-tiles-btn" style="white-space:nowrap;">
|
||||
<i class="bi bi-cloud-download me-1"></i>Download offline map
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
id="clear-tiles-btn" style="white-space:nowrap;">
|
||||
<i class="bi bi-trash3 me-1"></i>Clear cached tiles
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tile-cache-stats" class="small">
|
||||
<div class="text-muted fst-italic">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Offline Map modal -->
|
||||
<div class="modal fade" id="offline-download-modal" tabindex="-1" aria-labelledby="offline-download-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="offline-download-title">
|
||||
<i class="bi bi-cloud-download me-2"></i>Download Offline Map
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="offline-download-close-btn"></button>
|
||||
</div>
|
||||
|
||||
<!-- Form view (shown until Start is clicked) -->
|
||||
<div class="modal-body" id="offline-download-form-view">
|
||||
<p class="text-muted small mb-3">
|
||||
Pre-fetch map tiles so they're available when you're offline.
|
||||
Only the OpenStreetMap and Topographic base maps can be downloaded;
|
||||
other providers don't permit bulk caching.
|
||||
</p>
|
||||
|
||||
<!-- Base map -->
|
||||
<div class="mb-3">
|
||||
<label for="offline-basemap-select" class="form-label fw-bold">Base map</label>
|
||||
<select class="form-select form-select-sm" id="offline-basemap-select">
|
||||
<option value="topo">Topographic</option>
|
||||
<option value="osm">OpenStreetMap</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Area -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold mb-1">Area to download</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-view" value="view" checked>
|
||||
<label class="form-check-label" for="offline-area-view">
|
||||
Current map view
|
||||
<span class="text-muted small" id="offline-area-view-info"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-district" value="district">
|
||||
<label class="form-check-label" for="offline-area-district">
|
||||
District boundary
|
||||
<span class="text-muted small" id="offline-area-district-info"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-ghana" value="ghana">
|
||||
<label class="form-check-label" for="offline-area-ghana">
|
||||
Entire Ghana <span class="text-muted small">(very large — only attempt over fast Wi-Fi)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zoom range -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold mb-1">Zoom levels</label>
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-auto"><label for="offline-min-zoom" class="form-label small mb-0">Min</label></div>
|
||||
<div class="col-auto">
|
||||
<input type="number" class="form-control form-control-sm" id="offline-min-zoom" min="6" max="19" value="10" style="width:5em;">
|
||||
</div>
|
||||
<div class="col-auto"><label for="offline-max-zoom" class="form-label small mb-0">Max</label></div>
|
||||
<div class="col-auto">
|
||||
<input type="number" class="form-control form-control-sm" id="offline-max-zoom" min="6" max="19" value="15" style="width:5em;">
|
||||
</div>
|
||||
<div class="col text-muted small">10 = regional · 13 = neighbourhood · 16 = building</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estimate -->
|
||||
<div class="alert alert-info py-2 px-3 mb-3" id="offline-estimate" style="font-size:0.9em;">
|
||||
<strong>Estimated download:</strong>
|
||||
<span id="offline-estimate-detail">Calculating…</span>
|
||||
</div>
|
||||
|
||||
<!-- Acknowledgement -->
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="offline-ack-check">
|
||||
<label class="form-check-label small" for="offline-ack-check">
|
||||
I understand this counts against the tile provider's usage quota
|
||||
and will use mobile data if I'm not on Wi-Fi.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress view (shown during download) -->
|
||||
<div class="modal-body d-none" id="offline-download-progress-view">
|
||||
<div class="text-center mb-3">
|
||||
<div class="fs-5 fw-bold" id="offline-progress-percent">0%</div>
|
||||
<div class="small text-muted" id="offline-progress-counts">0 of 0 tiles</div>
|
||||
</div>
|
||||
<div class="progress mb-3" style="height:1.25em;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar" id="offline-progress-bar"
|
||||
aria-valuemin="0" aria-valuemax="100" style="width:0%;"></div>
|
||||
</div>
|
||||
<div class="row text-center small text-muted">
|
||||
<div class="col"><div class="fw-bold text-body" id="offline-progress-ok">0</div>fetched</div>
|
||||
<div class="col"><div class="fw-bold text-body" id="offline-progress-failed">0</div>failed</div>
|
||||
<div class="col"><div class="fw-bold text-body" id="offline-progress-eta">—</div>remaining</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Done view (shown after completion) -->
|
||||
<div class="modal-body d-none" id="offline-download-done-view">
|
||||
<div class="text-center py-3">
|
||||
<i class="bi bi-check-circle-fill text-success" style="font-size:3rem;"></i>
|
||||
<div class="fs-5 fw-bold mt-2" id="offline-done-title">Download complete</div>
|
||||
<div class="text-muted" id="offline-done-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="offline-download-cancel-btn">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="offline-download-start-btn" disabled>
|
||||
<i class="bi bi-cloud-download me-1"></i>Start download
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary d-none" id="offline-download-close-done-btn" data-bs-dismiss="modal">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Polyfill crypto.randomUUID for non-secure contexts (HTTP) -->
|
||||
<!-- Must run before module imports (SQLocal/coincident require it) -->
|
||||
|
||||
22
dist/manifest.json
vendored
@ -1,58 +1,58 @@
|
||||
{
|
||||
"name": "LUPMIS2 Drawing Tools",
|
||||
"short_name": "LUPMIS",
|
||||
"short_name": "LUPMIS2",
|
||||
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#005eb8",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-72.png",
|
||||
"src": "./icons/luspa-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96.png",
|
||||
"src": "./icons/luspa-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128.png",
|
||||
"src": "./icons/luspa-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144.png",
|
||||
"src": "./icons/luspa-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152.png",
|
||||
"src": "./icons/luspa-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/luspa-192x192.png",
|
||||
"src": "./icons/luspa-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384.png",
|
||||
"src": "./icons/luspa-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/luspa-512x512.png",
|
||||
"src": "./icons/luspa-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
|
||||
367
dist/sw.js
vendored
@ -1,29 +1,81 @@
|
||||
/**
|
||||
* Service Worker
|
||||
*
|
||||
*
|
||||
* Handles caching of:
|
||||
* - App shell (HTML, CSS, JS)
|
||||
* - Map tiles (runtime caching)
|
||||
* - Map tiles (passive runtime caching, per-host buckets)
|
||||
* - API responses (network-first)
|
||||
*
|
||||
*
|
||||
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
|
||||
* NOT by this service worker. They serve different purposes:
|
||||
* - Service Worker: Caching, offline asset serving, push notifications
|
||||
* - SharedWorker: Shared database connection across tabs
|
||||
*/
|
||||
|
||||
const CACHE_VERSION = 'v1';
|
||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||
const TILES_CACHE = `tiles-${CACHE_VERSION}`;
|
||||
// v3: lower per-cache limits (5000 → 1500) and counter-based eviction to
|
||||
// prevent Safari memory-pressure reloads.
|
||||
// v4: raise OSM and Topographic limits to 8000 to support active offline
|
||||
// downloads (Phase 2). Other providers stay at 1500.
|
||||
const CACHE_VERSION = 'v4';
|
||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||
|
||||
// Maximum number of tiles to cache
|
||||
const MAX_TILES = 500;
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tile caches — one per provider so users can clear them independently.
|
||||
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
|
||||
// which covers a Ghana district at zoom 10–15 (typical field-work range).
|
||||
// ----------------------------------------------------------------------------
|
||||
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
|
||||
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
|
||||
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
|
||||
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
|
||||
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
|
||||
|
||||
// App shell assets - precached on install
|
||||
// Vite will generate hashed filenames, so we cache the entry points
|
||||
// and let the browser handle the hashed assets
|
||||
// Per-provider tile limits.
|
||||
// • OSM and Topographic are the providers offered for active offline
|
||||
// download (Phase 2 dialog), so they get a higher cap (~240 MB each at
|
||||
// ~30 KB/tile) — enough for a typical Ghana district at zoom 10–15.
|
||||
// • The other providers serve passive caching only (whatever the user has
|
||||
// already viewed), so 1 500 tiles ≈ 45 MB is plenty.
|
||||
//
|
||||
// Total max ≈ 5 × ~150 MB = ~750 MB on disk in the worst case, but only the
|
||||
// two downloadable buckets are likely to fill. Eviction sweeps run every 100
|
||||
// inserts (see EVICTION_CHECK_INTERVAL) so memory pressure stays bounded.
|
||||
const TILE_LIMITS = {
|
||||
[TILES_OSM]: 8000,
|
||||
[TILES_TOPO]: 8000,
|
||||
[TILES_SATELLITE]: 1500,
|
||||
[TILES_CARTO_LIGHT]: 1500,
|
||||
[TILES_CARTO_DARK]: 1500,
|
||||
};
|
||||
|
||||
// Per-cache running insert counter, in memory. Avoids calling cache.keys()
|
||||
// (which materialises every Request object in the cache) on every put — that
|
||||
// was the cause of the Safari "reloaded due to memory pressure" failures.
|
||||
//
|
||||
// We only run a real eviction sweep every EVICTION_CHECK_INTERVAL inserts.
|
||||
const _tileInsertCounters = new Map(); // cacheName → number of inserts since last eviction
|
||||
const EVICTION_CHECK_INTERVAL = 100;
|
||||
|
||||
// Friendly name shown in the UI (matches Settings card labels)
|
||||
const TILE_CACHE_LABELS = {
|
||||
[TILES_OSM]: 'OpenStreetMap',
|
||||
[TILES_TOPO]: 'Topographic',
|
||||
[TILES_SATELLITE]: 'Satellite',
|
||||
[TILES_CARTO_LIGHT]: 'Carto Light',
|
||||
[TILES_CARTO_DARK]: 'Carto Dark',
|
||||
};
|
||||
|
||||
const ALL_TILE_CACHES = Object.keys(TILE_LIMITS);
|
||||
|
||||
// Approximate average tile size — used for storage estimation.
|
||||
// Real measurements: PNG tiles range 5–80 KB; 30 KB is a good middle ground.
|
||||
const AVG_TILE_BYTES = 30 * 1024;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App shell assets — precached on install.
|
||||
// ----------------------------------------------------------------------------
|
||||
const SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
@ -37,7 +89,7 @@ const SHELL_ASSETS = [
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing...');
|
||||
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE)
|
||||
.then((cache) => {
|
||||
@ -54,18 +106,26 @@ self.addEventListener('install', (event) => {
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating...');
|
||||
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
// Build the set of caches that should remain
|
||||
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
|
||||
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
// Delete anything that:
|
||||
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
|
||||
// • but is NOT in the current keep set
|
||||
// This includes the legacy "tiles-v1" single bucket.
|
||||
.filter((name) => {
|
||||
// Delete old version caches
|
||||
return (name.startsWith('shell-') && name !== SHELL_CACHE) ||
|
||||
(name.startsWith('tiles-') && name !== TILES_CACHE) ||
|
||||
(name.startsWith('modules-') && name !== MODULES_CACHE) ||
|
||||
(name.startsWith('api-') && name !== API_CACHE);
|
||||
const isOurs =
|
||||
name.startsWith('shell-') ||
|
||||
name.startsWith('tiles-') ||
|
||||
name.startsWith('modules-') ||
|
||||
name.startsWith('api-');
|
||||
return isOurs && !keep.has(name);
|
||||
})
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
@ -84,17 +144,28 @@ self.addEventListener('activate', (event) => {
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
|
||||
|
||||
// Only handle GET requests
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
|
||||
// Skip chrome-extension and other non-http(s) requests
|
||||
if (!url.protocol.startsWith('http')) return;
|
||||
|
||||
// Route to appropriate caching strategy
|
||||
if (isMapTile(url)) {
|
||||
event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES));
|
||||
} else if (isApiRequest(url)) {
|
||||
|
||||
// Skip worker files and Vite dev-server node_modules requests —
|
||||
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
|
||||
if (url.pathname.includes('node_modules') ||
|
||||
url.search.includes('worker_file') ||
|
||||
request.destination === 'worker') return;
|
||||
|
||||
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
|
||||
const tileCache = getTileCacheName(url);
|
||||
if (tileCache) {
|
||||
event.respondWith(tileCacheThenNetwork(request, tileCache));
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- OTHER ROUTES (unchanged) -----
|
||||
if (isApiRequest(url)) {
|
||||
event.respondWith(networkFirst(request, API_CACHE));
|
||||
} else if (isModuleAsset(url)) {
|
||||
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
||||
@ -108,15 +179,36 @@ self.addEventListener('fetch', (event) => {
|
||||
// URL CLASSIFICATION
|
||||
// ============================================================================
|
||||
|
||||
function isMapTile(url) {
|
||||
// Common tile server patterns for all our base maps
|
||||
return url.hostname.includes('tile.openstreetmap.org') ||
|
||||
url.hostname.includes('opentopomap.org') ||
|
||||
url.hostname.includes('arcgisonline.com') ||
|
||||
url.hostname.includes('basemaps.cartocdn.com') ||
|
||||
url.hostname.includes('tiles.') ||
|
||||
url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) ||
|
||||
url.pathname.match(/\/tile\/\d+\/\d+\/\d+/);
|
||||
/**
|
||||
* Classify a URL into the appropriate tile cache.
|
||||
* Returns `null` for non-tile requests, or for tile providers we deliberately
|
||||
* do NOT cache (e.g. Google — caching is forbidden by their ToS).
|
||||
*/
|
||||
function getTileCacheName(url) {
|
||||
const host = url.hostname;
|
||||
|
||||
// OpenStreetMap — tile.openstreetmap.org and a/b/c subdomains
|
||||
if (host.endsWith('tile.openstreetmap.org')) return TILES_OSM;
|
||||
|
||||
// OpenTopoMap — a/b/c.tile.opentopomap.org
|
||||
if (host.endsWith('tile.opentopomap.org') || host.endsWith('opentopomap.org')) return TILES_TOPO;
|
||||
|
||||
// Carto Basemaps — light_all / dark_all distinguished by path
|
||||
if (host.endsWith('basemaps.cartocdn.com')) {
|
||||
if (url.pathname.includes('/light_all/')) return TILES_CARTO_LIGHT;
|
||||
if (url.pathname.includes('/dark_all/')) return TILES_CARTO_DARK;
|
||||
return null; // unknown Carto style
|
||||
}
|
||||
|
||||
// Esri — server.arcgisonline.com
|
||||
if (host.endsWith('arcgisonline.com')) return TILES_SATELLITE;
|
||||
|
||||
// Google — caching forbidden by ToS, do not store
|
||||
if (host.endsWith('google.com') || host.endsWith('googleapis.com')) return null;
|
||||
|
||||
// Other tile providers (WMS endpoints, OWS, custom) — not cached at this layer
|
||||
// (the user's "online only" toast handles those).
|
||||
return null;
|
||||
}
|
||||
|
||||
function isApiRequest(url) {
|
||||
@ -129,7 +221,6 @@ function isModuleAsset(url) {
|
||||
}
|
||||
|
||||
function isAppAsset(url) {
|
||||
// Same origin, common asset extensions
|
||||
return url.origin === self.location.origin &&
|
||||
(url.pathname.endsWith('.html') ||
|
||||
url.pathname.endsWith('.css') ||
|
||||
@ -144,13 +235,13 @@ function isAppAsset(url) {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cache First - Use cache, fallback to network
|
||||
* Best for: App shell, static assets
|
||||
* Cache First — Use cache, fallback to network.
|
||||
* Best for: App shell, static assets.
|
||||
*/
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
@ -159,7 +250,6 @@ async function cacheFirst(request, cacheName) {
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Return offline page for navigation requests
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match('/offline.html');
|
||||
}
|
||||
@ -168,8 +258,8 @@ async function cacheFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Network First - Try network, fallback to cache
|
||||
* Best for: API requests, dynamic content
|
||||
* Network First — Try network, fallback to cache.
|
||||
* Best for: API requests, dynamic content.
|
||||
*/
|
||||
async function networkFirst(request, cacheName) {
|
||||
try {
|
||||
@ -187,89 +277,141 @@ async function networkFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale While Revalidate - Return cache immediately, update in background
|
||||
* Best for: Module assets, frequently updated content
|
||||
* Stale While Revalidate — Return cache immediately, update in background.
|
||||
* Best for: Module assets, frequently updated content.
|
||||
*/
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
|
||||
const fetchPromise = fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}).catch(() => cached);
|
||||
|
||||
|
||||
return cached || fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache Then Network with limit - Cache tiles with size limit
|
||||
* Best for: Map tiles
|
||||
* Tile Cache then Network — Per-host bucket with size limit.
|
||||
* Cache first; on miss, fetch from network and store.
|
||||
*
|
||||
* Memory-conservative eviction:
|
||||
* • Increments an in-memory counter on every successful insert
|
||||
* • Only calls cache.keys() (which materialises all Request objects) every
|
||||
* EVICTION_CHECK_INTERVAL inserts — so the cost is amortised
|
||||
* • Eviction drops the oldest 10 % when over the per-host limit
|
||||
*
|
||||
* On network failure (offline), serves a 408 so the map renders a blank tile
|
||||
* rather than throwing.
|
||||
*/
|
||||
async function cacheThenNetwork(request, cacheName, maxItems) {
|
||||
async function tileCacheThenNetwork(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
// Check cache size and trim if needed
|
||||
const keys = await cache.keys();
|
||||
if (keys.length >= maxItems) {
|
||||
// Remove oldest entries (first 10%)
|
||||
const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1));
|
||||
await Promise.all(toDelete.map(key => cache.delete(key)));
|
||||
// Bump the counter; periodically run a real eviction sweep
|
||||
const count = (_tileInsertCounters.get(cacheName) || 0) + 1;
|
||||
_tileInsertCounters.set(cacheName, count);
|
||||
|
||||
if (count % EVICTION_CHECK_INTERVAL === 0) {
|
||||
// Reset the counter — next sweep is another EVICTION_CHECK_INTERVAL away
|
||||
_tileInsertCounters.set(cacheName, 0);
|
||||
await maybeEvict(cache, cacheName);
|
||||
}
|
||||
|
||||
cache.put(request, response.clone());
|
||||
|
||||
// Don't await put() — it can run after we return the response, keeping
|
||||
// the fetch hot path lightweight.
|
||||
cache.put(request, response.clone()).catch((err) => {
|
||||
// QuotaExceededError → run an immediate eviction sweep and retry once
|
||||
if (err && err.name === 'QuotaExceededError') {
|
||||
maybeEvict(cache, cacheName, /* force */ true).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// For tiles, just fail silently - map will show blank tile
|
||||
// Offline — let the map renderer show a blank tile
|
||||
return new Response('', { status: 408, statusText: 'Offline' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an eviction sweep on a cache, dropping the oldest 10 % of entries
|
||||
* when over the per-cache limit. Heavy: only call periodically.
|
||||
*/
|
||||
async function maybeEvict(cache, cacheName, force = false) {
|
||||
try {
|
||||
const limit = TILE_LIMITS[cacheName] || 1500;
|
||||
const keys = await cache.keys();
|
||||
if (force || keys.length >= limit) {
|
||||
const drop = Math.max(1, Math.ceil(limit * 0.1));
|
||||
const toDelete = keys.slice(0, drop);
|
||||
await Promise.all(toDelete.map((k) => cache.delete(k)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[SW] eviction sweep failed for', cacheName, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MESSAGE HANDLING
|
||||
// ============================================================================
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const { type, payload } = event.data || {};
|
||||
|
||||
|
||||
switch (type) {
|
||||
case 'SKIP_WAITING':
|
||||
self.skipWaiting();
|
||||
break;
|
||||
|
||||
|
||||
case 'CACHE_MODULES':
|
||||
cacheModules(payload.modules);
|
||||
break;
|
||||
|
||||
|
||||
case 'CLEAR_USER_CACHE':
|
||||
clearUserCaches();
|
||||
break;
|
||||
|
||||
|
||||
case 'GET_CACHE_STATUS':
|
||||
getCacheStatus().then(status => {
|
||||
getCacheStatus().then((status) => {
|
||||
event.source.postMessage({ type: 'CACHE_STATUS', status });
|
||||
});
|
||||
break;
|
||||
|
||||
// ----- Tile-cache management (Phase 1 offline maps) -----
|
||||
case 'GET_TILE_STATS':
|
||||
getTileStats().then((stats) => {
|
||||
event.source.postMessage({ type: 'TILE_STATS', stats });
|
||||
});
|
||||
break;
|
||||
|
||||
case 'CLEAR_TILE_CACHES':
|
||||
clearTileCaches().then(() => {
|
||||
event.source.postMessage({ type: 'TILE_CACHES_CLEARED' });
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cache specific modules on demand
|
||||
* Cache specific modules on demand.
|
||||
*/
|
||||
async function cacheModules(moduleNames) {
|
||||
const cache = await caches.open(MODULES_CACHE);
|
||||
|
||||
|
||||
for (const moduleName of moduleNames) {
|
||||
try {
|
||||
const moduleAssets = [
|
||||
@ -277,9 +419,8 @@ async function cacheModules(moduleNames) {
|
||||
`/modules/${moduleName}/index.css`,
|
||||
`/modules/${moduleName}/index.html`
|
||||
];
|
||||
|
||||
|
||||
await cache.addAll(moduleAssets.filter(async (url) => {
|
||||
// Only cache assets that exist
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
return response.ok;
|
||||
@ -287,7 +428,7 @@ async function cacheModules(moduleNames) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
console.log('[SW] Cached module:', moduleName);
|
||||
} catch (error) {
|
||||
console.warn('[SW] Failed to cache module:', moduleName, error);
|
||||
@ -296,7 +437,8 @@ async function cacheModules(moduleNames) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user-specific caches (call on logout)
|
||||
* Clear user-specific caches (call on logout).
|
||||
* Tile caches are NOT cleared here — those belong to the device, not the user.
|
||||
*/
|
||||
async function clearUserCaches() {
|
||||
await caches.delete(API_CACHE);
|
||||
@ -305,17 +447,90 @@ async function clearUserCaches() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status information
|
||||
* Get summary status of all caches (count of entries in each).
|
||||
*/
|
||||
async function getCacheStatus() {
|
||||
const cacheNames = await caches.keys();
|
||||
const status = {};
|
||||
|
||||
|
||||
for (const name of cacheNames) {
|
||||
const cache = await caches.open(name);
|
||||
const keys = await cache.keys();
|
||||
status[name] = keys.length;
|
||||
}
|
||||
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-provider tile cache statistics.
|
||||
*
|
||||
* Returns shape:
|
||||
* {
|
||||
* totals: { count, estBytes },
|
||||
* byProvider: [{ key, label, count, limit, estBytes }, ...]
|
||||
* }
|
||||
*
|
||||
* estBytes is an approximation (count × AVG_TILE_BYTES). For an exact size,
|
||||
* the caller can use navigator.storage.estimate() on the page side.
|
||||
*
|
||||
* Result is cached for STATS_TTL_MS so rapid re-queries (e.g. multiple
|
||||
* Settings opens) don't re-enumerate every cache.
|
||||
*/
|
||||
const STATS_TTL_MS = 10 * 1000;
|
||||
let _cachedStats = null;
|
||||
let _cachedStatsAt = 0;
|
||||
|
||||
async function getTileStats({ force = false } = {}) {
|
||||
const now = Date.now();
|
||||
if (!force && _cachedStats && (now - _cachedStatsAt) < STATS_TTL_MS) {
|
||||
return _cachedStats;
|
||||
}
|
||||
|
||||
const byProvider = [];
|
||||
let totalCount = 0;
|
||||
|
||||
for (const cacheName of ALL_TILE_CACHES) {
|
||||
let count = 0;
|
||||
if (await caches.has(cacheName)) {
|
||||
const cache = await caches.open(cacheName);
|
||||
// matchAll returns a smaller payload than keys() on Safari, but neither
|
||||
// is free. Done at most once per STATS_TTL_MS thanks to the cache above.
|
||||
const keys = await cache.keys();
|
||||
count = keys.length;
|
||||
}
|
||||
byProvider.push({
|
||||
key: cacheName,
|
||||
label: TILE_CACHE_LABELS[cacheName] || cacheName,
|
||||
count,
|
||||
limit: TILE_LIMITS[cacheName] || 0,
|
||||
estBytes: count * AVG_TILE_BYTES,
|
||||
});
|
||||
totalCount += count;
|
||||
}
|
||||
|
||||
_cachedStats = {
|
||||
totals: {
|
||||
count: totalCount,
|
||||
estBytes: totalCount * AVG_TILE_BYTES,
|
||||
},
|
||||
byProvider,
|
||||
};
|
||||
_cachedStatsAt = now;
|
||||
return _cachedStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every tile cache. Frees the device storage used by cached map tiles.
|
||||
* Does not affect app-shell, modules, or API caches.
|
||||
*/
|
||||
async function clearTileCaches() {
|
||||
const results = await Promise.all(
|
||||
ALL_TILE_CACHES.map((name) => caches.delete(name))
|
||||
);
|
||||
// Reset counters and invalidate stats cache
|
||||
_tileInsertCounters.clear();
|
||||
_cachedStats = null;
|
||||
_cachedStatsAt = 0;
|
||||
console.log('[SW] Cleared tile caches:', ALL_TILE_CACHES.filter((_, i) => results[i]));
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
737
index.html
@ -2,19 +2,64 @@
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#005eb8">
|
||||
<meta name="description" content="LUPMIS2 Drawing Tools">
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/icons/luspa.icon">
|
||||
<link rel="icon" href="/icons/luspa.icon">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.png">
|
||||
|
||||
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Exo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
|
||||
<style>
|
||||
/* Bebas Neue 400 — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Bebas Neue';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/bebas-neue-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* Bebas Neue 400 — latin */
|
||||
@font-face {
|
||||
font-family: 'Bebas Neue';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/bebas-neue-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* Exo 300-800 — vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Exo';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/exo-vietnamese.woff2') format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* Exo 300-800 — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Exo';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/exo-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* Exo 300-800 — latin */
|
||||
@font-face {
|
||||
font-family: 'Exo';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/exo-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>LUPMIS2 Drawing Tools</title>
|
||||
|
||||
@ -106,6 +151,295 @@
|
||||
--radius-2xl: 1rem;
|
||||
}
|
||||
|
||||
/* ─── Fieldwork Mode ─── high-contrast + larger touch targets ─── */
|
||||
.fieldwork-mode {
|
||||
--foreground: #000;
|
||||
--background: #fff;
|
||||
--card: #fff;
|
||||
--card-foreground: #000;
|
||||
--primary: #0044aa;
|
||||
--primary-foreground: #fff;
|
||||
--primary-hover: #003080;
|
||||
--muted: #e0e0e0;
|
||||
--muted-foreground: #333;
|
||||
--accent: #cce0ff;
|
||||
--accent-foreground: #000;
|
||||
--border: rgba(0,0,0,0.25);
|
||||
--success: #005a00;
|
||||
--success-foreground: #fff;
|
||||
--warning: #b36b00;
|
||||
--warning-foreground: #000;
|
||||
--destructive: #b80000;
|
||||
--destructive-foreground: #fff;
|
||||
--ring: #0044aa;
|
||||
--bs-body-color: #000;
|
||||
}
|
||||
|
||||
/* Fieldwork: larger dock buttons */
|
||||
.fieldwork-mode .dock-btn {
|
||||
min-width: 72px;
|
||||
min-height: 58px;
|
||||
font-size: 1.6rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
.fieldwork-mode .dock-btn-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Fieldwork: bolder navbar */
|
||||
.fieldwork-mode .navbar {
|
||||
border-bottom-width: 4px;
|
||||
}
|
||||
.fieldwork-mode .navbar .navbar-brand {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: larger offcanvas toggle buttons */
|
||||
.fieldwork-mode .offcanvas-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: thicker bottom dock border */
|
||||
.fieldwork-mode .bottom-dock {
|
||||
border-top-width: 4px;
|
||||
}
|
||||
|
||||
/* Fieldwork: larger text in cards / lists */
|
||||
.fieldwork-mode .card-header h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.fieldwork-mode .list-group-item {
|
||||
font-size: 0.95rem;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: larger buttons globally */
|
||||
.fieldwork-mode .btn {
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fieldwork-mode .btn-sm {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: stronger borders on inputs / form controls */
|
||||
.fieldwork-mode .form-control,
|
||||
.fieldwork-mode .form-select {
|
||||
border-width: 2px;
|
||||
border-color: #555;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Fieldwork: bolder map controls (ol-ext) */
|
||||
.fieldwork-mode .ol-control button {
|
||||
font-size: 1.3rem;
|
||||
width: 2.2em;
|
||||
height: 2.2em;
|
||||
}
|
||||
|
||||
/* Fieldwork: scale bar text legibility */
|
||||
.fieldwork-mode .ol-scale-bar .ol-scale-step-text,
|
||||
.fieldwork-mode .ol-scale-bar .ol-scale-text {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 4px #fff, 0 0 8px #fff;
|
||||
}
|
||||
|
||||
/* ─── Dark Mode ─── reversed colour scheme ─── */
|
||||
.dark-mode {
|
||||
--foreground: #e0dff0;
|
||||
--background: #131325;
|
||||
--card: #1e1e38;
|
||||
--card-foreground: #e0dff0;
|
||||
--primary: #4d9de6;
|
||||
--primary-foreground: #fff;
|
||||
--primary-hover: #6fb3f0;
|
||||
--muted: #272745;
|
||||
--muted-foreground: #9594a8;
|
||||
--accent: #1e3a5f;
|
||||
--accent-foreground: #e0dff0;
|
||||
--border: rgba(255,255,255,0.12);
|
||||
--ring: #4d9de6;
|
||||
--success: #2dd46a;
|
||||
--success-foreground: #131325;
|
||||
--warning: #ffb84d;
|
||||
--warning-foreground: #131325;
|
||||
--destructive: #f04040;
|
||||
--destructive-foreground: #fff;
|
||||
--bs-body-color: #e0dff0;
|
||||
--bs-body-bg: #131325;
|
||||
--bs-tertiary-bg: #1e1e38;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Dark: navbar */
|
||||
.dark-mode .navbar {
|
||||
background-color: #1a1a30 !important;
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Dark: bottom dock */
|
||||
.dark-mode .bottom-dock {
|
||||
background-color: #1a1a30;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
.dark-mode .dock-btn {
|
||||
border-color: var(--primary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.dark-mode .dock-btn:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
.dark-mode .dock-btn.active {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
/* Dark: offcanvas panels */
|
||||
.dark-mode .offcanvas {
|
||||
background-color: var(--background) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
.dark-mode .offcanvas-header {
|
||||
border-bottom-color: var(--border) !important;
|
||||
}
|
||||
.dark-mode .btn-close {
|
||||
filter: invert(1) grayscale(100%) brightness(200%);
|
||||
}
|
||||
|
||||
/* Dark: cards */
|
||||
.dark-mode .card {
|
||||
background-color: var(--card) !important;
|
||||
color: var(--card-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Dark: offcanvas toggle buttons */
|
||||
.dark-mode .offcanvas-toggle {
|
||||
background-color: var(--card);
|
||||
color: var(--foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.dark-mode .offcanvas-toggle:hover {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
/* Dark: form controls */
|
||||
.dark-mode .form-control,
|
||||
.dark-mode .form-select {
|
||||
background-color: var(--muted) !important;
|
||||
color: var(--foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.dark-mode .form-check-input {
|
||||
background-color: var(--muted);
|
||||
border-color: var(--muted-foreground);
|
||||
}
|
||||
.dark-mode .form-check-input:checked {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Dark: list groups */
|
||||
.dark-mode .list-group-item {
|
||||
background-color: var(--card) !important;
|
||||
color: var(--card-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Dark: buttons */
|
||||
.dark-mode .btn-outline-primary {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.dark-mode .btn-outline-danger {
|
||||
color: var(--destructive);
|
||||
border-color: var(--destructive);
|
||||
}
|
||||
|
||||
/* Dark: text utilities */
|
||||
.dark-mode .text-muted {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
/* Dark: measurement tooltips */
|
||||
.dark-mode .measure-tooltip {
|
||||
background: rgba(30, 30, 56, 0.95);
|
||||
color: var(--foreground);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.dark-mode .measure-tooltip::before {
|
||||
border-right-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Dark: OL controls */
|
||||
.dark-mode .ol-control button {
|
||||
background-color: var(--card) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
.dark-mode .ol-control button:hover {
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
}
|
||||
.dark-mode .ol-attribution,
|
||||
.dark-mode .ol-attribution a {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
/* Dark: scale bar */
|
||||
.dark-mode .ol-scale-bar .ol-scale-step-text,
|
||||
.dark-mode .ol-scale-bar .ol-scale-text {
|
||||
color: #fff !important;
|
||||
text-shadow: 0 0 4px #000, 0 0 8px #000 !important;
|
||||
}
|
||||
.dark-mode .ol-scale-bar .ol-scale-singlebar-even {
|
||||
background-color: #fff !important;
|
||||
}
|
||||
.dark-mode .ol-scale-bar .ol-scale-singlebar-odd {
|
||||
background-color: #999 !important;
|
||||
}
|
||||
|
||||
/* Dark: map drop overlay */
|
||||
.dark-mode .map-drop-overlay {
|
||||
background: rgba(19, 19, 37, 0.85);
|
||||
border-color: var(--primary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Dark: ol-ext LayerSwitcher */
|
||||
.dark-mode .ol-layerswitcher {
|
||||
background-color: var(--card) !important;
|
||||
}
|
||||
.dark-mode .ol-layerswitcher .panel {
|
||||
background-color: var(--card) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
.dark-mode .ol-layerswitcher .panel li {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.dark-mode .ol-layerswitcher .ol-switchertopdiv,
|
||||
.dark-mode .ol-layerswitcher .ol-switcherbottomdiv {
|
||||
background: var(--card) !important;
|
||||
}
|
||||
|
||||
/* Dark: alert boxes */
|
||||
.dark-mode .alert-danger {
|
||||
background-color: rgba(240, 64, 64, 0.15) !important;
|
||||
color: var(--destructive) !important;
|
||||
border-color: var(--destructive) !important;
|
||||
}
|
||||
.dark-mode .alert-success {
|
||||
background-color: rgba(45, 212, 106, 0.15) !important;
|
||||
color: var(--success) !important;
|
||||
border-color: var(--success) !important;
|
||||
}
|
||||
|
||||
/* Full height layout */
|
||||
html, body {
|
||||
height: 100%;
|
||||
@ -214,9 +548,12 @@
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* Main container - full height */
|
||||
/* Main container - full height.
|
||||
100dvh accounts for mobile browser chrome and OS nav bars.
|
||||
Falls back to 100vh for older browsers. */
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -244,6 +581,35 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Drag-and-drop overlay shown when files are dragged over the map */
|
||||
.map-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 94, 184, 0.15);
|
||||
border: 3px dashed var(--primary, #005eb8);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.map-container.drag-over .map-drop-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.map-drop-overlay span {
|
||||
font-family: var(--font-body, 'Exo', sans-serif);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary, #005eb8);
|
||||
background: var(--card, #fff);
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* Offline indicator */
|
||||
#offline-indicator {
|
||||
display: none;
|
||||
@ -301,9 +667,14 @@
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
/* Fix ol-ext LayerSwitcher z-index */
|
||||
.ol-layerswitcher {
|
||||
z-index: 100;
|
||||
/* OL controls stacking context fix — OpenLayers sets z-index:0 on
|
||||
.ol-overlaycontainer-stopevent, trapping all controls below the
|
||||
offcanvas-toggle buttons (z-index:500). Raising the container
|
||||
to 501 lets the LayerSwitcher dropdown render above the toggles.
|
||||
pointer-events:none on the container still lets clicks through
|
||||
to the toggle buttons underneath. */
|
||||
.ol-overlaycontainer-stopevent {
|
||||
z-index: 501 !important;
|
||||
}
|
||||
|
||||
/* Alert hint box */
|
||||
@ -419,7 +790,7 @@
|
||||
}
|
||||
|
||||
.offcanvas-toggle-bottom {
|
||||
bottom: 80px; /* Above the dock */
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); /* Above the dock */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
@ -432,7 +803,10 @@
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
/* Bottom Dock — white card style with blue-strong accent */
|
||||
/* Bottom Dock — white card style with blue-strong accent.
|
||||
env(safe-area-inset-bottom) adds padding on devices with a
|
||||
home indicator / gesture bar (e.g. iPhone notch models).
|
||||
The value is 0 on devices without an inset. */
|
||||
.bottom-dock {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@ -441,7 +815,7 @@
|
||||
z-index: 600;
|
||||
background-color: var(--card);
|
||||
border-top: 3px solid var(--primary);
|
||||
padding: 8px 16px;
|
||||
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
@ -503,6 +877,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Snap-guides toggle — highlighted when active */
|
||||
.ol-snap-toggle.ol-active button {
|
||||
background: var(--primary) !important;
|
||||
color: var(--primary-foreground, #fff) !important;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Touch-friendly improvements for forms and buttons */
|
||||
.form-control, .form-select {
|
||||
min-height: 44px;
|
||||
@ -541,6 +922,18 @@
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Message log in the right panel */
|
||||
.message-log {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-log-entry {
|
||||
font-size: 0.82rem;
|
||||
border-color: var(--border, #eee) !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ol-ext GeolocationButton styling */
|
||||
.ol-geobt {
|
||||
top: auto !important;
|
||||
@ -830,6 +1223,7 @@
|
||||
/* Locations list in offcanvas - can be taller now without form */
|
||||
.offcanvas-end .locations-list {
|
||||
max-height: calc(100vh - 280px);
|
||||
max-height: calc(100dvh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@ -921,17 +1315,32 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ScaleLine - position above the bottom dock */
|
||||
.ol-scale-line {
|
||||
bottom: 76px !important;
|
||||
/* ScaleBar - position above the bottom dock with 4px gap */
|
||||
.ol-scale-bar {
|
||||
bottom: calc(85px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
left: 10px !important;
|
||||
}
|
||||
|
||||
.ol-scale-line-inner {
|
||||
border-color: var(--foreground) !important;
|
||||
.ol-scale-bar .ol-scale-step-text {
|
||||
color: var(--foreground) !important;
|
||||
font-family: var(--font-body) !important;
|
||||
font-size: 11px !important;
|
||||
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
|
||||
}
|
||||
|
||||
.ol-scale-bar .ol-scale-text {
|
||||
color: var(--foreground) !important;
|
||||
font-family: var(--font-body) !important;
|
||||
font-size: 11px !important;
|
||||
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
|
||||
}
|
||||
|
||||
.ol-scale-bar .ol-scale-singlebar-even {
|
||||
background-color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.ol-scale-bar .ol-scale-singlebar-odd {
|
||||
background-color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
/* ol-ext Bar overrides */
|
||||
@ -980,6 +1389,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<div class="map-drop-overlay"><span><i class="bi bi-file-earmark-arrow-up me-2"></i>Drop file to import (.shp .geojson .kml)</span></div>
|
||||
|
||||
<!-- Offcanvas toggle buttons -->
|
||||
<button class="offcanvas-toggle offcanvas-toggle-left"
|
||||
@ -1104,17 +1514,40 @@
|
||||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="local-data-btn">
|
||||
<i class="bi bi-database me-2"></i>Local Data
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-shp-btn">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import .shp
|
||||
</button>
|
||||
<input type="file" id="shp-file-input" accept=".zip,.shp,.dbf,.shx,.prj" multiple class="d-none">
|
||||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-geojson-btn">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import GeoJSON
|
||||
</button>
|
||||
<input type="file" id="geojson-file-input" accept=".geojson,.json" class="d-none">
|
||||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-kml-btn">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import KML
|
||||
</button>
|
||||
<input type="file" id="kml-file-input" accept=".kml,.kmz" class="d-none">
|
||||
<div id="file-import-alert" class="alert alert-danger alert-dismissible fade show d-none mb-3" role="alert">
|
||||
<small class="message-text"></small>
|
||||
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="imported-layers-info" class="d-none mb-3"></div>
|
||||
<div id="local-data-stats" class="d-none">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary py-2">
|
||||
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0"><i class="bi bi-database me-2"></i>Local Database Tables</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-light"
|
||||
id="clear-all-cached-btn"
|
||||
title="Delete all cached map layers. They will be re-downloaded on next app start.">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh cached layers
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-3">Table</th>
|
||||
<th class="text-end pe-3">Records</th>
|
||||
<th class="text-end">Records</th>
|
||||
<th class="text-end pe-3" style="width:3rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="local-data-tbody">
|
||||
@ -1142,6 +1575,10 @@
|
||||
<span class="message-text"></span>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="warning-message" class="alert alert-warning alert-dismissible fade show d-none" role="alert">
|
||||
<span class="message-text"></span>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<!-- Tip -->
|
||||
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
|
||||
@ -1181,20 +1618,268 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Log -->
|
||||
<div class="card mt-3" id="message-log-card">
|
||||
<div class="card-header bg-transparent py-2 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0" style="font-family:var(--font-body);font-weight:700;"><i class="bi bi-journal-text me-1"></i> Messages</h6>
|
||||
<button class="btn btn-sm btn-link text-muted p-0" id="clear-message-log" title="Clear messages">
|
||||
<i class="bi bi-trash3"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="message-log" class="message-log list-group list-group-flush">
|
||||
<div class="text-center text-muted py-3">
|
||||
<small>No messages yet.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Offcanvas -->
|
||||
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-chevron-down me-2"></i>Bottom Panel</h5>
|
||||
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-gear me-2"></i>Settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<p>This is the bottom offcanvas panel.</p>
|
||||
<p>You can add a data table, charts, or other wide content here.</p>
|
||||
<div class="row g-3">
|
||||
<!-- Fieldwork Mode -->
|
||||
<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>
|
||||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Fieldwork Mode</h6>
|
||||
<small class="text-muted">High-contrast colours and larger touch targets for bright sunlight and field conditions.</small>
|
||||
</div>
|
||||
<div class="form-check form-switch ms-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="fieldwork-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Dark Mode -->
|
||||
<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>
|
||||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Dark Mode</h6>
|
||||
<small class="text-muted">Reduce glare and save battery with a dark colour scheme.</small>
|
||||
</div>
|
||||
<div class="form-check form-switch ms-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="dark-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Measurement System -->
|
||||
<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>
|
||||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Measurement System</h6>
|
||||
<small class="text-muted">Switch between Metric (m, km) and Imperial (ft, mi, acres) units.</small>
|
||||
</div>
|
||||
<div class="form-check form-switch ms-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="measurement-system-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||||
<label class="form-check-label ms-1" id="measurement-system-label" for="measurement-system-toggle" style="font-size:0.8rem;font-weight:600;min-width:55px;">Metric</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Default Base Map -->
|
||||
<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;">Default Base Map</h6>
|
||||
<small class="text-muted">Base map shown on app start. Saved on this device.</small>
|
||||
</div>
|
||||
<div class="ms-3" style="min-width:140px;">
|
||||
<select class="form-select form-select-sm" id="default-basemap-select" aria-label="Default base map">
|
||||
<option value="topo">Topographic</option>
|
||||
<option value="osm">OpenStreetMap</option>
|
||||
<option value="satellite">Satellite</option>
|
||||
<option value="googlesat">Google Sat</option>
|
||||
<option value="carto-light">Carto Light</option>
|
||||
<option value="carto-dark">Carto Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Offline Map Tiles -->
|
||||
<div class="col-12 col-md-6 col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">
|
||||
<i class="bi bi-map me-1"></i>Offline Map Tiles
|
||||
</h6>
|
||||
<small class="text-muted">
|
||||
Map tiles you've already viewed are cached on this device so they work offline.
|
||||
Tiles are cached automatically as you browse, or you can pre-download a region.
|
||||
</small>
|
||||
</div>
|
||||
<div class="ms-3 d-flex gap-2 flex-shrink-0">
|
||||
<button type="button" class="btn btn-sm btn-primary"
|
||||
id="download-tiles-btn" style="white-space:nowrap;">
|
||||
<i class="bi bi-cloud-download me-1"></i>Download offline map
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
id="clear-tiles-btn" style="white-space:nowrap;">
|
||||
<i class="bi bi-trash3 me-1"></i>Clear cached tiles
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tile-cache-stats" class="small">
|
||||
<div class="text-muted fst-italic">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Offline Map modal -->
|
||||
<div class="modal fade" id="offline-download-modal" tabindex="-1" aria-labelledby="offline-download-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="offline-download-title">
|
||||
<i class="bi bi-cloud-download me-2"></i>Download Offline Map
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="offline-download-close-btn"></button>
|
||||
</div>
|
||||
|
||||
<!-- Form view (shown until Start is clicked) -->
|
||||
<div class="modal-body" id="offline-download-form-view">
|
||||
<p class="text-muted small mb-3">
|
||||
Pre-fetch map tiles so they're available when you're offline.
|
||||
Only the OpenStreetMap and Topographic base maps can be downloaded;
|
||||
other providers don't permit bulk caching.
|
||||
</p>
|
||||
|
||||
<!-- Base map -->
|
||||
<div class="mb-3">
|
||||
<label for="offline-basemap-select" class="form-label fw-bold">Base map</label>
|
||||
<select class="form-select form-select-sm" id="offline-basemap-select">
|
||||
<option value="topo">Topographic</option>
|
||||
<option value="osm">OpenStreetMap</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Area -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold mb-1">Area to download</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-view" value="view" checked>
|
||||
<label class="form-check-label" for="offline-area-view">
|
||||
Current map view
|
||||
<span class="text-muted small" id="offline-area-view-info"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-district" value="district">
|
||||
<label class="form-check-label" for="offline-area-district">
|
||||
District boundary
|
||||
<span class="text-muted small" id="offline-area-district-info"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-ghana" value="ghana">
|
||||
<label class="form-check-label" for="offline-area-ghana">
|
||||
Entire Ghana <span class="text-muted small">(very large — only attempt over fast Wi-Fi)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zoom range -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold mb-1">Zoom levels</label>
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-auto"><label for="offline-min-zoom" class="form-label small mb-0">Min</label></div>
|
||||
<div class="col-auto">
|
||||
<input type="number" class="form-control form-control-sm" id="offline-min-zoom" min="6" max="19" value="10" style="width:5em;">
|
||||
</div>
|
||||
<div class="col-auto"><label for="offline-max-zoom" class="form-label small mb-0">Max</label></div>
|
||||
<div class="col-auto">
|
||||
<input type="number" class="form-control form-control-sm" id="offline-max-zoom" min="6" max="19" value="15" style="width:5em;">
|
||||
</div>
|
||||
<div class="col text-muted small">10 = regional · 13 = neighbourhood · 16 = building</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estimate -->
|
||||
<div class="alert alert-info py-2 px-3 mb-3" id="offline-estimate" style="font-size:0.9em;">
|
||||
<strong>Estimated download:</strong>
|
||||
<span id="offline-estimate-detail">Calculating…</span>
|
||||
</div>
|
||||
|
||||
<!-- Acknowledgement -->
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="offline-ack-check">
|
||||
<label class="form-check-label small" for="offline-ack-check">
|
||||
I understand this counts against the tile provider's usage quota
|
||||
and will use mobile data if I'm not on Wi-Fi.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress view (shown during download) -->
|
||||
<div class="modal-body d-none" id="offline-download-progress-view">
|
||||
<div class="text-center mb-3">
|
||||
<div class="fs-5 fw-bold" id="offline-progress-percent">0%</div>
|
||||
<div class="small text-muted" id="offline-progress-counts">0 of 0 tiles</div>
|
||||
</div>
|
||||
<div class="progress mb-3" style="height:1.25em;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar" id="offline-progress-bar"
|
||||
aria-valuemin="0" aria-valuemax="100" style="width:0%;"></div>
|
||||
</div>
|
||||
<div class="row text-center small text-muted">
|
||||
<div class="col"><div class="fw-bold text-body" id="offline-progress-ok">0</div>fetched</div>
|
||||
<div class="col"><div class="fw-bold text-body" id="offline-progress-failed">0</div>failed</div>
|
||||
<div class="col"><div class="fw-bold text-body" id="offline-progress-eta">—</div>remaining</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Done view (shown after completion) -->
|
||||
<div class="modal-body d-none" id="offline-download-done-view">
|
||||
<div class="text-center py-3">
|
||||
<i class="bi bi-check-circle-fill text-success" style="font-size:3rem;"></i>
|
||||
<div class="fs-5 fw-bold mt-2" id="offline-done-title">Download complete</div>
|
||||
<div class="text-muted" id="offline-done-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="offline-download-cancel-btn">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="offline-download-start-btn" disabled>
|
||||
<i class="bi bi-cloud-download me-1"></i>Start download
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary d-none" id="offline-download-close-done-btn" data-bs-dismiss="modal">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Polyfill crypto.randomUUID for non-secure contexts (HTTP) -->
|
||||
<!-- Must run before module imports (SQLocal/coincident require it) -->
|
||||
|
||||
283
package-lock.json
generated
@ -1,18 +1,21 @@
|
||||
{
|
||||
"name": "lupmis-pwa",
|
||||
"name": "lupmis2-pwa",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lupmis-pwa",
|
||||
"name": "lupmis2-pwa",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"ol": "^10.3.0",
|
||||
"ol-ext": "^4.0.24",
|
||||
"shpjs": "^6.2.0",
|
||||
"sqlocal": "^0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -22,6 +25,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@ -795,12 +807,32 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/rbush": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||
@ -813,6 +845,16 @@
|
||||
"integrity": "sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||
@ -848,6 +890,32 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/but-unzip": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/but-unzip/-/but-unzip-0.1.10.tgz",
|
||||
"integrity": "sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/coincident": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz",
|
||||
@ -863,6 +931,38 @@
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
@ -911,6 +1011,17 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@ -929,6 +1040,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -968,12 +1085,64 @@
|
||||
"node": ">=10.19"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf-autotable": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
|
||||
"integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"jspdf": "^2 || ^3 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/mgrs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
|
||||
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@ -1031,6 +1200,12 @@
|
||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parsedbf": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parsedbf/-/parsedbf-2.0.0.tgz",
|
||||
"integrity": "sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
@ -1043,6 +1218,13 @@
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -1092,6 +1274,19 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proj4": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.3.tgz",
|
||||
"integrity": "sha512-uKJXnf/RkHhExxnWHqQqy2J1bPc5Qo8XSGzrMSJTdPWUQDo1DkunIRBfAS0crQaP9bZCSKNjqYJdYWVov0hDXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mgrs": "1.0.0",
|
||||
"wkt-parser": "^1.5.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ahocevar"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
@ -1122,6 +1317,16 @@
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||
@ -1131,6 +1336,13 @@
|
||||
"quickselect": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
@ -1140,6 +1352,16 @@
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.55.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz",
|
||||
@ -1185,6 +1407,17 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/shpjs": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz",
|
||||
"integrity": "sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"but-unzip": "^0.1.4",
|
||||
"parsedbf": "^2.0.0",
|
||||
"proj4": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -1237,6 +1470,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@ -1254,6 +1517,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
@ -1335,6 +1608,12 @@
|
||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/wkt-parser": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.3.tgz",
|
||||
"integrity": "sha512-myla+RrMj+WTlnHc8Y4HEwjBcBF9dqJ3vjff/zmlrn9V3OKOM1mZVIyNjlPEmOM9Jjr/PPut0tnaTs9NyHcK8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
|
||||
@ -9,11 +9,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"ol": "^10.3.0",
|
||||
"ol-ext": "^4.0.24",
|
||||
"shpjs": "^6.2.0",
|
||||
"sqlocal": "^0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
public/fonts/bebas-neue-latin-ext.woff2
Normal file
BIN
public/fonts/bebas-neue-latin.woff2
Normal file
BIN
public/fonts/exo-latin-ext.woff2
Normal file
BIN
public/fonts/exo-latin.woff2
Normal file
BIN
public/fonts/exo-vietnamese.woff2
Normal file
@ -1 +0,0 @@
|
||||
Place PWA icons here (icon-72.png, icon-96.png, icon-128.png, icon-144.png, icon-152.png, icon-192.png, icon-384.png, icon-512.png)
|
||||
BIN
public/icons/luspa-128x128.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/icons/luspa-144x144.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/icons/luspa-152x152.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/icons/luspa-384x384.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/icons/luspa-72x72.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/icons/luspa-96x96.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/icons/luspa-pdf.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 217 KiB |
@ -1,49 +0,0 @@
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"display-p3:0.10199,0.05884,0.32544,1.00000",
|
||||
"display-p3:0.27051,0.49023,0.74121,1.00000"
|
||||
],
|
||||
"orientation" : {
|
||||
"start" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0
|
||||
},
|
||||
"stop" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0.7
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "luspalogo.png",
|
||||
"name" : "luspalogo",
|
||||
"position" : {
|
||||
"scale" : 1.8,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@ -1,58 +1,58 @@
|
||||
{
|
||||
"name": "LUPMIS2 Drawing Tools",
|
||||
"short_name": "LUPMIS",
|
||||
"short_name": "LUPMIS2",
|
||||
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#005eb8",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-72.png",
|
||||
"src": "./icons/luspa-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96.png",
|
||||
"src": "./icons/luspa-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128.png",
|
||||
"src": "./icons/luspa-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144.png",
|
||||
"src": "./icons/luspa-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152.png",
|
||||
"src": "./icons/luspa-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/luspa-192x192.png",
|
||||
"src": "./icons/luspa-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384.png",
|
||||
"src": "./icons/luspa-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/luspa-512x512.png",
|
||||
"src": "./icons/luspa-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
|
||||
367
public/sw.js
@ -1,29 +1,81 @@
|
||||
/**
|
||||
* Service Worker
|
||||
*
|
||||
*
|
||||
* Handles caching of:
|
||||
* - App shell (HTML, CSS, JS)
|
||||
* - Map tiles (runtime caching)
|
||||
* - Map tiles (passive runtime caching, per-host buckets)
|
||||
* - API responses (network-first)
|
||||
*
|
||||
*
|
||||
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
|
||||
* NOT by this service worker. They serve different purposes:
|
||||
* - Service Worker: Caching, offline asset serving, push notifications
|
||||
* - SharedWorker: Shared database connection across tabs
|
||||
*/
|
||||
|
||||
const CACHE_VERSION = 'v1';
|
||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||
const TILES_CACHE = `tiles-${CACHE_VERSION}`;
|
||||
// v3: lower per-cache limits (5000 → 1500) and counter-based eviction to
|
||||
// prevent Safari memory-pressure reloads.
|
||||
// v4: raise OSM and Topographic limits to 8000 to support active offline
|
||||
// downloads (Phase 2). Other providers stay at 1500.
|
||||
const CACHE_VERSION = 'v4';
|
||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||
|
||||
// Maximum number of tiles to cache
|
||||
const MAX_TILES = 500;
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tile caches — one per provider so users can clear them independently.
|
||||
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
|
||||
// which covers a Ghana district at zoom 10–15 (typical field-work range).
|
||||
// ----------------------------------------------------------------------------
|
||||
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
|
||||
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
|
||||
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
|
||||
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
|
||||
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
|
||||
|
||||
// App shell assets - precached on install
|
||||
// Vite will generate hashed filenames, so we cache the entry points
|
||||
// and let the browser handle the hashed assets
|
||||
// Per-provider tile limits.
|
||||
// • OSM and Topographic are the providers offered for active offline
|
||||
// download (Phase 2 dialog), so they get a higher cap (~240 MB each at
|
||||
// ~30 KB/tile) — enough for a typical Ghana district at zoom 10–15.
|
||||
// • The other providers serve passive caching only (whatever the user has
|
||||
// already viewed), so 1 500 tiles ≈ 45 MB is plenty.
|
||||
//
|
||||
// Total max ≈ 5 × ~150 MB = ~750 MB on disk in the worst case, but only the
|
||||
// two downloadable buckets are likely to fill. Eviction sweeps run every 100
|
||||
// inserts (see EVICTION_CHECK_INTERVAL) so memory pressure stays bounded.
|
||||
const TILE_LIMITS = {
|
||||
[TILES_OSM]: 8000,
|
||||
[TILES_TOPO]: 8000,
|
||||
[TILES_SATELLITE]: 1500,
|
||||
[TILES_CARTO_LIGHT]: 1500,
|
||||
[TILES_CARTO_DARK]: 1500,
|
||||
};
|
||||
|
||||
// Per-cache running insert counter, in memory. Avoids calling cache.keys()
|
||||
// (which materialises every Request object in the cache) on every put — that
|
||||
// was the cause of the Safari "reloaded due to memory pressure" failures.
|
||||
//
|
||||
// We only run a real eviction sweep every EVICTION_CHECK_INTERVAL inserts.
|
||||
const _tileInsertCounters = new Map(); // cacheName → number of inserts since last eviction
|
||||
const EVICTION_CHECK_INTERVAL = 100;
|
||||
|
||||
// Friendly name shown in the UI (matches Settings card labels)
|
||||
const TILE_CACHE_LABELS = {
|
||||
[TILES_OSM]: 'OpenStreetMap',
|
||||
[TILES_TOPO]: 'Topographic',
|
||||
[TILES_SATELLITE]: 'Satellite',
|
||||
[TILES_CARTO_LIGHT]: 'Carto Light',
|
||||
[TILES_CARTO_DARK]: 'Carto Dark',
|
||||
};
|
||||
|
||||
const ALL_TILE_CACHES = Object.keys(TILE_LIMITS);
|
||||
|
||||
// Approximate average tile size — used for storage estimation.
|
||||
// Real measurements: PNG tiles range 5–80 KB; 30 KB is a good middle ground.
|
||||
const AVG_TILE_BYTES = 30 * 1024;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App shell assets — precached on install.
|
||||
// ----------------------------------------------------------------------------
|
||||
const SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
@ -37,7 +89,7 @@ const SHELL_ASSETS = [
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing...');
|
||||
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE)
|
||||
.then((cache) => {
|
||||
@ -54,18 +106,26 @@ self.addEventListener('install', (event) => {
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating...');
|
||||
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
// Build the set of caches that should remain
|
||||
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
|
||||
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
// Delete anything that:
|
||||
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
|
||||
// • but is NOT in the current keep set
|
||||
// This includes the legacy "tiles-v1" single bucket.
|
||||
.filter((name) => {
|
||||
// Delete old version caches
|
||||
return (name.startsWith('shell-') && name !== SHELL_CACHE) ||
|
||||
(name.startsWith('tiles-') && name !== TILES_CACHE) ||
|
||||
(name.startsWith('modules-') && name !== MODULES_CACHE) ||
|
||||
(name.startsWith('api-') && name !== API_CACHE);
|
||||
const isOurs =
|
||||
name.startsWith('shell-') ||
|
||||
name.startsWith('tiles-') ||
|
||||
name.startsWith('modules-') ||
|
||||
name.startsWith('api-');
|
||||
return isOurs && !keep.has(name);
|
||||
})
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
@ -84,17 +144,28 @@ self.addEventListener('activate', (event) => {
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
|
||||
|
||||
// Only handle GET requests
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
|
||||
// Skip chrome-extension and other non-http(s) requests
|
||||
if (!url.protocol.startsWith('http')) return;
|
||||
|
||||
// Route to appropriate caching strategy
|
||||
if (isMapTile(url)) {
|
||||
event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES));
|
||||
} else if (isApiRequest(url)) {
|
||||
|
||||
// Skip worker files and Vite dev-server node_modules requests —
|
||||
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
|
||||
if (url.pathname.includes('node_modules') ||
|
||||
url.search.includes('worker_file') ||
|
||||
request.destination === 'worker') return;
|
||||
|
||||
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
|
||||
const tileCache = getTileCacheName(url);
|
||||
if (tileCache) {
|
||||
event.respondWith(tileCacheThenNetwork(request, tileCache));
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- OTHER ROUTES (unchanged) -----
|
||||
if (isApiRequest(url)) {
|
||||
event.respondWith(networkFirst(request, API_CACHE));
|
||||
} else if (isModuleAsset(url)) {
|
||||
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
||||
@ -108,15 +179,36 @@ self.addEventListener('fetch', (event) => {
|
||||
// URL CLASSIFICATION
|
||||
// ============================================================================
|
||||
|
||||
function isMapTile(url) {
|
||||
// Common tile server patterns for all our base maps
|
||||
return url.hostname.includes('tile.openstreetmap.org') ||
|
||||
url.hostname.includes('opentopomap.org') ||
|
||||
url.hostname.includes('arcgisonline.com') ||
|
||||
url.hostname.includes('basemaps.cartocdn.com') ||
|
||||
url.hostname.includes('tiles.') ||
|
||||
url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) ||
|
||||
url.pathname.match(/\/tile\/\d+\/\d+\/\d+/);
|
||||
/**
|
||||
* Classify a URL into the appropriate tile cache.
|
||||
* Returns `null` for non-tile requests, or for tile providers we deliberately
|
||||
* do NOT cache (e.g. Google — caching is forbidden by their ToS).
|
||||
*/
|
||||
function getTileCacheName(url) {
|
||||
const host = url.hostname;
|
||||
|
||||
// OpenStreetMap — tile.openstreetmap.org and a/b/c subdomains
|
||||
if (host.endsWith('tile.openstreetmap.org')) return TILES_OSM;
|
||||
|
||||
// OpenTopoMap — a/b/c.tile.opentopomap.org
|
||||
if (host.endsWith('tile.opentopomap.org') || host.endsWith('opentopomap.org')) return TILES_TOPO;
|
||||
|
||||
// Carto Basemaps — light_all / dark_all distinguished by path
|
||||
if (host.endsWith('basemaps.cartocdn.com')) {
|
||||
if (url.pathname.includes('/light_all/')) return TILES_CARTO_LIGHT;
|
||||
if (url.pathname.includes('/dark_all/')) return TILES_CARTO_DARK;
|
||||
return null; // unknown Carto style
|
||||
}
|
||||
|
||||
// Esri — server.arcgisonline.com
|
||||
if (host.endsWith('arcgisonline.com')) return TILES_SATELLITE;
|
||||
|
||||
// Google — caching forbidden by ToS, do not store
|
||||
if (host.endsWith('google.com') || host.endsWith('googleapis.com')) return null;
|
||||
|
||||
// Other tile providers (WMS endpoints, OWS, custom) — not cached at this layer
|
||||
// (the user's "online only" toast handles those).
|
||||
return null;
|
||||
}
|
||||
|
||||
function isApiRequest(url) {
|
||||
@ -129,7 +221,6 @@ function isModuleAsset(url) {
|
||||
}
|
||||
|
||||
function isAppAsset(url) {
|
||||
// Same origin, common asset extensions
|
||||
return url.origin === self.location.origin &&
|
||||
(url.pathname.endsWith('.html') ||
|
||||
url.pathname.endsWith('.css') ||
|
||||
@ -144,13 +235,13 @@ function isAppAsset(url) {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cache First - Use cache, fallback to network
|
||||
* Best for: App shell, static assets
|
||||
* Cache First — Use cache, fallback to network.
|
||||
* Best for: App shell, static assets.
|
||||
*/
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
@ -159,7 +250,6 @@ async function cacheFirst(request, cacheName) {
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Return offline page for navigation requests
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match('/offline.html');
|
||||
}
|
||||
@ -168,8 +258,8 @@ async function cacheFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Network First - Try network, fallback to cache
|
||||
* Best for: API requests, dynamic content
|
||||
* Network First — Try network, fallback to cache.
|
||||
* Best for: API requests, dynamic content.
|
||||
*/
|
||||
async function networkFirst(request, cacheName) {
|
||||
try {
|
||||
@ -187,89 +277,141 @@ async function networkFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale While Revalidate - Return cache immediately, update in background
|
||||
* Best for: Module assets, frequently updated content
|
||||
* Stale While Revalidate — Return cache immediately, update in background.
|
||||
* Best for: Module assets, frequently updated content.
|
||||
*/
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
|
||||
const fetchPromise = fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}).catch(() => cached);
|
||||
|
||||
|
||||
return cached || fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache Then Network with limit - Cache tiles with size limit
|
||||
* Best for: Map tiles
|
||||
* Tile Cache then Network — Per-host bucket with size limit.
|
||||
* Cache first; on miss, fetch from network and store.
|
||||
*
|
||||
* Memory-conservative eviction:
|
||||
* • Increments an in-memory counter on every successful insert
|
||||
* • Only calls cache.keys() (which materialises all Request objects) every
|
||||
* EVICTION_CHECK_INTERVAL inserts — so the cost is amortised
|
||||
* • Eviction drops the oldest 10 % when over the per-host limit
|
||||
*
|
||||
* On network failure (offline), serves a 408 so the map renders a blank tile
|
||||
* rather than throwing.
|
||||
*/
|
||||
async function cacheThenNetwork(request, cacheName, maxItems) {
|
||||
async function tileCacheThenNetwork(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
// Check cache size and trim if needed
|
||||
const keys = await cache.keys();
|
||||
if (keys.length >= maxItems) {
|
||||
// Remove oldest entries (first 10%)
|
||||
const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1));
|
||||
await Promise.all(toDelete.map(key => cache.delete(key)));
|
||||
// Bump the counter; periodically run a real eviction sweep
|
||||
const count = (_tileInsertCounters.get(cacheName) || 0) + 1;
|
||||
_tileInsertCounters.set(cacheName, count);
|
||||
|
||||
if (count % EVICTION_CHECK_INTERVAL === 0) {
|
||||
// Reset the counter — next sweep is another EVICTION_CHECK_INTERVAL away
|
||||
_tileInsertCounters.set(cacheName, 0);
|
||||
await maybeEvict(cache, cacheName);
|
||||
}
|
||||
|
||||
cache.put(request, response.clone());
|
||||
|
||||
// Don't await put() — it can run after we return the response, keeping
|
||||
// the fetch hot path lightweight.
|
||||
cache.put(request, response.clone()).catch((err) => {
|
||||
// QuotaExceededError → run an immediate eviction sweep and retry once
|
||||
if (err && err.name === 'QuotaExceededError') {
|
||||
maybeEvict(cache, cacheName, /* force */ true).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// For tiles, just fail silently - map will show blank tile
|
||||
// Offline — let the map renderer show a blank tile
|
||||
return new Response('', { status: 408, statusText: 'Offline' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an eviction sweep on a cache, dropping the oldest 10 % of entries
|
||||
* when over the per-cache limit. Heavy: only call periodically.
|
||||
*/
|
||||
async function maybeEvict(cache, cacheName, force = false) {
|
||||
try {
|
||||
const limit = TILE_LIMITS[cacheName] || 1500;
|
||||
const keys = await cache.keys();
|
||||
if (force || keys.length >= limit) {
|
||||
const drop = Math.max(1, Math.ceil(limit * 0.1));
|
||||
const toDelete = keys.slice(0, drop);
|
||||
await Promise.all(toDelete.map((k) => cache.delete(k)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[SW] eviction sweep failed for', cacheName, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MESSAGE HANDLING
|
||||
// ============================================================================
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const { type, payload } = event.data || {};
|
||||
|
||||
|
||||
switch (type) {
|
||||
case 'SKIP_WAITING':
|
||||
self.skipWaiting();
|
||||
break;
|
||||
|
||||
|
||||
case 'CACHE_MODULES':
|
||||
cacheModules(payload.modules);
|
||||
break;
|
||||
|
||||
|
||||
case 'CLEAR_USER_CACHE':
|
||||
clearUserCaches();
|
||||
break;
|
||||
|
||||
|
||||
case 'GET_CACHE_STATUS':
|
||||
getCacheStatus().then(status => {
|
||||
getCacheStatus().then((status) => {
|
||||
event.source.postMessage({ type: 'CACHE_STATUS', status });
|
||||
});
|
||||
break;
|
||||
|
||||
// ----- Tile-cache management (Phase 1 offline maps) -----
|
||||
case 'GET_TILE_STATS':
|
||||
getTileStats().then((stats) => {
|
||||
event.source.postMessage({ type: 'TILE_STATS', stats });
|
||||
});
|
||||
break;
|
||||
|
||||
case 'CLEAR_TILE_CACHES':
|
||||
clearTileCaches().then(() => {
|
||||
event.source.postMessage({ type: 'TILE_CACHES_CLEARED' });
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cache specific modules on demand
|
||||
* Cache specific modules on demand.
|
||||
*/
|
||||
async function cacheModules(moduleNames) {
|
||||
const cache = await caches.open(MODULES_CACHE);
|
||||
|
||||
|
||||
for (const moduleName of moduleNames) {
|
||||
try {
|
||||
const moduleAssets = [
|
||||
@ -277,9 +419,8 @@ async function cacheModules(moduleNames) {
|
||||
`/modules/${moduleName}/index.css`,
|
||||
`/modules/${moduleName}/index.html`
|
||||
];
|
||||
|
||||
|
||||
await cache.addAll(moduleAssets.filter(async (url) => {
|
||||
// Only cache assets that exist
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
return response.ok;
|
||||
@ -287,7 +428,7 @@ async function cacheModules(moduleNames) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
console.log('[SW] Cached module:', moduleName);
|
||||
} catch (error) {
|
||||
console.warn('[SW] Failed to cache module:', moduleName, error);
|
||||
@ -296,7 +437,8 @@ async function cacheModules(moduleNames) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user-specific caches (call on logout)
|
||||
* Clear user-specific caches (call on logout).
|
||||
* Tile caches are NOT cleared here — those belong to the device, not the user.
|
||||
*/
|
||||
async function clearUserCaches() {
|
||||
await caches.delete(API_CACHE);
|
||||
@ -305,17 +447,90 @@ async function clearUserCaches() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status information
|
||||
* Get summary status of all caches (count of entries in each).
|
||||
*/
|
||||
async function getCacheStatus() {
|
||||
const cacheNames = await caches.keys();
|
||||
const status = {};
|
||||
|
||||
|
||||
for (const name of cacheNames) {
|
||||
const cache = await caches.open(name);
|
||||
const keys = await cache.keys();
|
||||
status[name] = keys.length;
|
||||
}
|
||||
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-provider tile cache statistics.
|
||||
*
|
||||
* Returns shape:
|
||||
* {
|
||||
* totals: { count, estBytes },
|
||||
* byProvider: [{ key, label, count, limit, estBytes }, ...]
|
||||
* }
|
||||
*
|
||||
* estBytes is an approximation (count × AVG_TILE_BYTES). For an exact size,
|
||||
* the caller can use navigator.storage.estimate() on the page side.
|
||||
*
|
||||
* Result is cached for STATS_TTL_MS so rapid re-queries (e.g. multiple
|
||||
* Settings opens) don't re-enumerate every cache.
|
||||
*/
|
||||
const STATS_TTL_MS = 10 * 1000;
|
||||
let _cachedStats = null;
|
||||
let _cachedStatsAt = 0;
|
||||
|
||||
async function getTileStats({ force = false } = {}) {
|
||||
const now = Date.now();
|
||||
if (!force && _cachedStats && (now - _cachedStatsAt) < STATS_TTL_MS) {
|
||||
return _cachedStats;
|
||||
}
|
||||
|
||||
const byProvider = [];
|
||||
let totalCount = 0;
|
||||
|
||||
for (const cacheName of ALL_TILE_CACHES) {
|
||||
let count = 0;
|
||||
if (await caches.has(cacheName)) {
|
||||
const cache = await caches.open(cacheName);
|
||||
// matchAll returns a smaller payload than keys() on Safari, but neither
|
||||
// is free. Done at most once per STATS_TTL_MS thanks to the cache above.
|
||||
const keys = await cache.keys();
|
||||
count = keys.length;
|
||||
}
|
||||
byProvider.push({
|
||||
key: cacheName,
|
||||
label: TILE_CACHE_LABELS[cacheName] || cacheName,
|
||||
count,
|
||||
limit: TILE_LIMITS[cacheName] || 0,
|
||||
estBytes: count * AVG_TILE_BYTES,
|
||||
});
|
||||
totalCount += count;
|
||||
}
|
||||
|
||||
_cachedStats = {
|
||||
totals: {
|
||||
count: totalCount,
|
||||
estBytes: totalCount * AVG_TILE_BYTES,
|
||||
},
|
||||
byProvider,
|
||||
};
|
||||
_cachedStatsAt = now;
|
||||
return _cachedStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every tile cache. Frees the device storage used by cached map tiles.
|
||||
* Does not affect app-shell, modules, or API caches.
|
||||
*/
|
||||
async function clearTileCaches() {
|
||||
const results = await Promise.all(
|
||||
ALL_TILE_CACHES.map((name) => caches.delete(name))
|
||||
);
|
||||
// Reset counters and invalidate stats cache
|
||||
_tileInsertCounters.clear();
|
||||
_cachedStats = null;
|
||||
_cachedStatsAt = 0;
|
||||
console.log('[SW] Cleared tile caches:', ALL_TILE_CACHES.filter((_, i) => results[i]));
|
||||
}
|
||||
|
||||
207
sql/create_landuse_parcels.sql
Normal file
@ -0,0 +1,207 @@
|
||||
-- ============================================================================
|
||||
-- LUPMIS — Land Use Parcels schema
|
||||
-- ============================================================================
|
||||
-- Source: "LAND USE INFORMATION FOR LUPMIS" (LUSPA, February 2026, revised)
|
||||
-- Implements the parcel-attribute table defined by Stephen / LUSPA, with a
|
||||
-- PostGIS geometry column and the indices needed for typical access patterns
|
||||
-- (spatial queries, lookup by zone / district / locality, time filtering).
|
||||
--
|
||||
-- Conventions:
|
||||
-- • Identifiers are unquoted (lowercase) — PostgreSQL folds them to lower
|
||||
-- case anyway, and this avoids the need for double-quotes in queries.
|
||||
-- • Source column names are PascalCase / Mixed_Case in the spec; their
|
||||
-- mapping to snake_case is shown in COMMENT ON COLUMN.
|
||||
-- • Geometry is stored in EPSG:4326 (WGS 84) for portability with the
|
||||
-- remote API. The MultiPolygon type accommodates parcels with islands
|
||||
-- or multi-part shapes.
|
||||
--
|
||||
-- Run as a database superuser (or a role with CREATEEXTENSION privilege)
|
||||
-- in the target database.
|
||||
-- ============================================================================
|
||||
|
||||
-- PostGIS is required for the geometry column and spatial index.
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- Drop existing table for clean re-runs in dev. Comment out for production.
|
||||
-- DROP TABLE IF EXISTS public.landuse_parcels CASCADE;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Table: public.landuse_parcels
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.landuse_parcels (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
-- Spec field 1: UPN — Unique Parcel Number (Integer, up to 10 digits).
|
||||
-- 10-digit integers can exceed INTEGER's max (2,147,483,647), hence BIGINT.
|
||||
upn BIGINT NOT NULL,
|
||||
|
||||
-- Spec field 2: Style — Colour Assign ID (Integer, 2 digits).
|
||||
-- References the colour palette defined in the Revised Zoning Guidelines
|
||||
-- and Planning Standards (2025). Optional FK to a lookup table.
|
||||
style SMALLINT,
|
||||
|
||||
-- Spec field 3: Landuse — Broad land use (Text, 50).
|
||||
landuse VARCHAR(50),
|
||||
|
||||
-- Spec field 4: Zone_Code — Zone acronym (Text, 5), e.g. "Re A".
|
||||
zone_code VARCHAR(5),
|
||||
|
||||
-- Spec field 5: Zone_Name — Zone name (Text, 50), e.g. "Residential Zone A".
|
||||
zone_name VARCHAR(50),
|
||||
|
||||
-- Spec field 6: Sector — Sector number of plan area (Text, 5).
|
||||
sector VARCHAR(5),
|
||||
|
||||
-- Spec field 7: Block — Block name within the sector (Text, 3).
|
||||
block VARCHAR(3),
|
||||
|
||||
-- Spec field 8: Parcel_No — Plot number for land registration (Text, 5).
|
||||
parcel_no VARCHAR(5),
|
||||
|
||||
-- Spec field 9: Prop_No — Property number for street addressing (Text, 5).
|
||||
prop_no VARCHAR(5),
|
||||
|
||||
-- Spec field 10: St_Name — Street name (Text, 18). From the Street Naming
|
||||
-- and Property Addressing System (SNPAS).
|
||||
st_name VARCHAR(18),
|
||||
|
||||
-- Spec field 11: Prop_Add — Property address (Text, 25).
|
||||
prop_add VARCHAR(25),
|
||||
|
||||
-- Spec field 12: Fac_Name — Facility name (Text, 100).
|
||||
fac_name VARCHAR(100),
|
||||
|
||||
-- Spec field 13: Min_Height — Minimum building height in storeys (Integer, 3).
|
||||
min_height SMALLINT,
|
||||
|
||||
-- Spec field 14: Max_Height — Maximum building height in storeys (Integer, 3).
|
||||
max_height SMALLINT,
|
||||
|
||||
-- Spec field 15: Eff_Date — Effective approval date by the District SPC.
|
||||
eff_date DATE,
|
||||
|
||||
-- Spec field 16: LP_Name — Local plan name (Text, 100).
|
||||
lp_name VARCHAR(100),
|
||||
|
||||
-- Spec field 17: Locality — Community / area name (Text, 50).
|
||||
locality VARCHAR(50),
|
||||
|
||||
-- Spec field 18: MMDA — Metropolitan / Municipal / District Assembly
|
||||
-- abbreviation (Text, 10), e.g. "LADMA".
|
||||
mmda VARCHAR(10),
|
||||
|
||||
-- Spec field 19: Last_Update — Last update on a parcel (e.g. change of
|
||||
-- use approved by SPC).
|
||||
last_update DATE,
|
||||
|
||||
-- Spec field 20: Remarks — Additional info (Text, 200).
|
||||
remarks VARCHAR(200),
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Geometry — parcel polygon in WGS 84 (EPSG:4326).
|
||||
-- MultiPolygon allows parcels with islands or disjoint parts.
|
||||
-- ------------------------------------------------------------------
|
||||
geom geometry(MultiPolygon, 4326),
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Audit columns (not in the spec, added for change tracking)
|
||||
-- ------------------------------------------------------------------
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Constraints
|
||||
-- ------------------------------------------------------------------
|
||||
CONSTRAINT uq_landuse_parcels_upn UNIQUE (upn),
|
||||
CONSTRAINT ck_landuse_parcels_style CHECK (style IS NULL OR style >= 0),
|
||||
CONSTRAINT ck_landuse_parcels_min_height CHECK (min_height IS NULL OR min_height >= 0),
|
||||
CONSTRAINT ck_landuse_parcels_max_height CHECK (max_height IS NULL OR max_height >= 0),
|
||||
CONSTRAINT ck_landuse_parcels_height_order
|
||||
CHECK (min_height IS NULL OR max_height IS NULL OR min_height <= max_height)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Column comments — preserve the source-document descriptions
|
||||
-- ---------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.landuse_parcels IS 'Land use parcels — LUSPA spec, February 2026 (revised).';
|
||||
|
||||
COMMENT ON COLUMN public.landuse_parcels.upn IS 'UPN — Unique Parcel Number (Integer, 10 digits).';
|
||||
COMMENT ON COLUMN public.landuse_parcels.style IS 'Style — Colour Assign ID per Revised Zoning Guidelines (2025).';
|
||||
COMMENT ON COLUMN public.landuse_parcels.landuse IS 'Broad land use, e.g. Residential, Commercial, Mixed.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.zone_code IS 'Zone code (acronym), e.g. Re A.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.zone_name IS 'Zone name, e.g. Residential Zone A.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.sector IS 'Sector number of the plan area.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.block IS 'Block name within the sector.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.parcel_no IS 'Plot number for land registration.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.prop_no IS 'Property number for street addressing.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.st_name IS 'Street name (max 18 characters, per SNPAS).';
|
||||
COMMENT ON COLUMN public.landuse_parcels.prop_add IS 'Property address of parcel.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.fac_name IS 'Facility name of property.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.min_height IS 'Minimum building height (storeys).';
|
||||
COMMENT ON COLUMN public.landuse_parcels.max_height IS 'Maximum building height (storeys).';
|
||||
COMMENT ON COLUMN public.landuse_parcels.eff_date IS 'Effective approval date by the District Spatial Planning Committee.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.lp_name IS 'Local plan name.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.locality IS 'Name of community or area.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.mmda IS 'Metropolitan/Municipal/District Assembly abbreviation, e.g. LADMA.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.last_update IS 'Last update on a parcel (e.g. change of use approved by SPC).';
|
||||
COMMENT ON COLUMN public.landuse_parcels.remarks IS 'Additional information on the parcel.';
|
||||
COMMENT ON COLUMN public.landuse_parcels.geom IS 'Parcel boundary geometry (MultiPolygon, EPSG:4326).';
|
||||
COMMENT ON COLUMN public.landuse_parcels.created_at IS 'Row-creation timestamp (audit).';
|
||||
COMMENT ON COLUMN public.landuse_parcels.updated_at IS 'Row last-modified timestamp (audit, maintained by trigger).';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Indices
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Spatial index — required for any ST_Intersects / ST_Within / map-bbox query.
|
||||
CREATE INDEX IF NOT EXISTS idx_landuse_parcels_geom
|
||||
ON public.landuse_parcels
|
||||
USING GIST (geom);
|
||||
|
||||
-- B-tree indices for common attribute lookups.
|
||||
-- (uq_landuse_parcels_upn already creates an implicit index on upn.)
|
||||
CREATE INDEX IF NOT EXISTS idx_landuse_parcels_zone_code
|
||||
ON public.landuse_parcels (zone_code);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_landuse_parcels_mmda
|
||||
ON public.landuse_parcels (mmda);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_landuse_parcels_locality
|
||||
ON public.landuse_parcels (locality);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_landuse_parcels_lp_name
|
||||
ON public.landuse_parcels (lp_name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_landuse_parcels_eff_date
|
||||
ON public.landuse_parcels (eff_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_landuse_parcels_last_update
|
||||
ON public.landuse_parcels (last_update);
|
||||
|
||||
-- Composite index for the very common "find all parcels in MMDA X with zone Y" query.
|
||||
CREATE INDEX IF NOT EXISTS idx_landuse_parcels_mmda_zone
|
||||
ON public.landuse_parcels (mmda, zone_code);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Trigger — keep updated_at fresh on every UPDATE
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.fn_landuse_parcels_set_updated_at()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_landuse_parcels_set_updated_at ON public.landuse_parcels;
|
||||
|
||||
CREATE TRIGGER trg_landuse_parcels_set_updated_at
|
||||
BEFORE UPDATE ON public.landuse_parcels
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_landuse_parcels_set_updated_at();
|
||||
|
||||
-- ============================================================================
|
||||
-- End of script
|
||||
-- ============================================================================
|
||||
@ -18,6 +18,7 @@ import { LineString, Circle, Polygon } from 'ol/geom';
|
||||
import { getLength, getArea } from 'ol/sphere';
|
||||
import Feature from 'ol/Feature';
|
||||
import { unByKey } from 'ol/Observable';
|
||||
import { formatLength, formatArea, formatCircleExtent } from '../units.js';
|
||||
|
||||
// ol-ext imports
|
||||
import EditBar from 'ol-ext/control/EditBar';
|
||||
@ -116,40 +117,6 @@ export class MapTools {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format length output
|
||||
*/
|
||||
formatLength(length) {
|
||||
if (length > 1000) {
|
||||
return (Math.round(length / 1000 * 100) / 100) + ' km';
|
||||
} else {
|
||||
return (Math.round(length * 100) / 100) + ' m';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format area output
|
||||
*/
|
||||
formatArea(area) {
|
||||
if (area > 1000000) {
|
||||
return (Math.round(area / 1000000 * 100) / 100) + ' km²';
|
||||
} else {
|
||||
return (Math.round(area * 100) / 100) + ' m²';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format circle extent (bounding box area)
|
||||
*/
|
||||
formatCircleExtent(radius) {
|
||||
const area = Math.PI * radius * radius;
|
||||
if (area > 1000000) {
|
||||
return (Math.round(area / 1000000 * 100) / 100) + ' km²';
|
||||
} else {
|
||||
return (Math.round(area * 100) / 100) + ' m²';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create measurement tooltip overlay
|
||||
*/
|
||||
@ -231,8 +198,8 @@ export class MapTools {
|
||||
|
||||
if (geom instanceof Circle) {
|
||||
const radius = geom.getRadius();
|
||||
const area = this.formatCircleExtent(radius);
|
||||
const radiusFormatted = this.formatLength(radius);
|
||||
const area = formatCircleExtent(radius);
|
||||
const radiusFormatted = formatLength(radius);
|
||||
|
||||
const output = `<strong>${radiusFormatted}</strong><br><small>${area}</small>`;
|
||||
|
||||
@ -311,7 +278,7 @@ export class MapTools {
|
||||
listener = sketch.getGeometry().on('change', (e) => {
|
||||
const geom = e.target;
|
||||
const length = getLength(geom);
|
||||
const output = this.formatLength(length);
|
||||
const output = formatLength(length);
|
||||
|
||||
this.measureTooltipElement.innerHTML = output;
|
||||
this.measureTooltip.setPosition(geom.getLastCoordinate());
|
||||
@ -364,7 +331,7 @@ export class MapTools {
|
||||
listener = sketch.getGeometry().on('change', (e) => {
|
||||
const geom = e.target;
|
||||
const area = getArea(geom);
|
||||
const output = this.formatArea(area);
|
||||
const output = formatArea(area);
|
||||
|
||||
this.measureTooltipElement.innerHTML = output;
|
||||
this.measureTooltip.setPosition(geom.getInteriorPoint().getCoordinates());
|
||||
@ -376,6 +343,10 @@ export class MapTools {
|
||||
const geom = feature.getGeometry();
|
||||
const area = getArea(geom);
|
||||
|
||||
// Tag so the double-click handler can identify it
|
||||
feature.set('_layerType', 'measure_area');
|
||||
feature.set('_area', area);
|
||||
|
||||
this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';
|
||||
this.measureTooltipElement = null;
|
||||
this.createMeasureTooltip();
|
||||
|
||||
170
src/database.js
@ -196,6 +196,17 @@ export async function initSchema() {
|
||||
)
|
||||
`;
|
||||
|
||||
// Create osm_roads table for caching the OSM road network
|
||||
console.log('[Database] Creating osm_roads table...');
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS osm_roads (
|
||||
osm_id INTEGER PRIMARY KEY,
|
||||
geometry_wkt TEXT,
|
||||
properties TEXT,
|
||||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
// Create indexes
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
|
||||
@ -541,17 +552,20 @@ export async function getLocalCollectorZones() {
|
||||
export async function saveParcels(parcels) {
|
||||
try {
|
||||
await sql`DELETE FROM parcels`;
|
||||
let saved = 0;
|
||||
for (const p of parcels) {
|
||||
const props = JSON.stringify(p);
|
||||
// API field names may vary — try common WKT field names
|
||||
const wkt = p.polygon || p.boundary || p.geom || p.wkt || '';
|
||||
const id = p.id || p.parcelid || p.parcel_id || null;
|
||||
if (id == null) continue; // skip rows without a usable ID
|
||||
const props = JSON.stringify(p);
|
||||
// API field names: 'boundary' (WKT), 'polygon', 'geom', 'wkt'
|
||||
const wkt = p.boundary || p.polygon || p.geom || p.wkt || '';
|
||||
await sql`
|
||||
INSERT INTO parcels (id, geometry_wkt, properties, fetched_at)
|
||||
INSERT OR REPLACE INTO parcels (id, geometry_wkt, properties, fetched_at)
|
||||
VALUES (${id}, ${wkt}, ${props}, CURRENT_TIMESTAMP)
|
||||
`;
|
||||
saved++;
|
||||
}
|
||||
console.log('[Database] ✓ Saved', parcels.length, 'parcels');
|
||||
console.log('[Database] ✓ Saved', saved, 'parcels (from', parcels.length, 'rows,', parcels.length - saved, 'duplicates replaced)');
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to save parcels:', error);
|
||||
throw error;
|
||||
@ -680,6 +694,62 @@ export async function getLocalBuildingFootprints() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OSM roads to the local SQLite table.
|
||||
* Replaces all existing rows.
|
||||
*
|
||||
* @param {Array} roads - Array of road objects from the API
|
||||
*/
|
||||
export async function saveOSMRoads(roads) {
|
||||
try {
|
||||
if (roads.length > 0) {
|
||||
const first = roads[0];
|
||||
const types = {};
|
||||
for (const [k, v] of Object.entries(first)) {
|
||||
types[k] = v === null ? 'null' : typeof v;
|
||||
}
|
||||
console.log('[Database] First road field types:', types);
|
||||
}
|
||||
|
||||
await sql`DELETE FROM osm_roads`;
|
||||
for (const r of roads) {
|
||||
const props = JSON.stringify(r);
|
||||
|
||||
// Geometry — may arrive as WKT string or GeoJSON object
|
||||
let rawWkt = r.geom || r.geometry || r.wkt || r.road || r.line || '';
|
||||
const wkt = typeof rawWkt === 'object' ? JSON.stringify(rawWkt) : String(rawWkt);
|
||||
|
||||
// osm_id must be a primitive — fall back to null if missing or malformed
|
||||
let rawId = r.osm_id ?? r.osmid ?? r.id ?? null;
|
||||
const osmId = (rawId !== null && typeof rawId === 'object') ? null : rawId;
|
||||
|
||||
await sql`
|
||||
INSERT OR REPLACE INTO osm_roads (osm_id, geometry_wkt, properties, fetched_at)
|
||||
VALUES (${osmId}, ${wkt}, ${props}, CURRENT_TIMESTAMP)
|
||||
`;
|
||||
}
|
||||
console.log('[Database] ✓ Saved', roads.length, 'OSM roads');
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to save OSM roads:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all cached OSM roads from the local table.
|
||||
* @returns {Promise<Array|null>} Array of road objects, or null if empty
|
||||
*/
|
||||
export async function getLocalOSMRoads() {
|
||||
try {
|
||||
const rows = await sql`SELECT properties FROM osm_roads ORDER BY osm_id`;
|
||||
if (rows.length === 0) return null;
|
||||
return rows.map(r => JSON.parse(r.properties));
|
||||
} catch (error) {
|
||||
console.error('[Database] ✗ Failed to read local OSM roads:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export / Import
|
||||
// ============================================================================
|
||||
@ -769,6 +839,90 @@ export async function getDatabaseStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cached-Layer Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Tables that hold data fetched from the server.
|
||||
*
|
||||
* Clearing these is safe — the corresponding loaders (loadParcels,
|
||||
* loadBuildingFootprints, loadOSMRoads, loadCollectorZones, …) will re-fetch
|
||||
* the data from the API on the next app start.
|
||||
*
|
||||
* NOT included: user-created tables (`locations`, `pending_changes`) — those
|
||||
* hold local work that must not be auto-deleted.
|
||||
*/
|
||||
export const CACHED_LAYER_TABLES = Object.freeze([
|
||||
'parcels',
|
||||
'building_footprints',
|
||||
'osm_roads',
|
||||
'collector_zones',
|
||||
'remote_data',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check whether a table name is in the cleared-layer allow-list.
|
||||
* @param {string} tableName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCachedLayerTable(tableName) {
|
||||
return CACHED_LAYER_TABLES.includes(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all rows from a single cached-layer table.
|
||||
* Rejects unknown table names so this can't be abused to drop user data.
|
||||
*
|
||||
* @param {string} tableName - One of CACHED_LAYER_TABLES
|
||||
* @returns {Promise<number>} Number of rows that were in the table before deletion
|
||||
*/
|
||||
export async function clearTable(tableName) {
|
||||
if (!isCachedLayerTable(tableName)) {
|
||||
throw new Error(`Refusing to clear "${tableName}" — not a known cached-layer table`);
|
||||
}
|
||||
|
||||
const before = await sql(`SELECT COUNT(*) AS n FROM "${tableName}"`);
|
||||
const count = before[0]?.n ?? 0;
|
||||
|
||||
await sql(`DELETE FROM "${tableName}"`);
|
||||
console.log(`[Database] ✓ Cleared "${tableName}" (${count} rows)`);
|
||||
broadcastChange(tableName, 'CLEAR', null);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear every cached-layer table (whatever exists in this database).
|
||||
* Tables that don't exist yet are skipped silently.
|
||||
*
|
||||
* @returns {Promise<{ table: string, count: number }[]>} per-table report
|
||||
*/
|
||||
export async function clearAllCachedLayers() {
|
||||
const existing = await sql`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name IN (
|
||||
'parcels', 'building_footprints', 'osm_roads', 'collector_zones', 'remote_data'
|
||||
)
|
||||
`;
|
||||
const existingNames = new Set(existing.map((r) => r.name));
|
||||
|
||||
const results = [];
|
||||
for (const tableName of CACHED_LAYER_TABLES) {
|
||||
if (!existingNames.has(tableName)) continue;
|
||||
try {
|
||||
const count = await clearTable(tableName);
|
||||
results.push({ table: tableName, count });
|
||||
} catch (err) {
|
||||
console.error(`[Database] Failed to clear ${tableName}:`, err);
|
||||
results.push({ table: tableName, count: 0, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const total = results.reduce((s, r) => s + r.count, 0);
|
||||
console.log(`[Database] ✓ Cleared all cached layers: ${total} rows across ${results.length} tables`);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all tables with their row counts.
|
||||
* @returns {Promise<Array<{name: string, count: number}>>}
|
||||
@ -890,6 +1044,12 @@ export default {
|
||||
insertNewParcel,
|
||||
saveBuildingFootprints,
|
||||
getLocalBuildingFootprints,
|
||||
saveOSMRoads,
|
||||
getLocalOSMRoads,
|
||||
CACHED_LAYER_TABLES,
|
||||
isCachedLayerTable,
|
||||
clearTable,
|
||||
clearAllCachedLayers,
|
||||
exportDatabase,
|
||||
exportToGeoJSON,
|
||||
importDatabase,
|
||||
|
||||
295
src/geom/polygonDivide.js
Normal file
@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Pure geometry functions for dividing a polygon into N equal-area pieces.
|
||||
*
|
||||
* No OpenLayers dependency — operates on raw coordinate arrays.
|
||||
*
|
||||
* The algorithm finds the polygon's longest edge, then places N-1 cutting
|
||||
* lines perpendicular to that edge. Each cutting-line position is found
|
||||
* via binary search so that the piece it cuts off has exactly 1/N of the
|
||||
* remaining area. The actual cut is delegated to `splitPolygonByLine()`.
|
||||
*/
|
||||
|
||||
import { splitPolygonByLine } from './polygonSplit.js';
|
||||
|
||||
// ── Utility helpers (self-contained) ─────────────────────────────────────────
|
||||
|
||||
function dist2(a, b) {
|
||||
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed area of a ring (shoelace formula).
|
||||
*/
|
||||
function signedArea(ring) {
|
||||
let area = 0;
|
||||
for (let i = 0, n = ring.length; i < n - 1; i++) {
|
||||
area += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute polygon area, accounting for holes.
|
||||
*/
|
||||
function polygonArea(coords) {
|
||||
let area = Math.abs(signedArea(coords[0]));
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
area -= Math.abs(signedArea(coords[i]));
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the longest edge of a ring and return direction vectors.
|
||||
*
|
||||
* @param {number[][]} ring Closed ring
|
||||
* @returns {{ p0: number[], p1: number[], along: number[], perp: number[] }}
|
||||
* `along` = unit vector along the longest edge,
|
||||
* `perp` = unit vector perpendicular to `along` (rotated 90° CCW)
|
||||
*/
|
||||
function longestEdge(ring) {
|
||||
const n = ring.length - 1; // unique vertices
|
||||
let bestLen = -1;
|
||||
let bestI = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = dist2(ring[i], ring[i + 1]);
|
||||
if (d > bestLen) {
|
||||
bestLen = d;
|
||||
bestI = i;
|
||||
}
|
||||
}
|
||||
|
||||
const p0 = ring[bestI];
|
||||
const p1 = ring[bestI + 1];
|
||||
const len = Math.sqrt(bestLen);
|
||||
const along = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len];
|
||||
// Perpendicular: rotate 90° CCW
|
||||
const perp = [-along[1], along[0]];
|
||||
|
||||
return { p0, p1, along, perp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a cutting line perpendicular to `along` at parameter `t`.
|
||||
*
|
||||
* The line passes through `origin + t * along` and extends `extent` units
|
||||
* in both `perp` directions — long enough to fully cross the polygon.
|
||||
*/
|
||||
function makeCuttingLine(origin, along, perp, t, extent) {
|
||||
const cx = origin[0] + t * along[0];
|
||||
const cy = origin[1] + t * along[1];
|
||||
return [
|
||||
[cx - extent * perp[0], cy - extent * perp[1]],
|
||||
[cx + extent * perp[0], cy + extent * perp[1]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the centroid of a polygon's exterior ring onto the `along` axis.
|
||||
* Returns the scalar parameter `t` relative to `origin`.
|
||||
*/
|
||||
function centroidT(coords, origin, along) {
|
||||
const ring = coords[0];
|
||||
const n = ring.length - 1;
|
||||
let sx = 0, sy = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
sx += ring[i][0];
|
||||
sy += ring[i][1];
|
||||
}
|
||||
const cx = sx / n - origin[0];
|
||||
const cy = sy / n - origin[1];
|
||||
return cx * along[0] + cy * along[1];
|
||||
}
|
||||
|
||||
// ── Main export ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Divide a polygon into N equal-area pieces by parallel cuts perpendicular
|
||||
* to a user-selected edge.
|
||||
*
|
||||
* @param {number[][][]} polygonCoords Polygon coordinates [ring, ...holes]
|
||||
* @param {number} n Number of pieces (must be >= 1)
|
||||
* @param {number[][]} edgeCoords The selected edge `[p0, p1]` — cuts will be
|
||||
* perpendicular to this edge direction.
|
||||
* @returns {{ pieces: number[][][][], error?: undefined } | { pieces: null, error: string }}
|
||||
*/
|
||||
export function dividePolygon(polygonCoords, n, edgeCoords) {
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
return { pieces: null, error: 'Number of divisions must be a positive integer.' };
|
||||
}
|
||||
if (n === 1) {
|
||||
return { pieces: [polygonCoords] };
|
||||
}
|
||||
|
||||
const ring = polygonCoords[0];
|
||||
const totalArea = polygonArea(polygonCoords);
|
||||
|
||||
if (totalArea < 1e-6) {
|
||||
return { pieces: null, error: 'Polygon has no measurable area.' };
|
||||
}
|
||||
|
||||
// 1. Determine cutting direction from the selected edge
|
||||
let p0, along, perp;
|
||||
if (edgeCoords && edgeCoords.length === 2) {
|
||||
p0 = edgeCoords[0];
|
||||
const dx = edgeCoords[1][0] - edgeCoords[0][0];
|
||||
const dy = edgeCoords[1][1] - edgeCoords[0][1];
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len < 1e-10) {
|
||||
return { pieces: null, error: 'Selected edge has zero length.' };
|
||||
}
|
||||
along = [dx / len, dy / len];
|
||||
perp = [-along[1], along[0]];
|
||||
} else {
|
||||
// Fallback: use longest edge
|
||||
const edge = longestEdge(ring);
|
||||
p0 = edge.p0;
|
||||
along = edge.along;
|
||||
perp = edge.perp;
|
||||
}
|
||||
const origin = p0;
|
||||
|
||||
// 2. Project all vertices onto the `along` axis to find extent
|
||||
const nVerts = ring.length - 1;
|
||||
let tMin = Infinity, tMax = -Infinity;
|
||||
for (let i = 0; i < nVerts; i++) {
|
||||
const dx = ring[i][0] - origin[0];
|
||||
const dy = ring[i][1] - origin[1];
|
||||
const t = dx * along[0] + dy * along[1];
|
||||
if (t < tMin) tMin = t;
|
||||
if (t > tMax) tMax = t;
|
||||
}
|
||||
|
||||
// Cutting line extent: enough to cross the polygon in the `perp` direction
|
||||
let perpMin = Infinity, perpMax = -Infinity;
|
||||
for (let i = 0; i < nVerts; i++) {
|
||||
const dx = ring[i][0] - origin[0];
|
||||
const dy = ring[i][1] - origin[1];
|
||||
const p = dx * perp[0] + dy * perp[1];
|
||||
if (p < perpMin) perpMin = p;
|
||||
if (p > perpMax) perpMax = p;
|
||||
}
|
||||
const extent = (perpMax - perpMin) * 1.5; // generous overshoot
|
||||
|
||||
// 3. Iteratively cut pieces
|
||||
const pieces = [];
|
||||
let remaining = polygonCoords;
|
||||
let remainingCount = n;
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const remainingArea = polygonArea(remaining);
|
||||
const targetArea = remainingArea / remainingCount;
|
||||
|
||||
// Re-project the remaining polygon to get its current t-range
|
||||
const remRing = remaining[0];
|
||||
const remN = remRing.length - 1;
|
||||
let rMin = Infinity, rMax = -Infinity;
|
||||
for (let j = 0; j < remN; j++) {
|
||||
const dx = remRing[j][0] - origin[0];
|
||||
const dy = remRing[j][1] - origin[1];
|
||||
const t = dx * along[0] + dy * along[1];
|
||||
if (t < rMin) rMin = t;
|
||||
if (t > rMax) rMax = t;
|
||||
}
|
||||
|
||||
// Binary search for the cutting position
|
||||
let lo = rMin;
|
||||
let hi = rMax;
|
||||
let bestT = (lo + hi) / 2;
|
||||
let bestPiece = null;
|
||||
let bestRemaining = null;
|
||||
let bestError = Infinity;
|
||||
|
||||
for (let iter = 0; iter < 40; iter++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const line = makeCuttingLine(origin, along, perp, mid, extent);
|
||||
const result = splitPolygonByLine(remaining, line);
|
||||
|
||||
if (!result) {
|
||||
// Cutting line didn't produce a valid split — nudge and retry
|
||||
// Try slightly shifted positions
|
||||
const nudge = (hi - lo) * 0.01;
|
||||
const lineA = makeCuttingLine(origin, along, perp, mid + nudge, extent);
|
||||
const resultA = splitPolygonByLine(remaining, lineA);
|
||||
if (resultA) {
|
||||
const [halfA, halfB] = resultA;
|
||||
const tA = centroidT(halfA, origin, along);
|
||||
const tB = centroidT(halfB, origin, along);
|
||||
const nearPiece = tA < tB ? halfA : halfB;
|
||||
const farPiece = tA < tB ? halfB : halfA;
|
||||
const nearArea = polygonArea(nearPiece);
|
||||
const err = Math.abs(nearArea - targetArea);
|
||||
if (err < bestError) {
|
||||
bestError = err;
|
||||
bestT = mid + nudge;
|
||||
bestPiece = nearPiece;
|
||||
bestRemaining = farPiece;
|
||||
}
|
||||
}
|
||||
// Try the other direction
|
||||
const lineB = makeCuttingLine(origin, along, perp, mid - nudge, extent);
|
||||
const resultB = splitPolygonByLine(remaining, lineB);
|
||||
if (resultB) {
|
||||
const [halfA, halfB] = resultB;
|
||||
const tA = centroidT(halfA, origin, along);
|
||||
const tB = centroidT(halfB, origin, along);
|
||||
const nearPiece = tA < tB ? halfA : halfB;
|
||||
const farPiece = tA < tB ? halfB : halfA;
|
||||
const nearArea = polygonArea(nearPiece);
|
||||
const err = Math.abs(nearArea - targetArea);
|
||||
if (err < bestError) {
|
||||
bestError = err;
|
||||
bestT = mid - nudge;
|
||||
bestPiece = nearPiece;
|
||||
bestRemaining = farPiece;
|
||||
}
|
||||
}
|
||||
// Bisect anyway to keep converging
|
||||
lo = mid;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [halfA, halfB] = result;
|
||||
const tA = centroidT(halfA, origin, along);
|
||||
const tB = centroidT(halfB, origin, along);
|
||||
const nearPiece = tA < tB ? halfA : halfB;
|
||||
const farPiece = tA < tB ? halfB : halfA;
|
||||
const nearArea = polygonArea(nearPiece);
|
||||
|
||||
const err = Math.abs(nearArea - targetArea);
|
||||
if (err < bestError) {
|
||||
bestError = err;
|
||||
bestT = mid;
|
||||
bestPiece = nearPiece;
|
||||
bestRemaining = farPiece;
|
||||
}
|
||||
|
||||
// Converged?
|
||||
if (err / remainingArea < 0.001) break;
|
||||
|
||||
// Adjust search range
|
||||
if (nearArea < targetArea) {
|
||||
lo = mid; // need to cut farther out
|
||||
} else {
|
||||
hi = mid; // need to cut closer
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestPiece || !bestRemaining) {
|
||||
return {
|
||||
pieces: null,
|
||||
error: `Could not find a valid cut for piece ${i + 1} of ${n}. The polygon shape may be too irregular for equal division.`,
|
||||
};
|
||||
}
|
||||
|
||||
pieces.push(bestPiece);
|
||||
remaining = bestRemaining;
|
||||
remainingCount--;
|
||||
}
|
||||
|
||||
// The last remaining piece is the Nth piece
|
||||
pieces.push(remaining);
|
||||
|
||||
return { pieces };
|
||||
}
|
||||
407
src/geom/polygonMerge.js
Normal file
@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Pure geometry functions for merging two adjacent polygons.
|
||||
*
|
||||
* No OpenLayers dependency — operates on raw coordinate arrays.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Squared distance between two points.
|
||||
*/
|
||||
function dist2(a, b) {
|
||||
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed area of a ring (shoelace formula).
|
||||
* Positive = counter-clockwise, negative = clockwise.
|
||||
*/
|
||||
function signedArea(ring) {
|
||||
let area = 0;
|
||||
for (let i = 0, n = ring.length; i < n - 1; i++) {
|
||||
area += (ring[i][0] * ring[i + 1][1]) - (ring[i + 1][0] * ring[i][1]);
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a point is inside a ring (ray-casting algorithm).
|
||||
*/
|
||||
function pointInRing(pt, ring) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 2; i < ring.length - 1; j = i++) {
|
||||
const xi = ring[i][0], yi = ring[i][1];
|
||||
const xj = ring[j][0], yj = ring[j][1];
|
||||
if (((yi > pt[1]) !== (yj > pt[1])) &&
|
||||
(pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi)) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a ring has the desired winding order.
|
||||
*/
|
||||
function ensureWinding(ring, ccw) {
|
||||
const area = signedArea(ring);
|
||||
if ((ccw && area < 0) || (!ccw && area > 0)) {
|
||||
return ring.slice().reverse();
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a ring (ensure first === last).
|
||||
*/
|
||||
function closeRing(coords) {
|
||||
if (coords.length < 2) return coords;
|
||||
if (dist2(coords[0], coords[coords.length - 1]) > 1e-10) {
|
||||
return [...coords, coords[0].slice()];
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance from a point to a segment.
|
||||
*
|
||||
* @param {number[]} pt
|
||||
* @param {number[]} segA Segment start
|
||||
* @param {number[]} segB Segment end
|
||||
* @returns {number} Squared distance
|
||||
*/
|
||||
function distToSegmentSq(pt, segA, segB) {
|
||||
const dx = segB[0] - segA[0];
|
||||
const dy = segB[1] - segA[1];
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
|
||||
if (lenSq < 1e-20) return dist2(pt, segA); // degenerate segment
|
||||
|
||||
// Parametric position of the projection
|
||||
let t = ((pt[0] - segA[0]) * dx + (pt[1] - segA[1]) * dy) / lenSq;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
|
||||
const projX = segA[0] + t * dx;
|
||||
const projY = segA[1] + t * dy;
|
||||
return (pt[0] - projX) ** 2 + (pt[1] - projY) ** 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the ring edge closest to a click coordinate.
|
||||
*
|
||||
* @param {number[][]} ring Closed ring
|
||||
* @param {number[]} clickCoord [x, y]
|
||||
* @returns {{ segIdx: number, distSq: number }}
|
||||
*/
|
||||
function findClosestEdge(ring, clickCoord) {
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
const n = ring.length - 1; // unique vertices
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = distToSegmentSq(clickCoord, ring[i], ring[(i + 1) % n === 0 ? n : i + 1]);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return { segIdx: bestIdx, distSq: bestDist };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two coordinates are equal within tolerance.
|
||||
*/
|
||||
function coordsEqual(a, b, tolSq) {
|
||||
return dist2(a, b) < tolSq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a point lies within tolerance of any edge of a ring.
|
||||
*
|
||||
* Unlike coordsEqual (vertex-to-vertex), this checks whether the point is
|
||||
* close to the ring's *boundary* — it projects onto segments, so it works
|
||||
* even when the two polygons have different vertex density along the shared
|
||||
* edge or when vertices are slightly offset from separate digitisation.
|
||||
*
|
||||
* @param {number[]} pt Point to test
|
||||
* @param {number[][]} ring Closed ring
|
||||
* @param {number} tolSq Squared distance tolerance
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isVertexNearRing(pt, ring, tolSq) {
|
||||
const n = ring.length - 1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (distToSegmentSq(pt, ring[i], ring[i + 1]) < tolSq) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the shared boundary between two polygon rings.
|
||||
*
|
||||
* Adjacent polygons share edges in reverse winding direction:
|
||||
* if A walks P→Q along the shared boundary, B walks Q→P.
|
||||
*
|
||||
* The algorithm has two stages:
|
||||
*
|
||||
* 1. **Seed validation** — uses `isVertexNearRing` (vertex-to-edge proximity)
|
||||
* to confirm the user-clicked edges actually lie on a common boundary.
|
||||
* This is the forgiving check that handles offset vertices from separate
|
||||
* digitisation.
|
||||
*
|
||||
* 2. **Lockstep extension** — walks both rings together (A forward, B in
|
||||
* the opposite direction) and extends the shared boundary one vertex at
|
||||
* a time. Three cases are tried at each step:
|
||||
* a) Both rings advance: vertex-to-vertex match (classic case).
|
||||
* b) Only A advances: A has an extra vertex that projects onto B's
|
||||
* frontier edge (different vertex density).
|
||||
* c) Only B advances: vice-versa.
|
||||
* Because extension is coupled to the *frontier edge* of the other ring
|
||||
* (not the entire ring), it cannot overshoot into non-shared territory,
|
||||
* even for small or closely-spaced polygons.
|
||||
*
|
||||
* @param {number[][]} ringA Closed ring
|
||||
* @param {number[][]} ringB Closed ring
|
||||
* @param {number} seedIdxA Seed edge index on ring A
|
||||
* @param {number} seedIdxB Seed edge index on ring B
|
||||
* @param {number} tolerance Distance tolerance (in map units)
|
||||
* @returns {{ startA: number, endA: number, startB: number, endB: number, reversed: boolean } | null}
|
||||
*/
|
||||
function findSharedBoundary(ringA, ringB, seedIdxA, seedIdxB, tolerance) {
|
||||
const nA = ringA.length - 1; // unique vertices
|
||||
const nB = ringB.length - 1;
|
||||
const tolSq = tolerance * tolerance;
|
||||
|
||||
// ── Validate seed edges ─────────────────────────────────────────────
|
||||
// Both vertices of the seed edge on A must be near ring B's boundary,
|
||||
// or both vertices of the seed edge on B must be near ring A's boundary.
|
||||
// This uses vertex-to-edge proximity so it handles offset digitisation.
|
||||
const a0 = ringA[seedIdxA];
|
||||
const a1 = ringA[(seedIdxA + 1) % nA];
|
||||
const b0 = ringB[seedIdxB];
|
||||
const b1 = ringB[(seedIdxB + 1) % nB];
|
||||
|
||||
const a0NearB = isVertexNearRing(a0, ringB, tolSq);
|
||||
const a1NearB = isVertexNearRing(a1, ringB, tolSq);
|
||||
const b0NearA = isVertexNearRing(b0, ringA, tolSq);
|
||||
const b1NearA = isVertexNearRing(b1, ringA, tolSq);
|
||||
|
||||
if (!(a0NearB && a1NearB) && !(b0NearA && b1NearA)) {
|
||||
console.warn('[polygonMerge] Seed edges are not on the shared boundary');
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Determine winding direction ─────────────────────────────────────
|
||||
// Reversed (the normal case): A's a0 ≈ B's b1 and A's a1 ≈ B's b0.
|
||||
let reversed;
|
||||
if (coordsEqual(a0, b1, tolSq) && coordsEqual(a1, b0, tolSq)) {
|
||||
reversed = true;
|
||||
} else if (coordsEqual(a0, b0, tolSq) && coordsEqual(a1, b1, tolSq)) {
|
||||
reversed = false;
|
||||
} else {
|
||||
// Vertices don't match exactly — use proximity to decide direction
|
||||
reversed = dist2(a0, b1) < dist2(a0, b0);
|
||||
}
|
||||
|
||||
// ── Initialise shared boundary ──────────────────────────────────────
|
||||
let startA = seedIdxA;
|
||||
let endA = (seedIdxA + 1) % nA;
|
||||
let startB, endB;
|
||||
|
||||
if (reversed) {
|
||||
// A walks startA → endA, B walks startB ← endB (reversed ring order)
|
||||
startB = (seedIdxB + 1) % nB;
|
||||
endB = seedIdxB;
|
||||
} else {
|
||||
startB = seedIdxB;
|
||||
endB = (seedIdxB + 1) % nB;
|
||||
}
|
||||
|
||||
// ── Extend forward (endA++, endB-- if reversed) ─────────────────────
|
||||
// Walk both rings in lockstep. At each step try three strategies:
|
||||
// 1. Both advance — vertices match (vertex-to-vertex).
|
||||
// 2. Only A advances — A's next vertex projects onto B's frontier edge.
|
||||
// 3. Only B advances — B's next vertex projects onto A's frontier edge.
|
||||
let safety = nA + nB;
|
||||
while (safety-- > 0) {
|
||||
const nextA = (endA + 1) % nA;
|
||||
const nextB = reversed ? (endB - 1 + nB) % nB : (endB + 1) % nB;
|
||||
if (nextA === startA || nextB === startB) break; // wrapped around
|
||||
|
||||
// Case 1: vertex-to-vertex match
|
||||
if (coordsEqual(ringA[nextA], ringB[nextB], tolSq)) {
|
||||
endA = nextA;
|
||||
endB = nextB;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: A has extra vertex — project onto B's frontier edge
|
||||
if (distToSegmentSq(ringA[nextA], ringB[endB], ringB[nextB]) < tolSq) {
|
||||
endA = nextA;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 3: B has extra vertex — project onto A's frontier edge
|
||||
if (distToSegmentSq(ringB[nextB], ringA[endA], ringA[nextA]) < tolSq) {
|
||||
endB = nextB;
|
||||
continue;
|
||||
}
|
||||
|
||||
break; // no match — end of shared boundary
|
||||
}
|
||||
|
||||
// ── Extend backward (startA--, startB++ if reversed) ────────────────
|
||||
safety = nA + nB;
|
||||
while (safety-- > 0) {
|
||||
const prevA = (startA - 1 + nA) % nA;
|
||||
const prevB = reversed ? (startB + 1) % nB : (startB - 1 + nB) % nB;
|
||||
if (prevA === endA || prevB === endB) break;
|
||||
|
||||
// Case 1: vertex-to-vertex match
|
||||
if (coordsEqual(ringA[prevA], ringB[prevB], tolSq)) {
|
||||
startA = prevA;
|
||||
startB = prevB;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: A has extra vertex — project onto B's frontier edge
|
||||
if (distToSegmentSq(ringA[prevA], ringB[startB], ringB[prevB]) < tolSq) {
|
||||
startA = prevA;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 3: B has extra vertex — project onto A's frontier edge
|
||||
if (distToSegmentSq(ringB[prevB], ringA[startA], ringA[prevA]) < tolSq) {
|
||||
startB = prevB;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return { startA, endA, startB, endB, reversed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a ring from startIdx to endIdx (exclusive), going forward and wrapping.
|
||||
* Skips startIdx and stops before reaching endIdx.
|
||||
* Returns the vertices of the non-shared portion.
|
||||
*
|
||||
* @param {number[][]} ring Closed ring
|
||||
* @param {number} fromIdx Start walking from this index (inclusive)
|
||||
* @param {number} toIdx Stop at this index (inclusive)
|
||||
* @returns {number[][]}
|
||||
*/
|
||||
function walkRing(ring, fromIdx, toIdx) {
|
||||
const n = ring.length - 1;
|
||||
const result = [];
|
||||
let idx = fromIdx;
|
||||
while (true) {
|
||||
result.push(ring[idx]);
|
||||
if (idx === toIdx) break;
|
||||
idx = (idx + 1) % n;
|
||||
// Safety: prevent infinite loops
|
||||
if (result.length > n + 1) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two adjacent polygons along their shared boundary.
|
||||
*
|
||||
* @param {number[][][]} polygonCoordsA Polygon A coordinates [exteriorRing, ...holes]
|
||||
* @param {number[][][]} polygonCoordsB Polygon B coordinates [exteriorRing, ...holes]
|
||||
* @param {number[]} clickCoordA Click coordinate on the shared edge of polygon A
|
||||
* @param {number[]} clickCoordB Click coordinate on the shared edge of polygon B
|
||||
* @param {number} [tolerance=5] Distance tolerance in map units (default 5 metres in EPSG:3857).
|
||||
* A larger tolerance handles polygons that were digitised separately and
|
||||
* whose shared vertices don't coincide exactly.
|
||||
* @returns {{ coords: number[][][], error?: undefined } | { coords: null, error: string }}
|
||||
* On success: `{ coords: [...] }`. On failure: `{ coords: null, error: 'reason' }`.
|
||||
*/
|
||||
export function mergePolygons(polygonCoordsA, polygonCoordsB, clickCoordA, clickCoordB, tolerance = 5) {
|
||||
const ringA = polygonCoordsA[0];
|
||||
const ringB = polygonCoordsB[0];
|
||||
const holesA = polygonCoordsA.slice(1);
|
||||
const holesB = polygonCoordsB.slice(1);
|
||||
|
||||
// 1. Find seed edges (closest to user clicks)
|
||||
const seedA = findClosestEdge(ringA, clickCoordA);
|
||||
const seedB = findClosestEdge(ringB, clickCoordB);
|
||||
|
||||
// 2. Find shared boundary
|
||||
const shared = findSharedBoundary(ringA, ringB, seedA.segIdx, seedB.segIdx, tolerance);
|
||||
if (!shared) {
|
||||
console.warn('[polygonMerge] Could not find shared boundary between polygons — seed edges are not near the other ring');
|
||||
return { coords: null, error: 'The selected edges are not on a shared boundary. Click edges that lie on the common border between the two polygons.' };
|
||||
}
|
||||
|
||||
const { startA, endA, startB, endB, reversed } = shared;
|
||||
const nA = ringA.length - 1;
|
||||
const nB = ringB.length - 1;
|
||||
|
||||
// 3. Stitch the non-shared portions together
|
||||
// A's shared goes from startA → endA. Non-shared: endA → startA (forward, wrapping).
|
||||
// B's shared (reversed case) goes from startB backward to endB.
|
||||
// Non-shared: startB → endB (forward).
|
||||
// B's shared (same-dir case) goes from startB → endB.
|
||||
// Non-shared: endB → startB (forward, wrapping).
|
||||
//
|
||||
// The last vertex of partA (startA) must coincide with the first vertex
|
||||
// of partB for a clean join.
|
||||
|
||||
const partA = walkRing(ringA, endA, startA);
|
||||
|
||||
let partB;
|
||||
if (reversed) {
|
||||
// B's non-shared goes from startB forward to endB.
|
||||
// startB vertex ≈ startA vertex (they meet at one end of the shared boundary).
|
||||
partB = walkRing(ringB, startB, endB);
|
||||
} else {
|
||||
// B's non-shared goes from endB forward to startB.
|
||||
partB = walkRing(ringB, endB, startB);
|
||||
}
|
||||
|
||||
// partA ends at startA, partB starts at a vertex that should coincide.
|
||||
// Skip the first vertex of partB to avoid the duplicate junction point.
|
||||
const merged = [...partA, ...partB.slice(1)];
|
||||
|
||||
// Snap the closing junction. With non-coincident vertices (separate
|
||||
// digitisation) the last vertex of partB may be a few metres from the
|
||||
// first vertex of partA (ringA[endA]). Replace it to avoid a tiny
|
||||
// sliver edge that closeRing would otherwise create.
|
||||
const tolSq = tolerance * tolerance;
|
||||
if (merged.length > 2 && dist2(merged[merged.length - 1], merged[0]) < tolSq) {
|
||||
merged[merged.length - 1] = merged[0].slice();
|
||||
}
|
||||
|
||||
const mergedRing = closeRing(merged);
|
||||
|
||||
// 4. Validate: the merged ring should have a reasonable area
|
||||
const areaA = Math.abs(signedArea(ringA));
|
||||
const areaB = Math.abs(signedArea(ringB));
|
||||
const areaMerged = Math.abs(signedArea(mergedRing));
|
||||
const expectedArea = areaA + areaB;
|
||||
|
||||
// Allow 10% tolerance for area mismatch (shared edges can cause slight differences)
|
||||
if (areaMerged < expectedArea * 0.5 || areaMerged > expectedArea * 1.5) {
|
||||
console.warn(`[polygonMerge] Area mismatch: A=${areaA.toFixed(1)}, B=${areaB.toFixed(1)}, merged=${areaMerged.toFixed(1)}, expected≈${expectedArea.toFixed(1)}`);
|
||||
return { coords: null, error: 'Merge produced an invalid polygon (area mismatch). The polygons may not be truly adjacent — try clicking closer to the shared boundary.' };
|
||||
}
|
||||
|
||||
// 5. Match winding order to original
|
||||
const originalCCW = signedArea(ringA) > 0;
|
||||
const finalRing = ensureWinding(mergedRing, originalCCW);
|
||||
|
||||
// 6. Collect holes from both polygons
|
||||
const allHoles = [...holesA, ...holesB];
|
||||
// Filter: only include holes that actually fall inside the merged ring
|
||||
const validHoles = allHoles.filter(hole => {
|
||||
const cx = hole.reduce((s, p) => s + p[0], 0) / (hole.length - 1);
|
||||
const cy = hole.reduce((s, p) => s + p[1], 0) / (hole.length - 1);
|
||||
return pointInRing([cx, cy], finalRing);
|
||||
});
|
||||
|
||||
return { coords: [finalRing, ...validHoles] };
|
||||
}
|
||||
395
src/geom/polygonSplit.js
Normal file
@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Pure geometry functions for splitting a polygon by a line.
|
||||
*
|
||||
* No OpenLayers dependency — operates on raw coordinate arrays.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the intersection point of two 2D line segments.
|
||||
* Segment A: p1→p2, Segment B: p3→p4.
|
||||
*
|
||||
* @param {number[]} p1
|
||||
* @param {number[]} p2
|
||||
* @param {number[]} p3
|
||||
* @param {number[]} p4
|
||||
* @param {number} [eps=1e-10] tolerance for parallel check
|
||||
* @returns {{ point: number[], t: number, u: number } | null}
|
||||
* t = parametric position on segment A (0–1),
|
||||
* u = parametric position on segment B (0–1)
|
||||
*/
|
||||
function segmentIntersection(p1, p2, p3, p4, eps = 1e-10) {
|
||||
const dx1 = p2[0] - p1[0];
|
||||
const dy1 = p2[1] - p1[1];
|
||||
const dx2 = p4[0] - p3[0];
|
||||
const dy2 = p4[1] - p3[1];
|
||||
|
||||
const denom = dx1 * dy2 - dy1 * dx2;
|
||||
if (Math.abs(denom) < eps) return null; // parallel / collinear
|
||||
|
||||
const dx3 = p3[0] - p1[0];
|
||||
const dy3 = p3[1] - p1[1];
|
||||
|
||||
const t = (dx3 * dy2 - dy3 * dx2) / denom;
|
||||
const u = (dx3 * dy1 - dy3 * dx1) / denom;
|
||||
|
||||
if (t < -eps || t > 1 + eps || u < -eps || u > 1 + eps) return null;
|
||||
|
||||
return {
|
||||
point: [p1[0] + t * dx1, p1[1] + t * dy1],
|
||||
t: Math.max(0, Math.min(1, t)),
|
||||
u: Math.max(0, Math.min(1, u)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed area of a ring (shoelace formula).
|
||||
* Positive = counter-clockwise, negative = clockwise.
|
||||
*/
|
||||
function signedArea(ring) {
|
||||
let area = 0;
|
||||
for (let i = 0, n = ring.length; i < n - 1; i++) {
|
||||
area += (ring[i][0] * ring[i + 1][1]) - (ring[i + 1][0] * ring[i][1]);
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a point is inside a ring (ray-casting algorithm).
|
||||
*/
|
||||
function pointInRing(pt, ring) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 2; i < ring.length - 1; j = i++) {
|
||||
const xi = ring[i][0], yi = ring[i][1];
|
||||
const xj = ring[j][0], yj = ring[j][1];
|
||||
if (((yi > pt[1]) !== (yj > pt[1])) &&
|
||||
(pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi)) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Squared distance between two points.
|
||||
*/
|
||||
function dist2(a, b) {
|
||||
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all intersection points between a cutting line and a polygon ring.
|
||||
*
|
||||
* @param {number[][]} ring Closed ring coordinates (first === last)
|
||||
* @param {number[][]} line LineString coordinates (2+ points)
|
||||
* @returns {Array<{ point: number[], ringSegIdx: number, ringT: number, lineSegIdx: number, lineT: number }>}
|
||||
*/
|
||||
function findIntersections(ring, line) {
|
||||
const hits = [];
|
||||
const eps = 1e-10;
|
||||
|
||||
for (let li = 0; li < line.length - 1; li++) {
|
||||
for (let ri = 0; ri < ring.length - 1; ri++) {
|
||||
const ix = segmentIntersection(ring[ri], ring[ri + 1], line[li], line[li + 1], eps);
|
||||
if (!ix) continue;
|
||||
|
||||
// Skip if intersection is at the very start of the ring segment
|
||||
// but was already caught as the end of the previous segment
|
||||
const pt = ix.point;
|
||||
|
||||
// Avoid duplicate hits at shared vertices
|
||||
let isDup = false;
|
||||
for (const h of hits) {
|
||||
if (dist2(h.point, pt) < 1e-6) {
|
||||
isDup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isDup) continue;
|
||||
|
||||
hits.push({
|
||||
point: pt,
|
||||
ringSegIdx: ri,
|
||||
ringT: ix.t,
|
||||
lineSegIdx: li,
|
||||
lineT: ix.u,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by position along the cutting line
|
||||
hits.sort((a, b) => {
|
||||
if (a.lineSegIdx !== b.lineSegIdx) return a.lineSegIdx - b.lineSegIdx;
|
||||
return a.lineT - b.lineT;
|
||||
});
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert intersection points into a ring, returning the expanded ring
|
||||
* and the new indices of the inserted points.
|
||||
*
|
||||
* @param {number[][]} ring Closed ring (first === last)
|
||||
* @param {Array<{ point: number[], ringSegIdx: number, ringT: number }>} hits
|
||||
* Sorted by ringSegIdx then ringT.
|
||||
* @returns {{ ring: number[][], indices: number[] }}
|
||||
*/
|
||||
function insertPointsIntoRing(ring, hits) {
|
||||
// Sort hits by ring position (segment index, then parametric t) so
|
||||
// we can insert from back to front without shifting earlier indices.
|
||||
const sorted = hits.map((h, i) => ({ ...h, origOrder: i }));
|
||||
sorted.sort((a, b) => {
|
||||
if (a.ringSegIdx !== b.ringSegIdx) return a.ringSegIdx - b.ringSegIdx;
|
||||
return a.ringT - b.ringT;
|
||||
});
|
||||
|
||||
const expanded = ring.slice(); // copy
|
||||
const indices = new Array(sorted.length);
|
||||
|
||||
// Insert from the end so that earlier insertions don't shift later indices.
|
||||
for (let k = sorted.length - 1; k >= 0; k--) {
|
||||
const h = sorted[k];
|
||||
const insertIdx = h.ringSegIdx + 1;
|
||||
|
||||
// Check if this point is essentially identical to an existing vertex
|
||||
const snapDist = 1e-6;
|
||||
if (dist2(h.point, expanded[h.ringSegIdx]) < snapDist) {
|
||||
indices[h.origOrder] = h.ringSegIdx;
|
||||
continue;
|
||||
}
|
||||
if (dist2(h.point, expanded[h.ringSegIdx + 1]) < snapDist) {
|
||||
indices[h.origOrder] = h.ringSegIdx + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert the new point
|
||||
expanded.splice(insertIdx, 0, h.point);
|
||||
indices[h.origOrder] = insertIdx;
|
||||
|
||||
// Adjust indices for all previously recorded insertions
|
||||
// that reference a position >= insertIdx
|
||||
for (let j = k + 1; j < sorted.length; j++) {
|
||||
if (indices[sorted[j].origOrder] >= insertIdx) {
|
||||
indices[sorted[j].origOrder]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ring: expanded, indices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a slice of a ring from index i0 to i1 (going forward, wrapping).
|
||||
* Both endpoints are included.
|
||||
*
|
||||
* @param {number[][]} ring Closed ring (first === last); length includes closing vertex
|
||||
* @param {number} i0 Start index (inclusive)
|
||||
* @param {number} i1 End index (inclusive)
|
||||
* @returns {number[][]}
|
||||
*/
|
||||
function ringSlice(ring, i0, i1) {
|
||||
const n = ring.length - 1; // number of unique vertices (ring is closed)
|
||||
// Normalise indices into the [0, n-1] range
|
||||
const start = ((i0 % n) + n) % n;
|
||||
const end = ((i1 % n) + n) % n;
|
||||
const result = [];
|
||||
let idx = start;
|
||||
while (true) {
|
||||
result.push(ring[idx]);
|
||||
if (idx === end) break;
|
||||
idx = (idx + 1) % n;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the cutting line segment between two intersection points.
|
||||
*
|
||||
* @param {number[][]} line Full cutting line coordinates
|
||||
* @param {{ point: number[], lineSegIdx: number, lineT: number }} hit0
|
||||
* @param {{ point: number[], lineSegIdx: number, lineT: number }} hit1
|
||||
* @returns {number[][]} Coordinates from hit0.point to hit1.point along the line
|
||||
*/
|
||||
function cuttingLineSlice(line, hit0, hit1) {
|
||||
const result = [hit0.point];
|
||||
|
||||
// Include all intermediate line vertices between the two hit segments
|
||||
const startSeg = hit0.lineSegIdx;
|
||||
const endSeg = hit1.lineSegIdx;
|
||||
|
||||
for (let i = startSeg + 1; i <= endSeg; i++) {
|
||||
result.push(line[i]);
|
||||
}
|
||||
|
||||
// Add the end intersection point if it's not the same as the last vertex
|
||||
if (dist2(result[result.length - 1], hit1.point) > 1e-10) {
|
||||
result.push(hit1.point);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a ring has the desired winding order.
|
||||
* @param {number[][]} ring Closed ring
|
||||
* @param {boolean} ccw true for counter-clockwise
|
||||
* @returns {number[][]}
|
||||
*/
|
||||
function ensureWinding(ring, ccw) {
|
||||
const area = signedArea(ring);
|
||||
if ((ccw && area < 0) || (!ccw && area > 0)) {
|
||||
return ring.slice().reverse();
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a ring (ensure first === last).
|
||||
*/
|
||||
function closeRing(coords) {
|
||||
if (coords.length < 2) return coords;
|
||||
const first = coords[0];
|
||||
const last = coords[coords.length - 1];
|
||||
if (dist2(first, last) > 1e-10) {
|
||||
return [...coords, first.slice()];
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend a cutting line so that both endpoints lie outside the polygon ring.
|
||||
* If an endpoint is inside, we extend the first/last segment outward past the
|
||||
* bounding box diagonal so it definitely exits.
|
||||
*
|
||||
* @param {number[][]} line Cutting line coordinates
|
||||
* @param {number[][]} ring Closed polygon ring
|
||||
* @returns {number[][]} Extended line (may be the original if already outside)
|
||||
*/
|
||||
function extendLineOutsideRing(line, ring) {
|
||||
// Compute bounding-box diagonal for a generous extension distance
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const pt of ring) {
|
||||
if (pt[0] < minX) minX = pt[0];
|
||||
if (pt[1] < minY) minY = pt[1];
|
||||
if (pt[0] > maxX) maxX = pt[0];
|
||||
if (pt[1] > maxY) maxY = pt[1];
|
||||
}
|
||||
const diag = Math.sqrt((maxX - minX) ** 2 + (maxY - minY) ** 2) || 1;
|
||||
|
||||
const result = line.slice();
|
||||
|
||||
// Extend start if inside
|
||||
if (pointInRing(result[0], ring)) {
|
||||
const p0 = result[0];
|
||||
const p1 = result[1];
|
||||
const dx = p0[0] - p1[0];
|
||||
const dy = p0[1] - p1[1];
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const scale = diag * 2 / len;
|
||||
result[0] = [p0[0] + dx * scale, p0[1] + dy * scale];
|
||||
}
|
||||
|
||||
// Extend end if inside
|
||||
const last = result.length - 1;
|
||||
if (pointInRing(result[last], ring)) {
|
||||
const pN = result[last];
|
||||
const pN1 = result[last - 1];
|
||||
const dx = pN[0] - pN1[0];
|
||||
const dy = pN[1] - pN1[1];
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const scale = diag * 2 / len;
|
||||
result[last] = [pN[0] + dx * scale, pN[1] + dy * scale];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a polygon by a cutting line.
|
||||
*
|
||||
* The cutting line can start or end inside the polygon — the algorithm will
|
||||
* automatically extend it outward so it crosses the boundary at exactly 2
|
||||
* points. Multi-vertex cutting lines (with corners or approximated arcs)
|
||||
* are fully supported.
|
||||
*
|
||||
* @param {number[][][]} polygonCoords Polygon coordinates:
|
||||
* [exteriorRing, ...holeRings] where each ring is closed (first === last)
|
||||
* @param {number[][]} lineCoords Cutting line coordinates (2+ points)
|
||||
* @returns {number[][][][] | null} Two polygon coordinate arrays, or null if split failed
|
||||
*/
|
||||
export function splitPolygonByLine(polygonCoords, lineCoords) {
|
||||
const exteriorRing = polygonCoords[0];
|
||||
const holes = polygonCoords.slice(1);
|
||||
|
||||
// Extend the cutting line if its endpoints are inside the polygon
|
||||
const extendedLine = extendLineOutsideRing(lineCoords, exteriorRing);
|
||||
|
||||
// 1. Find intersections between cutting line and exterior ring
|
||||
const hits = findIntersections(exteriorRing, extendedLine);
|
||||
|
||||
// We need exactly 2 intersection points for a simple split
|
||||
if (hits.length !== 2) {
|
||||
console.warn(`[polygonSplit] Expected 2 intersections, got ${hits.length}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [hit0, hit1] = hits;
|
||||
|
||||
// 2. Insert intersection points into the ring
|
||||
const { ring: expandedRing, indices } = insertPointsIntoRing(exteriorRing, hits);
|
||||
const idx0 = indices[0];
|
||||
const idx1 = indices[1];
|
||||
|
||||
// Ensure idx0 < idx1 for consistent traversal
|
||||
const [iA, iB] = idx0 < idx1 ? [idx0, idx1] : [idx1, idx0];
|
||||
const [hitA, hitB] = idx0 < idx1 ? [hit0, hit1] : [hit1, hit0];
|
||||
|
||||
// 3. Get the cutting line segment between the two intersection points
|
||||
const cutForward = idx0 < idx1
|
||||
? cuttingLineSlice(extendedLine, hit0, hit1)
|
||||
: cuttingLineSlice(extendedLine, hit1, hit0);
|
||||
const cutReverse = cutForward.slice().reverse();
|
||||
|
||||
// 4. Build two polygon rings
|
||||
// Ring A: walk ring from iA to iB (forward), then cutting line reversed back to iA
|
||||
const sliceAB = ringSlice(expandedRing, iA, iB);
|
||||
const ringA = closeRing([...sliceAB, ...cutReverse.slice(1)]);
|
||||
|
||||
// Ring B: walk ring from iB to iA (wrapping), then cutting line forward back to iB
|
||||
const sliceBA = ringSlice(expandedRing, iB, iA);
|
||||
const ringB = closeRing([...sliceBA, ...cutForward.slice(1)]);
|
||||
|
||||
// 5. Match winding order to original
|
||||
const originalCCW = signedArea(exteriorRing) > 0;
|
||||
const finalA = ensureWinding(ringA, originalCCW);
|
||||
const finalB = ensureWinding(ringB, originalCCW);
|
||||
|
||||
// 6. Build polygon coordinate arrays, assigning holes to the correct piece
|
||||
const polyA = [finalA];
|
||||
const polyB = [finalB];
|
||||
|
||||
for (const hole of holes) {
|
||||
// Use the centroid of the hole to determine containment
|
||||
const centroid = holeCentroid(hole);
|
||||
if (pointInRing(centroid, finalA)) {
|
||||
polyA.push(hole);
|
||||
} else {
|
||||
polyB.push(hole);
|
||||
}
|
||||
}
|
||||
|
||||
return [polyA, polyB];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the centroid of a closed ring.
|
||||
*/
|
||||
function holeCentroid(ring) {
|
||||
let cx = 0, cy = 0;
|
||||
const n = ring.length - 1; // exclude closing vertex
|
||||
for (let i = 0; i < n; i++) {
|
||||
cx += ring[i][0];
|
||||
cy += ring[i][1];
|
||||
}
|
||||
return [cx / n, cy / n];
|
||||
}
|
||||
492
src/interactions/PolygonDivideInteraction.js
Normal file
@ -0,0 +1,492 @@
|
||||
/**
|
||||
* PolygonDivideInteraction
|
||||
*
|
||||
* A three-phase OpenLayers interaction for dividing a polygon into N
|
||||
* equal-area pieces:
|
||||
* Phase 1 – SELECT: hover to highlight, click to select a polygon
|
||||
* Phase 2 – EDGE: hover to highlight edges, click to pick the divide
|
||||
* direction (cuts will be perpendicular to this edge)
|
||||
* Phase 3 – FORM: wait for the popup form to call performDivide(n)
|
||||
*
|
||||
* After a successful divide the original feature is removed and N new
|
||||
* coloured features are added. The interaction fires `beforedivide` and
|
||||
* `afterdivide` events compatible with ol-ext's UndoRedo.
|
||||
*/
|
||||
|
||||
import ol_interaction_Interaction from 'ol/interaction/Interaction';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import Feature from 'ol/Feature';
|
||||
import { Style, Stroke, Fill } from 'ol/style';
|
||||
import { LineString, Polygon as PolygonGeom } from 'ol/geom';
|
||||
import { dividePolygon } from '../geom/polygonDivide.js';
|
||||
import { showToast } from '../toast.js';
|
||||
|
||||
// Highlight style for the selected polygon (phase 1)
|
||||
const HIGHLIGHT_STYLE = new Style({
|
||||
stroke: new Stroke({ color: '#0ea5e9', width: 3 }),
|
||||
fill: new Fill({ color: 'rgba(14,165,233,0.15)' }),
|
||||
});
|
||||
|
||||
// Style for the hovered edge (phase 2)
|
||||
const EDGE_STYLE = new Style({
|
||||
stroke: new Stroke({ color: '#8b5cf6', width: 4, lineDash: [10, 6] }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate N visually distinct colours using evenly-spaced HSL hues.
|
||||
*/
|
||||
function pieceColors(n) {
|
||||
const colors = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const hue = Math.round((i * 360) / n);
|
||||
colors.push({
|
||||
stroke: `hsl(${hue}, 70%, 45%)`,
|
||||
fill: `hsla(${hue}, 70%, 55%, 0.25)`,
|
||||
});
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
export class PolygonDivideInteraction extends ol_interaction_Interaction {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {VectorSource|VectorSource[]} [options.sources] Specific sources
|
||||
* to search. If omitted the interaction searches all visible vector layers.
|
||||
* @param {number} [options.snapDistance=25] Pixel distance for hover.
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
handleEvent: (e) => this._handleEvent(e),
|
||||
});
|
||||
|
||||
this.snapDistance_ = options.snapDistance || 25;
|
||||
this._sources = options.sources
|
||||
? (Array.isArray(options.sources) ? options.sources : [options.sources])
|
||||
: null;
|
||||
|
||||
// Phase: 'select' | 'edge' | 'form' | 'pick'
|
||||
this._phase = 'select';
|
||||
this._selectedFeature = null;
|
||||
this._selectedSource = null;
|
||||
this._selectedEdge = null; // [p0, p1] — the edge the user clicked
|
||||
this._dividedFeatures = null; // features created after divide (for pick phase)
|
||||
|
||||
// Overlay layer for polygon highlight
|
||||
this._overlaySource = new VectorSource({ useSpatialIndex: false });
|
||||
this._overlayLayer = new VectorLayer({
|
||||
source: this._overlaySource,
|
||||
displayInLayerSwitcher: false,
|
||||
style: HIGHLIGHT_STYLE,
|
||||
});
|
||||
|
||||
// Overlay layer for edge highlight
|
||||
this._edgeSource = new VectorSource({ useSpatialIndex: false });
|
||||
this._edgeLayer = new VectorLayer({
|
||||
source: this._edgeSource,
|
||||
displayInLayerSwitcher: false,
|
||||
style: EDGE_STYLE,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Map lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
setMap(map) {
|
||||
if (this.getMap()) {
|
||||
this.getMap().removeLayer(this._overlayLayer);
|
||||
this.getMap().removeLayer(this._edgeLayer);
|
||||
}
|
||||
super.setMap(map);
|
||||
if (map) {
|
||||
this._overlayLayer.setMap(map);
|
||||
this._edgeLayer.setMap(map);
|
||||
}
|
||||
}
|
||||
|
||||
setActive(active) {
|
||||
super.setActive(active);
|
||||
if (!active) {
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Source helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_getSources() {
|
||||
if (this._sources) return this._sources;
|
||||
if (!this.getMap()) return [];
|
||||
const sources = [];
|
||||
const collect = (layers) => {
|
||||
layers.forEach((layer) => {
|
||||
if (layer.getVisible()) {
|
||||
if (layer.getSource && layer.getSource() instanceof VectorSource) {
|
||||
sources.push(layer.getSource());
|
||||
} else if (layer.getLayers) {
|
||||
collect(layer.getLayers());
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
collect(this.getMap().getLayers());
|
||||
return sources;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Event router */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_handleEvent(e) {
|
||||
if (!this.getActive()) return true;
|
||||
|
||||
// Escape cancels at any phase
|
||||
if (e.type === 'keydown' && e.originalEvent?.key === 'Escape') {
|
||||
if (this._phase === 'form') {
|
||||
this.cancelDivide();
|
||||
} else {
|
||||
this._reset();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._phase === 'select') {
|
||||
if (e.type === 'pointermove') return this._onSelectMove(e);
|
||||
if (e.type === 'singleclick') return this._onSelectClick(e);
|
||||
}
|
||||
|
||||
if (this._phase === 'edge') {
|
||||
if (e.type === 'pointermove') return this._onEdgeMove(e);
|
||||
if (e.type === 'singleclick') return this._onEdgeClick(e);
|
||||
}
|
||||
|
||||
if (this._phase === 'pick') {
|
||||
if (e.type === 'pointermove') return this._onPickMove(e);
|
||||
if (e.type === 'singleclick') return this._onPickClick(e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 1: SELECT polygon */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_onSelectMove(e) {
|
||||
const map = this.getMap();
|
||||
if (!map) return true;
|
||||
|
||||
this._overlaySource.clear();
|
||||
|
||||
const hit = this._closestPolygon(e);
|
||||
if (hit) {
|
||||
const clone = hit.feature.clone();
|
||||
this._overlaySource.addFeature(clone);
|
||||
map.getTargetElement().style.cursor = 'pointer';
|
||||
} else {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onSelectClick(e) {
|
||||
const hit = this._closestPolygon(e);
|
||||
if (!hit) return true;
|
||||
|
||||
this._selectedFeature = hit.feature;
|
||||
this._selectedSource = hit.source;
|
||||
|
||||
// Keep polygon highlight visible during edge phase
|
||||
this._overlaySource.clear();
|
||||
const clone = hit.feature.clone();
|
||||
clone.set('_permanent', true);
|
||||
this._overlaySource.addFeature(clone);
|
||||
|
||||
this._phase = 'edge';
|
||||
showToast('Click the edge to divide along.', 'info', 3000);
|
||||
return false;
|
||||
}
|
||||
|
||||
_closestPolygon(e) {
|
||||
let best = null;
|
||||
let bestDist = this.snapDistance_ + 1;
|
||||
|
||||
for (const source of this._getSources()) {
|
||||
const feat = source.getClosestFeatureToCoordinate(e.coordinate);
|
||||
if (!feat) continue;
|
||||
const geom = feat.getGeometry();
|
||||
if (!geom) continue;
|
||||
const type = geom.getType();
|
||||
if (type !== 'Polygon' && type !== 'MultiPolygon') continue;
|
||||
|
||||
const closest = geom.getClosestPoint(e.coordinate);
|
||||
const line = new LineString([e.coordinate, closest]);
|
||||
const distPx = line.getLength() / e.frameState.viewState.resolution;
|
||||
|
||||
if (distPx < bestDist) {
|
||||
bestDist = distPx;
|
||||
best = { feature: feat, source };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 2: EDGE selection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_onEdgeMove(e) {
|
||||
const map = this.getMap();
|
||||
if (!map) return true;
|
||||
|
||||
this._edgeSource.clear();
|
||||
|
||||
const edge = this._closestEdgeSegment(this._selectedFeature, e);
|
||||
if (edge) {
|
||||
const edgeFeat = new Feature(new LineString([edge.segStart, edge.segEnd]));
|
||||
this._edgeSource.addFeature(edgeFeat);
|
||||
map.getTargetElement().style.cursor = 'crosshair';
|
||||
} else {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onEdgeClick(e) {
|
||||
const edge = this._closestEdgeSegment(this._selectedFeature, e);
|
||||
if (!edge) return true;
|
||||
|
||||
this._selectedEdge = [edge.segStart, edge.segEnd];
|
||||
this._edgeSource.clear();
|
||||
|
||||
this._phase = 'form';
|
||||
|
||||
// Dispatch divideform so MapView can show the popup
|
||||
const geom = this._selectedFeature.getGeometry();
|
||||
const ext = geom.getExtent();
|
||||
const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2];
|
||||
|
||||
this.dispatchEvent({
|
||||
type: 'divideform',
|
||||
feature: this._selectedFeature,
|
||||
source: this._selectedSource,
|
||||
coordinate: center,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest edge segment of a polygon feature to the cursor.
|
||||
*/
|
||||
_closestEdgeSegment(feature, e) {
|
||||
const geom = feature.getGeometry();
|
||||
let ring;
|
||||
if (geom.getType() === 'Polygon') {
|
||||
ring = geom.getCoordinates()[0];
|
||||
} else if (geom.getType() === 'MultiPolygon') {
|
||||
ring = geom.getCoordinates()[0][0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolution = e.frameState.viewState.resolution;
|
||||
let bestDist = Infinity;
|
||||
let bestSeg = null;
|
||||
const n = ring.length - 1;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = ring[i];
|
||||
const b = ring[i + 1];
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq < 1e-20) continue;
|
||||
|
||||
let t = ((e.coordinate[0] - a[0]) * dx + (e.coordinate[1] - a[1]) * dy) / lenSq;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const projX = a[0] + t * dx, projY = a[1] + t * dy;
|
||||
const distPx = Math.sqrt((e.coordinate[0] - projX) ** 2 + (e.coordinate[1] - projY) ** 2) / resolution;
|
||||
|
||||
if (distPx < bestDist) {
|
||||
bestDist = distPx;
|
||||
bestSeg = { segStart: a, segEnd: b };
|
||||
}
|
||||
}
|
||||
return bestDist <= this.snapDistance_ ? bestSeg : null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 3: FORM — called externally by the popup */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Divide the selected polygon into `n` equal-area pieces.
|
||||
* Called by the MapView popup's Confirm handler.
|
||||
*
|
||||
* @param {number} n Number of pieces (>= 2)
|
||||
*/
|
||||
performDivide(n) {
|
||||
if (this._phase !== 'form' || !this._selectedFeature) return;
|
||||
|
||||
const feature = this._selectedFeature;
|
||||
const source = this._selectedSource;
|
||||
const geom = feature.getGeometry();
|
||||
|
||||
let polygonCoords;
|
||||
if (geom.getType() === 'Polygon') {
|
||||
polygonCoords = geom.getCoordinates();
|
||||
} else if (geom.getType() === 'MultiPolygon') {
|
||||
polygonCoords = geom.getCoordinates()[0];
|
||||
}
|
||||
|
||||
const result = dividePolygon(polygonCoords, n, this._selectedEdge);
|
||||
|
||||
if (!result.pieces) {
|
||||
showToast(result.error || 'Division failed.', 'error', 5000);
|
||||
this._reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create N new coloured features
|
||||
const colors = pieceColors(n);
|
||||
const newFeatures = result.pieces.map((coords, i) => {
|
||||
const f = feature.clone();
|
||||
f.setGeometry(new PolygonGeom(coords));
|
||||
f.setStyle(new Style({
|
||||
stroke: new Stroke({ color: colors[i].stroke, width: 2.5 }),
|
||||
fill: new Fill({ color: colors[i].fill }),
|
||||
}));
|
||||
return f;
|
||||
});
|
||||
|
||||
// Dispatch beforedivide (UndoRedo compatible)
|
||||
const evtData = {
|
||||
type: 'beforedivide',
|
||||
original: feature,
|
||||
features: newFeatures,
|
||||
};
|
||||
this.dispatchEvent(evtData);
|
||||
source.dispatchEvent({ ...evtData });
|
||||
|
||||
// Replace original with pieces
|
||||
source.removeFeature(feature);
|
||||
for (const f of newFeatures) {
|
||||
source.addFeature(f);
|
||||
}
|
||||
|
||||
// Dispatch afterdivide
|
||||
const afterEvt = {
|
||||
type: 'afterdivide',
|
||||
original: feature,
|
||||
features: newFeatures,
|
||||
};
|
||||
this.dispatchEvent(afterEvt);
|
||||
source.dispatchEvent({ ...afterEvt });
|
||||
|
||||
// If original was a parcel, enter pick phase for UPN assignment
|
||||
const isParcel = feature.get('_layerType') === 'parcel';
|
||||
if (isParcel) {
|
||||
this._dividedFeatures = newFeatures;
|
||||
this._phase = 'pick';
|
||||
showToast('Click the polygon that should keep the original identifier.', 'info', 5000);
|
||||
|
||||
this.dispatchEvent({
|
||||
type: 'dividedparcel',
|
||||
features: newFeatures,
|
||||
originalProps: feature.getProperties(),
|
||||
source,
|
||||
});
|
||||
} else {
|
||||
showToast(`Polygon divided into ${n} equal pieces.`, 'success');
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 4: PICK — select which piece keeps the UPN */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_onPickMove(e) {
|
||||
const map = this.getMap();
|
||||
if (!map) return true;
|
||||
|
||||
this._overlaySource.clear();
|
||||
|
||||
// Highlight whichever divided piece is under the cursor
|
||||
const hit = this._closestDividedPiece(e);
|
||||
if (hit) {
|
||||
const clone = hit.clone();
|
||||
this._overlaySource.addFeature(clone);
|
||||
map.getTargetElement().style.cursor = 'pointer';
|
||||
} else {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onPickClick(e) {
|
||||
const hit = this._closestDividedPiece(e);
|
||||
if (!hit) return true;
|
||||
|
||||
this.dispatchEvent({
|
||||
type: 'dividepick',
|
||||
picked: hit,
|
||||
features: this._dividedFeatures,
|
||||
});
|
||||
|
||||
this._reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest divided piece to the cursor.
|
||||
*/
|
||||
_closestDividedPiece(e) {
|
||||
if (!this._dividedFeatures) return null;
|
||||
let best = null;
|
||||
let bestDist = this.snapDistance_ + 1;
|
||||
|
||||
for (const feat of this._dividedFeatures) {
|
||||
const geom = feat.getGeometry();
|
||||
if (!geom) continue;
|
||||
const closest = geom.getClosestPoint(e.coordinate);
|
||||
const line = new LineString([e.coordinate, closest]);
|
||||
const distPx = line.getLength() / e.frameState.viewState.resolution;
|
||||
if (distPx < bestDist) {
|
||||
bestDist = distPx;
|
||||
best = feat;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the divide operation and return to select phase.
|
||||
* Called by the MapView popup's Cancel handler.
|
||||
*/
|
||||
cancelDivide() {
|
||||
this.dispatchEvent({ type: 'dividecancel' });
|
||||
this._reset();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reset */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_reset() {
|
||||
this._phase = 'select';
|
||||
this._selectedFeature = null;
|
||||
this._selectedSource = null;
|
||||
this._selectedEdge = null;
|
||||
this._dividedFeatures = null;
|
||||
this._overlaySource.clear();
|
||||
this._edgeSource.clear();
|
||||
|
||||
const map = this.getMap();
|
||||
if (map) {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
475
src/interactions/PolygonMergeInteraction.js
Normal file
@ -0,0 +1,475 @@
|
||||
/**
|
||||
* PolygonMergeInteraction
|
||||
*
|
||||
* A four-phase OpenLayers interaction for merging two adjacent polygons:
|
||||
* Phase 1 – SELECT_A: hover to highlight, click to select polygon A
|
||||
* Phase 2 – SELECT_B: hover to highlight, click to select polygon B
|
||||
* Phase 3 – CLICK_EDGE_A: hover highlights edge, click to pick shared edge on A
|
||||
* Phase 4 – CLICK_EDGE_B: hover highlights edge, click to pick shared edge on B → merge
|
||||
*
|
||||
* After a successful merge the two original features are removed and one
|
||||
* merged feature (coloured green) is added. If both originals were parcels,
|
||||
* a `mergedparcel` event is fired so external code can present a UPN chooser.
|
||||
*/
|
||||
|
||||
import ol_interaction_Interaction from 'ol/interaction/Interaction';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import Feature from 'ol/Feature';
|
||||
import { Style, Stroke, Fill, Text } from 'ol/style';
|
||||
import { LineString, Polygon as PolygonGeom } from 'ol/geom';
|
||||
import { mergePolygons } from '../geom/polygonMerge.js';
|
||||
import { showToast } from '../toast.js';
|
||||
|
||||
// ── Styles ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const HIGHLIGHT_A = new Style({
|
||||
stroke: new Stroke({ color: '#0ea5e9', width: 3 }),
|
||||
fill: new Fill({ color: 'rgba(14,165,233,0.15)' }),
|
||||
});
|
||||
|
||||
const HIGHLIGHT_B = new Style({
|
||||
stroke: new Stroke({ color: '#f59e0b', width: 3 }),
|
||||
fill: new Fill({ color: 'rgba(245,158,11,0.15)' }),
|
||||
});
|
||||
|
||||
// Labelled versions for permanent highlights (shown after selection)
|
||||
const LABEL_A = new Style({
|
||||
stroke: new Stroke({ color: '#0ea5e9', width: 3 }),
|
||||
fill: new Fill({ color: 'rgba(14,165,233,0.15)' }),
|
||||
text: new Text({
|
||||
text: 'A',
|
||||
font: 'bold 22px Exo, sans-serif',
|
||||
fill: new Fill({ color: '#0ea5e9' }),
|
||||
stroke: new Stroke({ color: '#fff', width: 4 }),
|
||||
overflow: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const LABEL_B = new Style({
|
||||
stroke: new Stroke({ color: '#f59e0b', width: 3 }),
|
||||
fill: new Fill({ color: 'rgba(245,158,11,0.15)' }),
|
||||
text: new Text({
|
||||
text: 'B',
|
||||
font: 'bold 22px Exo, sans-serif',
|
||||
fill: new Fill({ color: '#f59e0b' }),
|
||||
stroke: new Stroke({ color: '#fff', width: 4 }),
|
||||
overflow: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const EDGE_STYLE = new Style({
|
||||
stroke: new Stroke({ color: '#ec4899', width: 4, lineDash: [10, 6] }),
|
||||
});
|
||||
|
||||
const MERGE_STYLE = new Style({
|
||||
stroke: new Stroke({ color: '#10b981', width: 2.5 }),
|
||||
fill: new Fill({ color: 'rgba(16,185,129,0.3)' }),
|
||||
});
|
||||
|
||||
// ── Interaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
export class PolygonMergeInteraction extends ol_interaction_Interaction {
|
||||
/**
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.snapDistance=25] Pixel distance for hover detection.
|
||||
* @param {number} [options.tolerance=5] Map-unit tolerance for shared-edge matching.
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
handleEvent: (e) => this._handleEvent(e),
|
||||
});
|
||||
|
||||
this.snapDistance_ = options.snapDistance || 25;
|
||||
this.tolerance_ = options.tolerance || 5;
|
||||
|
||||
// Phase: 'select_a' | 'select_b' | 'click_edge_a' | 'click_edge_b'
|
||||
this._phase = 'select_a';
|
||||
|
||||
// Selected features and their sources
|
||||
this._featureA = null;
|
||||
this._sourceA = null;
|
||||
this._featureB = null;
|
||||
this._sourceB = null;
|
||||
|
||||
// Clicked edge coordinates (map coords)
|
||||
this._edgeClickA = null;
|
||||
this._edgeClickB = null;
|
||||
|
||||
// Overlay for polygon highlights
|
||||
this._highlightSource = new VectorSource({ useSpatialIndex: false });
|
||||
this._highlightLayer = new VectorLayer({
|
||||
source: this._highlightSource,
|
||||
displayInLayerSwitcher: false,
|
||||
style: (f) => f.get('_highlightStyle') || HIGHLIGHT_A,
|
||||
});
|
||||
|
||||
// Overlay for edge highlights
|
||||
this._edgeSource = new VectorSource({ useSpatialIndex: false });
|
||||
this._edgeLayer = new VectorLayer({
|
||||
source: this._edgeSource,
|
||||
displayInLayerSwitcher: false,
|
||||
style: EDGE_STYLE,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Map lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
setMap(map) {
|
||||
if (this.getMap()) {
|
||||
this.getMap().removeLayer(this._highlightLayer);
|
||||
this.getMap().removeLayer(this._edgeLayer);
|
||||
}
|
||||
super.setMap(map);
|
||||
if (map) {
|
||||
this._highlightLayer.setMap(map);
|
||||
this._edgeLayer.setMap(map);
|
||||
}
|
||||
}
|
||||
|
||||
setActive(active) {
|
||||
super.setActive(active);
|
||||
if (!active) this._reset();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Source helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_getSources() {
|
||||
if (!this.getMap()) return [];
|
||||
const sources = [];
|
||||
const collect = (layers) => {
|
||||
layers.forEach((layer) => {
|
||||
if (layer.getVisible()) {
|
||||
if (layer.getSource && layer.getSource() instanceof VectorSource) {
|
||||
sources.push(layer.getSource());
|
||||
} else if (layer.getLayers) {
|
||||
collect(layer.getLayers());
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
collect(this.getMap().getLayers());
|
||||
return sources;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Event router */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_handleEvent(e) {
|
||||
if (!this.getActive()) return true;
|
||||
|
||||
// Escape cancels at any phase
|
||||
if (e.type === 'keydown' && e.originalEvent?.key === 'Escape') {
|
||||
this._reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (this._phase) {
|
||||
case 'select_a':
|
||||
if (e.type === 'pointermove') return this._onSelectMove(e, null);
|
||||
if (e.type === 'singleclick') return this._onSelectAClick(e);
|
||||
break;
|
||||
case 'select_b':
|
||||
if (e.type === 'pointermove') return this._onSelectMove(e, this._featureA);
|
||||
if (e.type === 'singleclick') return this._onSelectBClick(e);
|
||||
break;
|
||||
case 'click_edge_a':
|
||||
if (e.type === 'pointermove') return this._onEdgeMove(e, this._featureA);
|
||||
if (e.type === 'singleclick') return this._onEdgeAClick(e);
|
||||
break;
|
||||
case 'click_edge_b':
|
||||
if (e.type === 'pointermove') return this._onEdgeMove(e, this._featureB);
|
||||
if (e.type === 'singleclick') return this._onEdgeBClick(e);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 1 & 2: SELECT polygons */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_onSelectMove(e, skipFeature) {
|
||||
const map = this.getMap();
|
||||
if (!map) return true;
|
||||
|
||||
// Keep existing highlights for already-selected polygons
|
||||
this._highlightSource.clear();
|
||||
this._edgeSource.clear();
|
||||
this._rebuildHighlights();
|
||||
|
||||
const hit = this._closestPolygon(e, skipFeature);
|
||||
if (hit) {
|
||||
const style = this._phase === 'select_a' ? HIGHLIGHT_A : HIGHLIGHT_B;
|
||||
const clone = hit.feature.clone();
|
||||
clone.set('_highlightStyle', style);
|
||||
this._highlightSource.addFeature(clone);
|
||||
map.getTargetElement().style.cursor = 'pointer';
|
||||
} else {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onSelectAClick(e) {
|
||||
const hit = this._closestPolygon(e, null);
|
||||
if (!hit) return true;
|
||||
|
||||
this._featureA = hit.feature;
|
||||
this._sourceA = hit.source;
|
||||
this._phase = 'select_b';
|
||||
|
||||
this._rebuildHighlights();
|
||||
return false;
|
||||
}
|
||||
|
||||
_onSelectBClick(e) {
|
||||
const hit = this._closestPolygon(e, this._featureA);
|
||||
if (!hit) return true;
|
||||
|
||||
this._featureB = hit.feature;
|
||||
this._sourceB = hit.source;
|
||||
this._phase = 'click_edge_a';
|
||||
|
||||
this._rebuildHighlights();
|
||||
this.getMap().getTargetElement().style.cursor = 'crosshair';
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest polygon feature within snap distance.
|
||||
* Optionally skip a feature (used in phase 2 to avoid re-selecting A).
|
||||
*/
|
||||
_closestPolygon(e, skipFeature) {
|
||||
let best = null;
|
||||
let bestDist = this.snapDistance_ + 1;
|
||||
|
||||
for (const source of this._getSources()) {
|
||||
const feat = source.getClosestFeatureToCoordinate(e.coordinate);
|
||||
if (!feat) continue;
|
||||
if (skipFeature && feat === skipFeature) continue;
|
||||
const geom = feat.getGeometry();
|
||||
if (!geom) continue;
|
||||
const type = geom.getType();
|
||||
if (type !== 'Polygon' && type !== 'MultiPolygon') continue;
|
||||
|
||||
const closest = geom.getClosestPoint(e.coordinate);
|
||||
const line = new LineString([e.coordinate, closest]);
|
||||
const distPx = line.getLength() / e.frameState.viewState.resolution;
|
||||
|
||||
if (distPx < bestDist) {
|
||||
bestDist = distPx;
|
||||
best = { feature: feat, source, coord: closest };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 3 & 4: CLICK edges */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_onEdgeMove(e, feature) {
|
||||
const map = this.getMap();
|
||||
if (!map) return true;
|
||||
|
||||
this._edgeSource.clear();
|
||||
|
||||
const edge = this._closestEdgeSegment(feature, e);
|
||||
if (edge) {
|
||||
const edgeFeat = new Feature(new LineString([edge.segStart, edge.segEnd]));
|
||||
this._edgeSource.addFeature(edgeFeat);
|
||||
map.getTargetElement().style.cursor = 'crosshair';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onEdgeAClick(e) {
|
||||
this._edgeClickA = e.coordinate;
|
||||
this._phase = 'click_edge_b';
|
||||
this._edgeSource.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
_onEdgeBClick(e) {
|
||||
this._edgeClickB = e.coordinate;
|
||||
this._performMerge();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest edge segment of a polygon feature to the cursor.
|
||||
*/
|
||||
_closestEdgeSegment(feature, e) {
|
||||
const geom = feature.getGeometry();
|
||||
let ring;
|
||||
if (geom.getType() === 'Polygon') {
|
||||
ring = geom.getCoordinates()[0];
|
||||
} else if (geom.getType() === 'MultiPolygon') {
|
||||
ring = geom.getCoordinates()[0][0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolution = e.frameState.viewState.resolution;
|
||||
let bestDist = Infinity;
|
||||
let bestSeg = null;
|
||||
const n = ring.length - 1;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = ring[i];
|
||||
const b = ring[i + 1];
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq < 1e-20) continue;
|
||||
|
||||
let t = ((e.coordinate[0] - a[0]) * dx + (e.coordinate[1] - a[1]) * dy) / lenSq;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const projX = a[0] + t * dx, projY = a[1] + t * dy;
|
||||
const distPx = Math.sqrt((e.coordinate[0] - projX) ** 2 + (e.coordinate[1] - projY) ** 2) / resolution;
|
||||
|
||||
if (distPx < bestDist) {
|
||||
bestDist = distPx;
|
||||
bestSeg = { segStart: a, segEnd: b };
|
||||
}
|
||||
}
|
||||
return bestDist <= this.snapDistance_ ? bestSeg : null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Merge logic */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_performMerge() {
|
||||
const featureA = this._featureA;
|
||||
const featureB = this._featureB;
|
||||
const sourceA = this._sourceA;
|
||||
const sourceB = this._sourceB;
|
||||
|
||||
// Extract polygon coordinates
|
||||
const geomA = featureA.getGeometry();
|
||||
const geomB = featureB.getGeometry();
|
||||
const coordsA = geomA.getType() === 'Polygon' ? geomA.getCoordinates() : geomA.getCoordinates()[0];
|
||||
const coordsB = geomB.getType() === 'Polygon' ? geomB.getCoordinates() : geomB.getCoordinates()[0];
|
||||
|
||||
const result = mergePolygons(coordsA, coordsB, this._edgeClickA, this._edgeClickB, this.tolerance_);
|
||||
|
||||
if (!result.coords) {
|
||||
showToast(result.error || 'Merge failed — try clicking on the shared boundary.', 'error', 5000);
|
||||
// Return to edge click phase for retry
|
||||
this._edgeClickA = null;
|
||||
this._edgeClickB = null;
|
||||
this._phase = 'click_edge_a';
|
||||
this._edgeSource.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create merged feature (clone A for default properties)
|
||||
const mergedFeature = featureA.clone();
|
||||
mergedFeature.setGeometry(new PolygonGeom(result.coords));
|
||||
mergedFeature.setStyle(MERGE_STYLE);
|
||||
|
||||
// Dispatch beforemerge events
|
||||
const evtData = {
|
||||
type: 'beforemerge',
|
||||
original: [featureA, featureB],
|
||||
merged: mergedFeature,
|
||||
};
|
||||
this.dispatchEvent(evtData);
|
||||
sourceA.dispatchEvent({ ...evtData });
|
||||
if (sourceB !== sourceA) {
|
||||
sourceB.dispatchEvent({ ...evtData });
|
||||
}
|
||||
|
||||
// Replace originals with merged
|
||||
sourceA.removeFeature(featureA);
|
||||
sourceB.removeFeature(featureB);
|
||||
sourceA.addFeature(mergedFeature);
|
||||
|
||||
// Dispatch aftermerge events
|
||||
const afterEvt = {
|
||||
type: 'aftermerge',
|
||||
original: [featureA, featureB],
|
||||
merged: mergedFeature,
|
||||
};
|
||||
this.dispatchEvent(afterEvt);
|
||||
sourceA.dispatchEvent({ ...afterEvt });
|
||||
if (sourceB !== sourceA) {
|
||||
sourceB.dispatchEvent({ ...afterEvt });
|
||||
}
|
||||
|
||||
// If both features were parcels, fire mergedparcel so MapView can show the UPN chooser
|
||||
const isParcelA = featureA.get('_layerType') === 'parcel';
|
||||
const isParcelB = featureB.get('_layerType') === 'parcel';
|
||||
if (isParcelA && isParcelB) {
|
||||
this.dispatchEvent({
|
||||
type: 'mergedparcel',
|
||||
merged: mergedFeature,
|
||||
propsA: featureA.getProperties(),
|
||||
propsB: featureB.getProperties(),
|
||||
coordinate: this._edgeClickA,
|
||||
});
|
||||
showToast('Polygons merged — choose which identifier to keep.', 'success');
|
||||
} else {
|
||||
showToast('Polygons merged successfully.', 'success');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this._reset();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Highlight management */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Rebuild the permanent highlights for already-selected polygons.
|
||||
*/
|
||||
_rebuildHighlights() {
|
||||
// Remove previous non-hover highlights
|
||||
const toRemove = [];
|
||||
this._highlightSource.getFeatures().forEach((f) => {
|
||||
if (f.get('_permanent')) toRemove.push(f);
|
||||
});
|
||||
toRemove.forEach((f) => this._highlightSource.removeFeature(f));
|
||||
|
||||
if (this._featureA) {
|
||||
const cloneA = this._featureA.clone();
|
||||
cloneA.set('_highlightStyle', LABEL_A);
|
||||
cloneA.set('_permanent', true);
|
||||
this._highlightSource.addFeature(cloneA);
|
||||
}
|
||||
if (this._featureB) {
|
||||
const cloneB = this._featureB.clone();
|
||||
cloneB.set('_highlightStyle', LABEL_B);
|
||||
cloneB.set('_permanent', true);
|
||||
this._highlightSource.addFeature(cloneB);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reset */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_reset() {
|
||||
this._phase = 'select_a';
|
||||
this._featureA = null;
|
||||
this._sourceA = null;
|
||||
this._featureB = null;
|
||||
this._sourceB = null;
|
||||
this._edgeClickA = null;
|
||||
this._edgeClickB = null;
|
||||
this._highlightSource.clear();
|
||||
this._edgeSource.clear();
|
||||
|
||||
const map = this.getMap();
|
||||
if (map) {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
433
src/interactions/PolygonSplitInteraction.js
Normal file
@ -0,0 +1,433 @@
|
||||
/**
|
||||
* PolygonSplitInteraction
|
||||
*
|
||||
* A two-phase OpenLayers interaction for splitting polygons:
|
||||
* Phase 1 – SELECT: hover to highlight, click to select a polygon
|
||||
* Phase 2 – DRAW: draw a cutting line, double-click to finish
|
||||
*
|
||||
* After a successful split the original feature is removed and two new
|
||||
* coloured features are added. The interaction fires `beforesplit` and
|
||||
* `aftersplit` events compatible with ol-ext's UndoRedo.
|
||||
*/
|
||||
|
||||
import ol_interaction_Interaction from 'ol/interaction/Interaction';
|
||||
import ol_interaction_Draw from 'ol/interaction/Draw';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import Feature from 'ol/Feature';
|
||||
import { Style, Stroke, Fill, Circle as CircleStyle } from 'ol/style';
|
||||
import { LineString } from 'ol/geom';
|
||||
import { Polygon as PolygonGeom } from 'ol/geom';
|
||||
import { splitPolygonByLine } from '../geom/polygonSplit.js';
|
||||
import { showToast } from '../toast.js';
|
||||
|
||||
// Marker colours for the two split pieces
|
||||
const SPLIT_COLORS = [
|
||||
{ stroke: '#ef4444', fill: 'rgba(239,68,68,0.25)' }, // red
|
||||
{ stroke: '#3b82f6', fill: 'rgba(59,130,246,0.25)' }, // blue
|
||||
];
|
||||
|
||||
// Highlight style for the selected polygon (phase 1)
|
||||
const HIGHLIGHT_STYLE = new Style({
|
||||
stroke: new Stroke({ color: '#0ea5e9', width: 3 }),
|
||||
fill: new Fill({ color: 'rgba(14,165,233,0.15)' }),
|
||||
});
|
||||
|
||||
// Style for the cutting-line sketch (phase 2)
|
||||
const SKETCH_STYLE = new Style({
|
||||
stroke: new Stroke({ color: '#f43f5e', width: 2, lineDash: [8, 6] }),
|
||||
image: new CircleStyle({
|
||||
radius: 5,
|
||||
fill: new Fill({ color: '#f43f5e' }),
|
||||
stroke: new Stroke({ color: '#fff', width: 1.5 }),
|
||||
}),
|
||||
});
|
||||
|
||||
export class PolygonSplitInteraction extends ol_interaction_Interaction {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {VectorSource|VectorSource[]} [options.sources] Sources containing
|
||||
* polygons to split. If omitted the interaction searches all visible
|
||||
* vector layers on the map.
|
||||
* @param {number} [options.snapDistance=25] Pixel distance for hover highlight.
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
handleEvent: (e) => this._handleEvent(e),
|
||||
});
|
||||
|
||||
this.snapDistance_ = options.snapDistance || 25;
|
||||
this._sources = options.sources
|
||||
? (Array.isArray(options.sources) ? options.sources : [options.sources])
|
||||
: null;
|
||||
|
||||
// Phase: 'select' | 'draw' | 'pick'
|
||||
this._phase = 'select';
|
||||
this._selectedFeature = null;
|
||||
this._selectedSource = null;
|
||||
this._drawInteraction = null;
|
||||
this._splitFeatures = null; // the two pieces (for pick phase)
|
||||
|
||||
// Overlay layer for highlighting the polygon under the cursor / selected
|
||||
this._overlaySource = new VectorSource({ useSpatialIndex: false });
|
||||
this._overlayLayer = new VectorLayer({
|
||||
source: this._overlaySource,
|
||||
displayInLayerSwitcher: false,
|
||||
style: HIGHLIGHT_STYLE,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Map lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
setMap(map) {
|
||||
if (this.getMap()) {
|
||||
this.getMap().removeLayer(this._overlayLayer);
|
||||
this._removeDrawInteraction();
|
||||
}
|
||||
super.setMap(map);
|
||||
if (map) {
|
||||
this._overlayLayer.setMap(map);
|
||||
}
|
||||
}
|
||||
|
||||
setActive(active) {
|
||||
super.setActive(active);
|
||||
if (!active) {
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Source helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_getSources() {
|
||||
if (this._sources) return this._sources;
|
||||
if (!this.getMap()) return [];
|
||||
const sources = [];
|
||||
const collect = (layers) => {
|
||||
layers.forEach((layer) => {
|
||||
if (layer.getVisible()) {
|
||||
if (layer.getSource && layer.getSource() instanceof VectorSource) {
|
||||
sources.push(layer.getSource());
|
||||
} else if (layer.getLayers) {
|
||||
collect(layer.getLayers());
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
collect(this.getMap().getLayers());
|
||||
return sources;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Event router */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_handleEvent(e) {
|
||||
if (!this.getActive()) return true;
|
||||
|
||||
if (this._phase === 'select') {
|
||||
if (e.type === 'pointermove') return this._onSelectMove(e);
|
||||
if (e.type === 'singleclick') return this._onSelectClick(e);
|
||||
}
|
||||
// In 'draw' phase the Draw interaction handles events directly;
|
||||
// we only intercept Escape to cancel.
|
||||
if (this._phase === 'draw') {
|
||||
if (e.type === 'keydown' && e.originalEvent?.key === 'Escape') {
|
||||
this._cancelDraw();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// In 'pick' phase the user selects which split piece keeps the UPN
|
||||
if (this._phase === 'pick') {
|
||||
if (e.type === 'pointermove') return this._onPickMove(e);
|
||||
if (e.type === 'singleclick') return this._onPickClick(e);
|
||||
if (e.type === 'keydown' && e.originalEvent?.key === 'Escape') {
|
||||
this._reset();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 1: SELECT */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_onSelectMove(e) {
|
||||
const map = this.getMap();
|
||||
if (!map) return true;
|
||||
|
||||
this._overlaySource.clear();
|
||||
|
||||
const hit = this._closestPolygon(e);
|
||||
if (hit) {
|
||||
// Show highlight copy
|
||||
const clone = hit.feature.clone();
|
||||
this._overlaySource.addFeature(clone);
|
||||
map.getTargetElement().style.cursor = 'pointer';
|
||||
} else {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onSelectClick(e) {
|
||||
const hit = this._closestPolygon(e);
|
||||
if (!hit) return true;
|
||||
|
||||
this._selectedFeature = hit.feature;
|
||||
this._selectedSource = hit.source;
|
||||
|
||||
// Keep highlight visible during draw phase
|
||||
this._overlaySource.clear();
|
||||
const clone = hit.feature.clone();
|
||||
this._overlaySource.addFeature(clone);
|
||||
|
||||
this._startDrawPhase();
|
||||
return false; // consume the click
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest polygon feature within snap distance.
|
||||
*/
|
||||
_closestPolygon(e) {
|
||||
let best = null;
|
||||
let bestDist = this.snapDistance_ + 1;
|
||||
|
||||
for (const source of this._getSources()) {
|
||||
const feat = source.getClosestFeatureToCoordinate(e.coordinate);
|
||||
if (!feat) continue;
|
||||
const geom = feat.getGeometry();
|
||||
if (!geom) continue;
|
||||
const type = geom.getType();
|
||||
if (type !== 'Polygon' && type !== 'MultiPolygon') continue;
|
||||
|
||||
const closest = geom.getClosestPoint(e.coordinate);
|
||||
const line = new LineString([e.coordinate, closest]);
|
||||
const distPx = line.getLength() / e.frameState.viewState.resolution;
|
||||
|
||||
if (distPx < bestDist) {
|
||||
bestDist = distPx;
|
||||
best = { feature: feat, source, coord: closest };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 2: DRAW cutting line */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_startDrawPhase() {
|
||||
this._phase = 'draw';
|
||||
const map = this.getMap();
|
||||
if (!map) return;
|
||||
|
||||
map.getTargetElement().style.cursor = 'crosshair';
|
||||
|
||||
this._drawInteraction = new ol_interaction_Draw({
|
||||
type: 'LineString',
|
||||
style: SKETCH_STYLE,
|
||||
});
|
||||
|
||||
this._drawInteraction.on('drawend', (evt) => {
|
||||
const cuttingLine = evt.feature.getGeometry().getCoordinates();
|
||||
this._performSplit(cuttingLine);
|
||||
});
|
||||
|
||||
map.addInteraction(this._drawInteraction);
|
||||
}
|
||||
|
||||
_removeDrawInteraction() {
|
||||
if (this._drawInteraction && this.getMap()) {
|
||||
this.getMap().removeInteraction(this._drawInteraction);
|
||||
}
|
||||
this._drawInteraction = null;
|
||||
}
|
||||
|
||||
_cancelDraw() {
|
||||
this._removeDrawInteraction();
|
||||
this._reset();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Split logic */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_performSplit(cuttingLineCoords) {
|
||||
const feature = this._selectedFeature;
|
||||
const source = this._selectedSource;
|
||||
const geom = feature.getGeometry();
|
||||
|
||||
let polygonCoords;
|
||||
if (geom.getType() === 'Polygon') {
|
||||
polygonCoords = geom.getCoordinates();
|
||||
} else if (geom.getType() === 'MultiPolygon') {
|
||||
// For MultiPolygon, try to split each sub-polygon and use the
|
||||
// first one that produces a valid result.
|
||||
// For now, use the first polygon ring.
|
||||
polygonCoords = geom.getCoordinates()[0];
|
||||
}
|
||||
|
||||
const result = splitPolygonByLine(polygonCoords, cuttingLineCoords);
|
||||
|
||||
if (!result) {
|
||||
console.warn('[PolygonSplit] Split failed — line must cross the polygon boundary at exactly 2 points.');
|
||||
// Stay in draw phase so user can retry
|
||||
this._removeDrawInteraction();
|
||||
this._startDrawPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
const [coordsA, coordsB] = result;
|
||||
|
||||
// Create two new features from the split result
|
||||
const featureA = feature.clone();
|
||||
featureA.setGeometry(new PolygonGeom(coordsA));
|
||||
featureA.setStyle(new Style({
|
||||
stroke: new Stroke({ color: SPLIT_COLORS[0].stroke, width: 2.5 }),
|
||||
fill: new Fill({ color: SPLIT_COLORS[0].fill }),
|
||||
}));
|
||||
|
||||
const featureB = feature.clone();
|
||||
featureB.setGeometry(new PolygonGeom(coordsB));
|
||||
featureB.setStyle(new Style({
|
||||
stroke: new Stroke({ color: SPLIT_COLORS[1].stroke, width: 2.5 }),
|
||||
fill: new Fill({ color: SPLIT_COLORS[1].fill }),
|
||||
}));
|
||||
|
||||
// Dispatch beforesplit (compatible with ol-ext UndoRedo)
|
||||
const splitFeatures = [featureA, featureB];
|
||||
this.dispatchEvent({
|
||||
type: 'beforesplit',
|
||||
original: feature,
|
||||
features: splitFeatures,
|
||||
});
|
||||
source.dispatchEvent({
|
||||
type: 'beforesplit',
|
||||
original: feature,
|
||||
features: splitFeatures,
|
||||
});
|
||||
|
||||
// Replace the original feature
|
||||
source.removeFeature(feature);
|
||||
source.addFeature(featureA);
|
||||
source.addFeature(featureB);
|
||||
|
||||
// Dispatch aftersplit
|
||||
this.dispatchEvent({
|
||||
type: 'aftersplit',
|
||||
original: feature,
|
||||
features: splitFeatures,
|
||||
});
|
||||
source.dispatchEvent({
|
||||
type: 'aftersplit',
|
||||
original: feature,
|
||||
features: splitFeatures,
|
||||
});
|
||||
|
||||
// Clean up draw interaction
|
||||
this._removeDrawInteraction();
|
||||
|
||||
// If the original was a parcel, enter pick phase for UPN assignment
|
||||
const isParcel = feature.get('_layerType') === 'parcel';
|
||||
if (isParcel) {
|
||||
this._splitFeatures = splitFeatures;
|
||||
this._phase = 'pick';
|
||||
this._overlaySource.clear();
|
||||
const map = this.getMap();
|
||||
if (map) map.getTargetElement().style.cursor = '';
|
||||
showToast('Click the polygon that should keep the original identifier.', 'info', 5000);
|
||||
|
||||
this.dispatchEvent({
|
||||
type: 'splitparcel',
|
||||
features: splitFeatures,
|
||||
originalProps: feature.getProperties(),
|
||||
source,
|
||||
});
|
||||
} else {
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Phase 3: PICK — select which split piece keeps the UPN */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_onPickMove(e) {
|
||||
const map = this.getMap();
|
||||
if (!map) return true;
|
||||
|
||||
this._overlaySource.clear();
|
||||
|
||||
const hit = this._closestSplitPiece(e);
|
||||
if (hit) {
|
||||
const clone = hit.clone();
|
||||
this._overlaySource.addFeature(clone);
|
||||
map.getTargetElement().style.cursor = 'pointer';
|
||||
} else {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_onPickClick(e) {
|
||||
const hit = this._closestSplitPiece(e);
|
||||
if (!hit) return true;
|
||||
|
||||
this.dispatchEvent({
|
||||
type: 'splitpick',
|
||||
picked: hit,
|
||||
features: this._splitFeatures,
|
||||
});
|
||||
|
||||
this._reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest split piece to the cursor.
|
||||
*/
|
||||
_closestSplitPiece(e) {
|
||||
if (!this._splitFeatures) return null;
|
||||
let best = null;
|
||||
let bestDist = this.snapDistance_ + 1;
|
||||
|
||||
for (const feat of this._splitFeatures) {
|
||||
const geom = feat.getGeometry();
|
||||
if (!geom) continue;
|
||||
const closest = geom.getClosestPoint(e.coordinate);
|
||||
const line = new LineString([e.coordinate, closest]);
|
||||
const distPx = line.getLength() / e.frameState.viewState.resolution;
|
||||
if (distPx < bestDist) {
|
||||
bestDist = distPx;
|
||||
best = feat;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reset */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
_reset() {
|
||||
this._phase = 'select';
|
||||
this._selectedFeature = null;
|
||||
this._selectedSource = null;
|
||||
this._splitFeatures = null;
|
||||
this._overlaySource.clear();
|
||||
this._removeDrawInteraction();
|
||||
|
||||
const map = this.getMap();
|
||||
if (map) {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
293
src/offlineTiles.js
Normal file
@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Offline Tile Downloader
|
||||
*
|
||||
* Pre-fetches map tiles for a given extent and zoom range so they are stored
|
||||
* in the Service Worker's per-host tile cache for offline use.
|
||||
*
|
||||
* The downloader simply issues `fetch()` calls; the existing SW intercepts
|
||||
* them and routes to the right cache bucket. No direct Cache API access is
|
||||
* needed here — the SW is the single source of truth for storage.
|
||||
*
|
||||
* Throttling defaults are conservative to respect tile-server usage policies:
|
||||
* • 2 concurrent requests
|
||||
* • 50 ms inter-batch delay
|
||||
* • Standard browser User-Agent / Referer headers
|
||||
*
|
||||
* Usage:
|
||||
* const downloader = new OfflineTileDownloader({
|
||||
* baseMap: 'topo',
|
||||
* extent3857: [minX, minY, maxX, maxY], // EPSG:3857
|
||||
* minZoom: 10,
|
||||
* maxZoom: 15,
|
||||
* onProgress: (s) => console.log(s),
|
||||
* });
|
||||
* await downloader.start();
|
||||
* downloader.cancel(); // any time
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Base-map URL templates
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Tile URL templates for base maps that may be downloaded for offline use.
|
||||
*
|
||||
* The SW recognises these hosts in `getTileCacheName()` and routes them to
|
||||
* the matching `tiles-*-vN` cache. If you add a new entry here, also add
|
||||
* the host to the SW's classifier or the tiles will not be cached.
|
||||
*/
|
||||
export const BASEMAP_TEMPLATES = {
|
||||
topo: {
|
||||
url: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||
label: 'Topographic',
|
||||
maxZoom: 17,
|
||||
cacheKey: 'tiles-topo',
|
||||
},
|
||||
osm: {
|
||||
url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
label: 'OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
cacheKey: 'tiles-osm',
|
||||
},
|
||||
};
|
||||
|
||||
// Approximate bytes per raster tile — used for storage estimates.
|
||||
export const AVG_TILE_BYTES = 30 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Tile coordinate math (Web Mercator XYZ scheme)
|
||||
// ============================================================================
|
||||
|
||||
const ORIGIN_SHIFT = 2 * Math.PI * 6378137 / 2; // 20037508.342789244
|
||||
|
||||
/** Convert Web Mercator metres → (lon, lat) in degrees. */
|
||||
function metersToLonLat(x, y) {
|
||||
const lon = (x / ORIGIN_SHIFT) * 180;
|
||||
let lat = (y / ORIGIN_SHIFT) * 180;
|
||||
lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2);
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/** Tile (x, y) in XYZ scheme for a given lon/lat at zoom z. */
|
||||
function lonLatToTile(lon, lat, z) {
|
||||
const n = Math.pow(2, z);
|
||||
const x = Math.floor((lon + 180) / 360 * n);
|
||||
const latRad = lat * Math.PI / 180;
|
||||
const y = Math.floor(
|
||||
(1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n
|
||||
);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Tile range covering an EPSG:3857 extent at a given zoom level. */
|
||||
export function tileRangeForExtent(extent3857, z) {
|
||||
const [minX, minY, maxX, maxY] = extent3857;
|
||||
const [minLon, minLat] = metersToLonLat(minX, minY);
|
||||
const [maxLon, maxLat] = metersToLonLat(maxX, maxY);
|
||||
|
||||
const tl = lonLatToTile(minLon, maxLat, z); // top-left in XYZ (NW)
|
||||
const br = lonLatToTile(maxLon, minLat, z); // bottom-right (SE)
|
||||
|
||||
const n = Math.pow(2, z);
|
||||
const minTileX = Math.max(0, Math.min(tl.x, br.x));
|
||||
const maxTileX = Math.min(n - 1, Math.max(tl.x, br.x));
|
||||
const minTileY = Math.max(0, Math.min(tl.y, br.y));
|
||||
const maxTileY = Math.min(n - 1, Math.max(tl.y, br.y));
|
||||
|
||||
return {
|
||||
z,
|
||||
minX: minTileX, maxX: maxTileX,
|
||||
minY: minTileY, maxY: maxTileY,
|
||||
count: (maxTileX - minTileX + 1) * (maxTileY - minTileY + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** Total tile count for an extent across a zoom range (inclusive). */
|
||||
export function countTiles(extent3857, minZ, maxZ) {
|
||||
let total = 0;
|
||||
for (let z = minZ; z <= maxZ; z++) {
|
||||
total += tileRangeForExtent(extent3857, z).count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate every tile in an extent across a zoom range.
|
||||
* Returns an array of { z, x, y } objects. For very large ranges this can be
|
||||
* large — the caller is expected to validate the count first.
|
||||
*/
|
||||
export function enumerateTiles(extent3857, minZ, maxZ) {
|
||||
const out = [];
|
||||
for (let z = minZ; z <= maxZ; z++) {
|
||||
const r = tileRangeForExtent(extent3857, z);
|
||||
for (let x = r.minX; x <= r.maxX; x++) {
|
||||
for (let y = r.minY; y <= r.maxY; y++) {
|
||||
out.push({ z, x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tile URL for a given coordinate using a {z}/{x}/{y} template.
|
||||
*/
|
||||
export function formatTileUrl(template, { z, x, y }) {
|
||||
return template
|
||||
.replace('{z}', z)
|
||||
.replace('{x}', x)
|
||||
.replace('{y}', y);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OfflineTileDownloader
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Concurrent, throttled tile downloader. Issues `fetch()` per tile; the
|
||||
* service worker handles caching transparently.
|
||||
*
|
||||
* Events via `onProgress` callback:
|
||||
* { phase: 'running' | 'done' | 'cancelled' | 'error',
|
||||
* done, total, ok, failed, cached,
|
||||
* elapsedMs, etaMs }
|
||||
*/
|
||||
export class OfflineTileDownloader {
|
||||
constructor({
|
||||
baseMap, // 'topo' | 'osm'
|
||||
extent3857, // [minX, minY, maxX, maxY]
|
||||
minZoom,
|
||||
maxZoom,
|
||||
concurrency = 2, // OSM ToS-friendly default
|
||||
interBatchDelayMs = 50,
|
||||
onProgress = () => {},
|
||||
}) {
|
||||
const tpl = BASEMAP_TEMPLATES[baseMap];
|
||||
if (!tpl) throw new Error(`Unknown base map: ${baseMap}`);
|
||||
if (maxZoom > tpl.maxZoom) {
|
||||
console.warn(`[OfflineTiles] ${baseMap}: maxZoom ${maxZoom} > supported ${tpl.maxZoom}; clamping`);
|
||||
maxZoom = tpl.maxZoom;
|
||||
}
|
||||
|
||||
this.baseMap = baseMap;
|
||||
this.template = tpl.url;
|
||||
this.extent = extent3857;
|
||||
this.minZoom = minZoom;
|
||||
this.maxZoom = maxZoom;
|
||||
this.concurrency = Math.max(1, Math.min(concurrency, 6));
|
||||
this.interBatchDelayMs = interBatchDelayMs;
|
||||
this.onProgress = onProgress;
|
||||
|
||||
this._abortCtrl = null;
|
||||
this._cancelled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin downloading. Returns a Promise that resolves with the final stats
|
||||
* when complete, or when cancelled.
|
||||
*/
|
||||
async start() {
|
||||
if (this._abortCtrl) throw new Error('Downloader already started');
|
||||
this._abortCtrl = new AbortController();
|
||||
this._cancelled = false;
|
||||
|
||||
const tiles = enumerateTiles(this.extent, this.minZoom, this.maxZoom);
|
||||
const total = tiles.length;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let done = 0, ok = 0, failed = 0, cached = 0;
|
||||
|
||||
const emit = (phase) => {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const etaMs = done > 0 ? Math.round((elapsedMs / done) * (total - done)) : null;
|
||||
this.onProgress({ phase, done, total, ok, failed, cached, elapsedMs, etaMs });
|
||||
};
|
||||
|
||||
emit('running');
|
||||
|
||||
// Process in chunks of `concurrency`
|
||||
for (let i = 0; i < tiles.length; i += this.concurrency) {
|
||||
if (this._cancelled) break;
|
||||
|
||||
const batch = tiles.slice(i, i + this.concurrency);
|
||||
await Promise.all(batch.map(async (t) => {
|
||||
if (this._cancelled) return;
|
||||
const url = formatTileUrl(this.template, t);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: this._abortCtrl.signal,
|
||||
// Hint the SW that this is a passive prefetch
|
||||
cache: 'default',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
ok++;
|
||||
// Detect "served from SW cache" via headers — not reliable across
|
||||
// implementations, so we just count all 200s as ok. Reading the body
|
||||
// (or cancelling it) lets the browser GC the response promptly.
|
||||
if (res.body) res.body.cancel().catch(() => {});
|
||||
} else if (res.status === 408) {
|
||||
// Our SW returns 408 when offline AND nothing cached. Treat as failed.
|
||||
failed++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
// Cancellation — don't count
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
done++;
|
||||
}));
|
||||
|
||||
emit('running');
|
||||
|
||||
if (this.interBatchDelayMs > 0 && i + this.concurrency < tiles.length) {
|
||||
await new Promise((r) => setTimeout(r, this.interBatchDelayMs));
|
||||
}
|
||||
}
|
||||
|
||||
emit(this._cancelled ? 'cancelled' : 'done');
|
||||
|
||||
return {
|
||||
phase: this._cancelled ? 'cancelled' : 'done',
|
||||
done, total, ok, failed, cached,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight download. Resolves on the next batch boundary.
|
||||
*/
|
||||
cancel() {
|
||||
this._cancelled = true;
|
||||
if (this._abortCtrl) this._abortCtrl.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Predefined extents
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Whole-of-Ghana bounding box in EPSG:3857.
|
||||
* Approximate: -3.3°W → 1.2°E, 4.5°N → 11.2°N.
|
||||
*/
|
||||
export const GHANA_EXTENT_3857 = (() => {
|
||||
const lonLatToMeters = (lon, lat) => {
|
||||
const x = lon * ORIGIN_SHIFT / 180;
|
||||
const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180);
|
||||
return [x, y * ORIGIN_SHIFT / 180];
|
||||
};
|
||||
const sw = lonLatToMeters(-3.3, 4.5);
|
||||
const ne = lonLatToMeters(1.2, 11.2);
|
||||
return [sw[0], sw[1], ne[0], ne[1]];
|
||||
})();
|
||||
|
||||
// Useful for size estimates
|
||||
export function estimatedSizeBytes(tileCount) {
|
||||
return tileCount * AVG_TILE_BYTES;
|
||||
}
|
||||
157
src/pdf-export.js
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* PDF Export Module
|
||||
*
|
||||
* Generates branded PDF reports from analysis data.
|
||||
* Uses jspdf + jspdf-autotable, loaded on demand via dynamic import
|
||||
* from the calling code so the library is only fetched when needed.
|
||||
*
|
||||
* Usage:
|
||||
* import { exportAnalysisPDF } from '../pdf-export.js';
|
||||
* await exportAnalysisPDF({ title: 'Circle Analysis', rows: [...] });
|
||||
*/
|
||||
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { applyPlugin } from 'jspdf-autotable';
|
||||
|
||||
applyPlugin(jsPDF);
|
||||
|
||||
// Cached logo data URL — fetched once, reused for subsequent exports
|
||||
let _logoDataUrl = null;
|
||||
|
||||
/**
|
||||
* Load the LUSPA logo, draw it onto a canvas to flatten alpha and
|
||||
* produce a clean JPEG data URL that jsPDF can embed reliably.
|
||||
* Caches the result so subsequent calls are instant.
|
||||
*/
|
||||
async function getLogoDataUrl() {
|
||||
if (_logoDataUrl) return _logoDataUrl;
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = './icons/luspa-pdf.jpg';
|
||||
});
|
||||
|
||||
// Draw onto a canvas to get a reliable JPEG data URL
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
// White background to flatten any remaining alpha
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
_logoDataUrl = canvas.toDataURL('image/jpeg', 0.92);
|
||||
return _logoDataUrl;
|
||||
} catch (err) {
|
||||
console.warn('[PDF] Could not load logo:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and open a branded PDF report.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.title - Report title (e.g. "Circle Analysis")
|
||||
* @param {Array<{label: string, value: string}>} options.rows - Table data
|
||||
*/
|
||||
export async function exportAnalysisPDF({ title, rows }) {
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
|
||||
// Brand colours
|
||||
const navy = [30, 26, 75]; // #1e1a4b
|
||||
|
||||
// ---- Logo ----
|
||||
const logo = await getLogoDataUrl();
|
||||
const logoSize = 28;
|
||||
const marginLeft = 14;
|
||||
let cursorY = 14;
|
||||
|
||||
if (logo) {
|
||||
doc.addImage(logo, 'JPEG', marginLeft, cursorY, logoSize, logoSize);
|
||||
}
|
||||
|
||||
// ---- Header text (next to logo) ----
|
||||
const textX = marginLeft + logoSize + 6;
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(18);
|
||||
doc.setTextColor(...navy);
|
||||
doc.text('LUPMIS', textX, cursorY + 11);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(12);
|
||||
doc.text(title, textX, cursorY + 19);
|
||||
|
||||
// ---- Date / time ----
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'long', day: 'numeric',
|
||||
});
|
||||
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(120, 120, 120);
|
||||
doc.text(`${dateStr} ${timeStr}`, pageWidth - marginLeft, cursorY + 11, { align: 'right' });
|
||||
|
||||
// ---- Divider line ----
|
||||
cursorY += logoSize + 6;
|
||||
doc.setDrawColor(...navy);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(marginLeft, cursorY, pageWidth - marginLeft, cursorY);
|
||||
cursorY += 6;
|
||||
|
||||
// ---- Analysis table ----
|
||||
const tableBody = rows.map(r => [r.label, r.value]);
|
||||
|
||||
doc.autoTable({
|
||||
startY: cursorY,
|
||||
head: [['Property', 'Value']],
|
||||
body: tableBody,
|
||||
margin: { left: marginLeft, right: marginLeft },
|
||||
styles: {
|
||||
font: 'helvetica',
|
||||
fontSize: 10,
|
||||
cellPadding: 4,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: navy,
|
||||
textColor: [255, 255, 255],
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [245, 245, 250],
|
||||
},
|
||||
columnStyles: {
|
||||
0: { fontStyle: 'bold', cellWidth: 50 },
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Footer ----
|
||||
const finalY = doc.lastAutoTable.finalY + 10;
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(160, 160, 160);
|
||||
doc.text('Generated by LUPMIS2 Land Use Planning & Management Information System', marginLeft, finalY);
|
||||
|
||||
// ---- Open in browser ----
|
||||
const blob = doc.output('blob');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const win = window.open(url, '_blank');
|
||||
if (!win) {
|
||||
// Popup blocked (mobile) — fall back to download
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title.replace(/\s+/g, '_')}_${now.toISOString().slice(0, 10)}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
}
|
||||
159
src/pwa.js
@ -276,6 +276,158 @@ export function clearUserCaches() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Service Worker we can postMessage to. Resolves with:
|
||||
* • `navigator.serviceWorker.controller` if it's already in control of the
|
||||
* page (fastest path), or
|
||||
* • `registration.active` once `navigator.serviceWorker.ready` resolves
|
||||
* (covers the first-load case before the SW has claimed the page).
|
||||
*
|
||||
* Rejects after `timeoutMs` if no SW becomes available — which would only
|
||||
* happen in a private/incognito context, an unsupported browser, or when
|
||||
* registration genuinely failed.
|
||||
*
|
||||
* @param {{ timeoutMs?: number }} [opts]
|
||||
* @returns {Promise<ServiceWorker>}
|
||||
*/
|
||||
export async function getActiveServiceWorker({ timeoutMs = 10000 } = {}) {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service Workers not supported in this browser');
|
||||
}
|
||||
|
||||
// Fastest path — page is already SW-controlled
|
||||
if (navigator.serviceWorker.controller) {
|
||||
return navigator.serviceWorker.controller;
|
||||
}
|
||||
|
||||
// Otherwise wait for the registration to become ready (active SW exists
|
||||
// for this scope, even if it hasn't claimed THIS page yet)
|
||||
const ready = navigator.serviceWorker.ready;
|
||||
const timeout = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Service-worker readiness timeout')), timeoutMs)
|
||||
);
|
||||
|
||||
const registration = await Promise.race([ready, timeout]);
|
||||
|
||||
// The controller may have appeared while we were waiting; otherwise use
|
||||
// the registration's active worker (we can still postMessage to it — caches
|
||||
// are shared across the origin)
|
||||
const sw = navigator.serviceWorker.controller || registration.active;
|
||||
if (!sw) {
|
||||
throw new Error('No active service worker available');
|
||||
}
|
||||
return sw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to controller-change events. The callback fires whenever a new
|
||||
* Service Worker takes control of the page (e.g. after an SW update or first
|
||||
* activation on initial load). Useful for re-querying SW-backed state once
|
||||
* the SW has actually taken over.
|
||||
*
|
||||
* @param {() => void} callback
|
||||
* @returns {() => void} unsubscribe function
|
||||
*/
|
||||
export function onServiceWorkerControllerChange(callback) {
|
||||
if (!('serviceWorker' in navigator)) return () => {};
|
||||
const handler = () => {
|
||||
try { callback(); } catch (e) { console.error('[PWA] controllerchange handler error:', e); }
|
||||
};
|
||||
navigator.serviceWorker.addEventListener('controllerchange', handler);
|
||||
return () => navigator.serviceWorker.removeEventListener('controllerchange', handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the service worker and wait for a single reply with the
|
||||
* given response type. Waits for the SW to become available if it isn't yet.
|
||||
* Resolves with the reply payload, or rejects after a timeout.
|
||||
*
|
||||
* @template T
|
||||
* @param {string} requestType - Message type to send (e.g. 'GET_TILE_STATS')
|
||||
* @param {string} responseType - Message type expected back (e.g. 'TILE_STATS')
|
||||
* @param {Object} [extra={}] - Extra fields merged into the outgoing message
|
||||
* @param {number} [timeoutMs=5000] Reply timeout (after the SW is available)
|
||||
* @param {number} [readyTimeoutMs=10000] Timeout for the SW to be available
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
async function requestFromServiceWorker(requestType, responseType, extra = {}, timeoutMs = 5000, readyTimeoutMs = 10000) {
|
||||
const sw = await getActiveServiceWorker({ timeoutMs: readyTimeoutMs });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timer = setTimeout(() => {
|
||||
channel.port1.close();
|
||||
reject(new Error(`Service-worker reply "${responseType}" timed out`));
|
||||
}, timeoutMs);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data?.type === responseType) {
|
||||
clearTimeout(timer);
|
||||
channel.port1.close();
|
||||
const { type, ...rest } = event.data;
|
||||
resolve(rest);
|
||||
}
|
||||
};
|
||||
|
||||
sw.postMessage({ type: requestType, ...extra }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about tiles cached locally on this device, broken down by
|
||||
* provider. Waits up to `readyTimeoutMs` for the service worker to become
|
||||
* available. Returns null only if the SW genuinely cannot be reached
|
||||
* (private mode, registration failure, or timeout).
|
||||
*
|
||||
* @returns {Promise<{
|
||||
* totals: { count: number, estBytes: number },
|
||||
* byProvider: Array<{ key: string, label: string, count: number, limit: number, estBytes: number }>
|
||||
* } | null>}
|
||||
*/
|
||||
export async function getTileCacheStats() {
|
||||
try {
|
||||
const reply = await requestFromServiceWorker('GET_TILE_STATS', 'TILE_STATS');
|
||||
return reply.stats;
|
||||
} catch (err) {
|
||||
console.warn('[PWA] getTileCacheStats failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every cached tile from this device. Doesn't touch the app shell,
|
||||
* modules, or API caches — only the per-provider tile buckets.
|
||||
* Waits for the SW to be available before sending the request.
|
||||
*
|
||||
* @returns {Promise<boolean>} true if the request was acknowledged
|
||||
*/
|
||||
export async function clearTileCaches() {
|
||||
try {
|
||||
await requestFromServiceWorker('CLEAR_TILE_CACHES', 'TILE_CACHES_CLEARED');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[PWA] clearTileCaches failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total disk used by this origin (Cache API + IndexedDB + OPFS).
|
||||
* Returns null if the Storage API is not available.
|
||||
*
|
||||
* @returns {Promise<{ usage: number, quota: number } | null>}
|
||||
*/
|
||||
export async function getStorageEstimate() {
|
||||
if (!navigator.storage?.estimate) return null;
|
||||
try {
|
||||
const { usage, quota } = await navigator.storage.estimate();
|
||||
return { usage: usage || 0, quota: quota || 0 };
|
||||
} catch (err) {
|
||||
console.warn('[PWA] getStorageEstimate failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-initialization
|
||||
// ============================================================================
|
||||
@ -313,5 +465,10 @@ export default {
|
||||
applyUpdate,
|
||||
postToServiceWorker,
|
||||
cacheModules,
|
||||
clearUserCaches
|
||||
clearUserCaches,
|
||||
getTileCacheStats,
|
||||
clearTileCaches,
|
||||
getStorageEstimate,
|
||||
getActiveServiceWorker,
|
||||
onServiceWorkerControllerChange,
|
||||
};
|
||||
|
||||
133
src/remotedb.js
@ -21,13 +21,89 @@ const API_CREDENTIALS = {
|
||||
api_token: '1c46538c712e9b5b'
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Server Reachability
|
||||
// ============================================================================
|
||||
|
||||
/** Default timeout for API requests (ms) */
|
||||
const REQUEST_TIMEOUT = 30_000;
|
||||
|
||||
/** Timeout for the fast reachability probe (ms) */
|
||||
const PING_TIMEOUT = 5_000;
|
||||
|
||||
/** Cached result of the last reachability check */
|
||||
let _serverReachable = null;
|
||||
|
||||
/**
|
||||
* Quick probe to determine if the API server is responding.
|
||||
* Sends a small POST to a lightweight endpoint with a short timeout.
|
||||
* The result is cached so subsequent calls within the same page load
|
||||
* return immediately.
|
||||
*
|
||||
* @param {boolean} [force=false] - Re-check even if a cached result exists
|
||||
* @returns {Promise<boolean>} true if the server responded in time
|
||||
*/
|
||||
export async function checkServerReachable(force = false) {
|
||||
if (_serverReachable !== null && !force) return _serverReachable;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/get_layers.php`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(API_CREDENTIALS),
|
||||
signal: controller.signal,
|
||||
});
|
||||
_serverReachable = response.ok;
|
||||
} catch {
|
||||
_serverReachable = false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
console.log('[RemoteDB] Server reachable:', _serverReachable);
|
||||
return _serverReachable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached server-reachability flag (synchronous).
|
||||
* Returns null if checkServerReachable() has not been called yet.
|
||||
* @returns {boolean|null}
|
||||
*/
|
||||
export function isServerReachable() {
|
||||
return _serverReachable;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Request Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create an AbortController that auto-aborts after `ms` milliseconds.
|
||||
* If the caller already supplied a signal in `options`, it is combined
|
||||
* so that either the caller's abort or the timeout will cancel the request.
|
||||
*/
|
||||
function withTimeout(options, ms = REQUEST_TIMEOUT) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
|
||||
// If the caller provided their own signal, chain it
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener('abort', () => controller.abort());
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
clear: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a GET request to the remote API.
|
||||
* Credentials are sent as URL query parameters.
|
||||
* Automatically times out after REQUEST_TIMEOUT ms.
|
||||
*
|
||||
* @param {string} endpoint - API endpoint filename (e.g. 'get_district_boundary.php')
|
||||
* @param {Object} [params={}] - Additional query parameters
|
||||
@ -45,13 +121,15 @@ export async function remoteGet(endpoint, params = {}, options = {}) {
|
||||
|
||||
console.log('[RemoteDB] GET', url.toString());
|
||||
|
||||
const timeout = withTimeout(options);
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
...options
|
||||
...options,
|
||||
signal: timeout.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -63,14 +141,21 @@ export async function remoteGet(endpoint, params = {}, options = {}) {
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.error('[RemoteDB] GET timed out:', endpoint);
|
||||
throw new Error(`Request timed out: ${endpoint}`);
|
||||
}
|
||||
console.error('[RemoteDB] GET failed:', endpoint, error);
|
||||
throw error;
|
||||
} finally {
|
||||
timeout.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a POST request to the remote API.
|
||||
* Credentials are included in the JSON body.
|
||||
* Automatically times out after REQUEST_TIMEOUT ms.
|
||||
*
|
||||
* @param {string} endpoint - API endpoint filename (e.g. 'some_endpoint.php')
|
||||
* @param {Object} [body={}] - Request payload (credentials are merged in)
|
||||
@ -84,6 +169,7 @@ export async function remotePost(endpoint, body = {}, options = {}) {
|
||||
|
||||
console.log('[RemoteDB] POST', url);
|
||||
|
||||
const timeout = withTimeout(options);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
@ -92,7 +178,8 @@ export async function remotePost(endpoint, body = {}, options = {}) {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
...options
|
||||
...options,
|
||||
signal: timeout.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -104,8 +191,14 @@ export async function remotePost(endpoint, body = {}, options = {}) {
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.error('[RemoteDB] POST timed out:', endpoint);
|
||||
throw new Error(`Request timed out: ${endpoint}`);
|
||||
}
|
||||
console.error('[RemoteDB] POST failed:', endpoint, error);
|
||||
throw error;
|
||||
} finally {
|
||||
timeout.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,16 +263,50 @@ export async function getBuildingFootprints() {
|
||||
return remotePost('get_all_footprint_per_district.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the Contours hillshade elevation layer from the server.
|
||||
*
|
||||
* Source: table `contours_hillshade` in the local PostgreSQL `public` schema
|
||||
* (imported from OpenTopography's viz.hh_hillshade).
|
||||
*
|
||||
* Expected response:
|
||||
* { success: true, data: [{ id, elevation, geom: "LINESTRING(...)" | "MULTILINESTRING(...)" | "POLYGON(...)", ... }, ...] }
|
||||
*
|
||||
* @returns {Promise<Object>} Contours hillshade list
|
||||
*/
|
||||
export async function getContoursHillshade() {
|
||||
return remotePost('get_contours_hillshade.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the OSM roads layer from the server.
|
||||
*
|
||||
* Source: table `pi_osm_roads` in the local PostgreSQL `public` schema
|
||||
* (imported from OpenStreetMap road network for the district).
|
||||
*
|
||||
* Expected response:
|
||||
* { success: true, data: [{ id, ..., geom: "LINESTRING(...)" | "MULTILINESTRING(...)", ... }, ...] }
|
||||
*
|
||||
* @returns {Promise<Object>} OSM roads list
|
||||
*/
|
||||
export async function getOSMRoads() {
|
||||
return remotePost('get_osm_roads.php');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exports
|
||||
// ============================================================================
|
||||
|
||||
export default {
|
||||
checkServerReachable,
|
||||
isServerReachable,
|
||||
remoteGet,
|
||||
remotePost,
|
||||
getDistrictBoundary,
|
||||
getLayers,
|
||||
getDistrictParcels,
|
||||
getCollectorZones,
|
||||
getBuildingFootprints
|
||||
getBuildingFootprints,
|
||||
getContoursHillshade,
|
||||
getOSMRoads,
|
||||
};
|
||||
|
||||
98
src/toast.js
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Lightweight toast notification system.
|
||||
*
|
||||
* Usage:
|
||||
* import { showToast } from '../toast.js';
|
||||
*
|
||||
* showToast('Something went wrong', 'error');
|
||||
* showToast('Merge successful!', 'success');
|
||||
* showToast('Select two adjacent polygons', 'info');
|
||||
*/
|
||||
|
||||
// ── Palette ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const THEMES = {
|
||||
success: { bg: '#10b981', icon: '\u2705' }, // green
|
||||
error: { bg: '#ef4444', icon: '\u274c' }, // red
|
||||
warning: { bg: '#f59e0b', icon: '\u26a0\ufe0f' }, // amber
|
||||
info: { bg: '#0ea5e9', icon: '\u2139\ufe0f' }, // cyan
|
||||
};
|
||||
|
||||
// ── Container (created once, appended to <body>) ────────────────────────────
|
||||
|
||||
let container = null;
|
||||
|
||||
function ensureContainer() {
|
||||
if (container) return container;
|
||||
container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Display a toast notification.
|
||||
*
|
||||
* @param {string} message Plain-text message to show.
|
||||
* @param {'success'|'error'|'warning'|'info'} [type='info']
|
||||
* @param {number} [duration=4000] Auto-dismiss time in ms.
|
||||
*/
|
||||
export function showToast(message, type = 'info', duration = 4000) {
|
||||
const parent = ensureContainer();
|
||||
const theme = THEMES[type] || THEMES.info;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `
|
||||
background: ${theme.bg};
|
||||
color: #fff;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-body, 'Exo', sans-serif);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
transform: translateY(-8px);
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
el.textContent = `${theme.icon} ${message}`;
|
||||
|
||||
parent.appendChild(el);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = '1';
|
||||
el.style.transform = 'translateY(0)';
|
||||
});
|
||||
|
||||
// Dismiss helper
|
||||
const dismiss = () => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(-8px)';
|
||||
setTimeout(() => el.remove(), 300);
|
||||
};
|
||||
|
||||
// Click to dismiss early
|
||||
el.addEventListener('click', dismiss);
|
||||
|
||||
// Auto-dismiss
|
||||
setTimeout(dismiss, duration);
|
||||
}
|
||||
123
src/units.js
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Measurement unit formatting — Metric / Imperial.
|
||||
*
|
||||
* The active system is persisted in localStorage('measurement-system').
|
||||
* Every formatter reads the current setting so the UI updates immediately
|
||||
* after the user flips the toggle.
|
||||
*
|
||||
* All input values are in metres (length) or square metres (area).
|
||||
*/
|
||||
|
||||
// ── Conversion constants ────────────────────────────────────────────────────
|
||||
const M_TO_FT = 3.28084;
|
||||
const M_TO_MI = 0.000621371;
|
||||
const SQM_TO_SQFT = 10.7639;
|
||||
const SQM_TO_ACRE = 0.000247105;
|
||||
const SQM_TO_SQMI = 3.861e-7;
|
||||
|
||||
// ── System accessor ─────────────────────────────────────────────────────────
|
||||
|
||||
/** @returns {'metric'|'imperial'} */
|
||||
export function getSystem() {
|
||||
return localStorage.getItem('measurement-system') || 'metric';
|
||||
}
|
||||
|
||||
// ── Length / distance ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a length value (in metres) for display.
|
||||
* Metric: m / km
|
||||
* Imperial: ft / mi
|
||||
*/
|
||||
export function formatLength(metres) {
|
||||
if (getSystem() === 'imperial') {
|
||||
const ft = metres * M_TO_FT;
|
||||
if (ft >= 5280) {
|
||||
return (Math.round(metres * M_TO_MI * 100) / 100) + ' mi';
|
||||
}
|
||||
return Math.round(ft) + ' ft';
|
||||
}
|
||||
// metric
|
||||
if (metres > 1000) {
|
||||
return (Math.round(metres / 1000 * 100) / 100) + ' km';
|
||||
}
|
||||
return (Math.round(metres * 100) / 100) + ' m';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a length with both large and small units (for info popups).
|
||||
* Metric: "1.23 km (1,230 m)" or "456 m"
|
||||
* Imperial: "1.23 mi (6,494 ft)" or "456 ft"
|
||||
*/
|
||||
export function formatLengthFull(metres) {
|
||||
if (getSystem() === 'imperial') {
|
||||
const ft = metres * M_TO_FT;
|
||||
const mi = metres * M_TO_MI;
|
||||
if (ft >= 5280) {
|
||||
return `${mi.toFixed(2)} mi (${ft.toLocaleString('en', { maximumFractionDigits: 0 })} ft)`;
|
||||
}
|
||||
return `${ft.toLocaleString('en', { maximumFractionDigits: 1 })} ft`;
|
||||
}
|
||||
if (metres >= 1000) {
|
||||
return `${(metres / 1000).toFixed(2)} km (${metres.toLocaleString('en', { maximumFractionDigits: 0 })} m)`;
|
||||
}
|
||||
return `${metres.toLocaleString('en', { maximumFractionDigits: 1 })} m`;
|
||||
}
|
||||
|
||||
// ── Area ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format an area value (in square metres) for display.
|
||||
* Metric: m² / km²
|
||||
* Imperial: ft² / acres / mi²
|
||||
*/
|
||||
export function formatArea(sqMetres) {
|
||||
if (getSystem() === 'imperial') {
|
||||
const acres = sqMetres * SQM_TO_ACRE;
|
||||
if (acres >= 640) {
|
||||
return (Math.round(sqMetres * SQM_TO_SQMI * 100) / 100) + ' mi²';
|
||||
}
|
||||
if (acres >= 1) {
|
||||
return (Math.round(acres * 100) / 100) + ' acres';
|
||||
}
|
||||
return Math.round(sqMetres * SQM_TO_SQFT).toLocaleString('en') + ' ft²';
|
||||
}
|
||||
// metric
|
||||
if (sqMetres > 1000000) {
|
||||
return (Math.round(sqMetres / 1000000 * 100) / 100) + ' km²';
|
||||
}
|
||||
return (Math.round(sqMetres * 100) / 100) + ' m²';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an area with both large and small units (for info popups).
|
||||
* Metric: "1.23 km² (1,230,000 m²)" or "456 m²"
|
||||
* Imperial: "1.23 mi² (787 acres)" or "2.5 acres" or "456 ft²"
|
||||
*/
|
||||
export function formatAreaFull(sqMetres) {
|
||||
if (getSystem() === 'imperial') {
|
||||
const sqft = sqMetres * SQM_TO_SQFT;
|
||||
const acres = sqMetres * SQM_TO_ACRE;
|
||||
const sqmi = sqMetres * SQM_TO_SQMI;
|
||||
if (acres >= 640) {
|
||||
return `${sqmi.toFixed(2)} mi² (${acres.toLocaleString('en', { maximumFractionDigits: 0 })} acres)`;
|
||||
}
|
||||
if (acres >= 1) {
|
||||
return `${acres.toLocaleString('en', { maximumFractionDigits: 1 })} acres (${sqft.toLocaleString('en', { maximumFractionDigits: 0 })} ft²)`;
|
||||
}
|
||||
return `${sqft.toLocaleString('en', { maximumFractionDigits: 0 })} ft²`;
|
||||
}
|
||||
if (sqMetres > 1000000) {
|
||||
return `${(sqMetres / 1000000).toFixed(2)} km² (${sqMetres.toLocaleString('en', { maximumFractionDigits: 0 })} m²)`;
|
||||
}
|
||||
return `${sqMetres.toLocaleString('en', { maximumFractionDigits: 0 })} m²`;
|
||||
}
|
||||
|
||||
// ── Circle helper ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format the area of a circle given its radius (in metres).
|
||||
*/
|
||||
export function formatCircleExtent(radiusMetres) {
|
||||
return formatArea(Math.PI * radiusMetres * radiusMetres);
|
||||
}
|
||||
@ -34,12 +34,25 @@ export default defineConfig({
|
||||
// Target modern browsers that support OPFS
|
||||
target: 'esnext',
|
||||
|
||||
// Raise the chunk-size warning threshold.
|
||||
// Rationale: the two largest chunks are unavoidable —
|
||||
// • openlayers (~535 kB / ~152 kB gzipped) — the OL library itself,
|
||||
// used app-wide; further splitting just creates more HTTP round-trips
|
||||
// • sqlite3.wasm (~856 kB) — a runtime WASM binary, not a JS chunk;
|
||||
// code-splitting does not apply
|
||||
// 900 kB silences the noise without hiding genuine regressions.
|
||||
chunkSizeWarningLimit: 900,
|
||||
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
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';
|
||||
// shpjs (+ its jszip dependency) — dynamically imported at runtime,
|
||||
// so this chunk is only fetched when the user imports a shapefile.
|
||||
if (id.includes('node_modules/shpjs/') || id.includes('node_modules/jszip/')) return 'shpjs';
|
||||
if (id.includes('node_modules/jspdf')) return 'jspdf';
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||