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">
|
<html lang="en" data-bs-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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="theme-color" content="#005eb8">
|
||||||
<meta name="description" content="LUPMIS2 Drawing Tools">
|
<meta name="description" content="LUPMIS2 Drawing Tools">
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="apple-touch-icon" href="/icons/luspa.icon">
|
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
|
||||||
<link rel="icon" href="/icons/luspa.icon">
|
<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) -->
|
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<style>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
/* Bebas Neue 400 — latin-ext */
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Exo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
@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>
|
<title>LUPMIS2 Drawing Tools</title>
|
||||||
|
|
||||||
@ -106,6 +151,295 @@
|
|||||||
--radius-2xl: 1rem;
|
--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 */
|
/* Full height layout */
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -214,9 +548,12 @@
|
|||||||
font-family: var(--font-body);
|
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 {
|
.app-container {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@ -244,6 +581,35 @@
|
|||||||
height: 100%;
|
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 */
|
||||||
#offline-indicator {
|
#offline-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
@ -301,9 +667,14 @@
|
|||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix ol-ext LayerSwitcher z-index */
|
/* OL controls stacking context fix — OpenLayers sets z-index:0 on
|
||||||
.ol-layerswitcher {
|
.ol-overlaycontainer-stopevent, trapping all controls below the
|
||||||
z-index: 100;
|
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 */
|
/* Alert hint box */
|
||||||
@ -419,7 +790,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.offcanvas-toggle-bottom {
|
.offcanvas-toggle-bottom {
|
||||||
bottom: 80px; /* Above the dock */
|
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); /* Above the dock */
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
@ -432,7 +803,10 @@
|
|||||||
transform: translateX(-50%) scale(0.95);
|
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 {
|
.bottom-dock {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@ -441,7 +815,7 @@
|
|||||||
z-index: 600;
|
z-index: 600;
|
||||||
background-color: var(--card);
|
background-color: var(--card);
|
||||||
border-top: 3px solid var(--primary);
|
border-top: 3px solid var(--primary);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -503,6 +877,13 @@
|
|||||||
justify-content: center;
|
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 */
|
/* Touch-friendly improvements for forms and buttons */
|
||||||
.form-control, .form-select {
|
.form-control, .form-select {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@ -541,6 +922,18 @@
|
|||||||
color: var(--foreground);
|
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-ext GeolocationButton styling */
|
||||||
.ol-geobt {
|
.ol-geobt {
|
||||||
top: auto !important;
|
top: auto !important;
|
||||||
@ -830,6 +1223,7 @@
|
|||||||
/* Locations list in offcanvas - can be taller now without form */
|
/* Locations list in offcanvas - can be taller now without form */
|
||||||
.offcanvas-end .locations-list {
|
.offcanvas-end .locations-list {
|
||||||
max-height: calc(100vh - 280px);
|
max-height: calc(100vh - 280px);
|
||||||
|
max-height: calc(100dvh - 280px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -921,17 +1315,32 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ScaleLine - position above the bottom dock */
|
/* ScaleBar - position above the bottom dock with 4px gap */
|
||||||
.ol-scale-line {
|
.ol-scale-bar {
|
||||||
bottom: 76px !important;
|
bottom: calc(85px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
left: 10px !important;
|
left: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-scale-line-inner {
|
.ol-scale-bar .ol-scale-step-text {
|
||||||
border-color: var(--foreground) !important;
|
|
||||||
color: var(--foreground) !important;
|
color: var(--foreground) !important;
|
||||||
font-family: var(--font-body) !important;
|
font-family: var(--font-body) !important;
|
||||||
font-size: 11px !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 */
|
/* ol-ext Bar overrides */
|
||||||
@ -950,12 +1359,12 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
</style>
|
</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/bootstrap-D1-uvFxm.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-D2I-bVN2.js">
|
<link rel="modulepreload" crossorigin href="/assets/ol-ext-CSk2UikI.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-DytxBANR.js">
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
|
<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/ol-ext-BgKrOIxx.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BnwqsTiD.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BnwqsTiD.css">
|
||||||
</head>
|
</head>
|
||||||
@ -988,6 +1397,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="map"></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 -->
|
<!-- Offcanvas toggle buttons -->
|
||||||
<button class="offcanvas-toggle offcanvas-toggle-left"
|
<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">
|
<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
|
<i class="bi bi-database me-2"></i>Local Data
|
||||||
</button>
|
</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 id="local-data-stats" class="d-none">
|
||||||
<div class="card">
|
<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>
|
<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>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<table class="table table-sm table-striped mb-0">
|
<table class="table table-sm table-striped mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-3">Table</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="local-data-tbody">
|
<tbody id="local-data-tbody">
|
||||||
@ -1150,6 +1583,10 @@
|
|||||||
<span class="message-text"></span>
|
<span class="message-text"></span>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</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 -->
|
<!-- Tip -->
|
||||||
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
|
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
|
||||||
@ -1189,20 +1626,268 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Offcanvas -->
|
<!-- Bottom Offcanvas -->
|
||||||
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
|
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
|
||||||
<div class="offcanvas-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
<p>This is the bottom offcanvas panel.</p>
|
<div class="row g-3">
|
||||||
<p>You can add a data table, charts, or other wide content here.</p>
|
<!-- 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>
|
||||||
|
</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) -->
|
<!-- Polyfill crypto.randomUUID for non-secure contexts (HTTP) -->
|
||||||
<!-- Must run before module imports (SQLocal/coincident require it) -->
|
<!-- Must run before module imports (SQLocal/coincident require it) -->
|
||||||
|
|||||||
22
dist/manifest.json
vendored
@ -1,58 +1,58 @@
|
|||||||
{
|
{
|
||||||
"name": "LUPMIS2 Drawing Tools",
|
"name": "LUPMIS2 Drawing Tools",
|
||||||
"short_name": "LUPMIS",
|
"short_name": "LUPMIS2",
|
||||||
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
||||||
"start_url": "/",
|
"start_url": "./",
|
||||||
"scope": "/",
|
"scope": "./",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#005eb8",
|
"theme_color": "#005eb8",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-72.png",
|
"src": "./icons/luspa-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-96.png",
|
"src": "./icons/luspa-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-128.png",
|
"src": "./icons/luspa-128x128.png",
|
||||||
"sizes": "128x128",
|
"sizes": "128x128",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-144.png",
|
"src": "./icons/luspa-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-152.png",
|
"src": "./icons/luspa-152x152.png",
|
||||||
"sizes": "152x152",
|
"sizes": "152x152",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/luspa-192x192.png",
|
"src": "./icons/luspa-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-384.png",
|
"src": "./icons/luspa-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/luspa-512x512.png",
|
"src": "./icons/luspa-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
|
|||||||
321
dist/sw.js
vendored
@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Handles caching of:
|
* Handles caching of:
|
||||||
* - App shell (HTML, CSS, JS)
|
* - App shell (HTML, CSS, JS)
|
||||||
* - Map tiles (runtime caching)
|
* - Map tiles (passive runtime caching, per-host buckets)
|
||||||
* - API responses (network-first)
|
* - API responses (network-first)
|
||||||
*
|
*
|
||||||
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
|
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
|
||||||
@ -12,18 +12,70 @@
|
|||||||
* - SharedWorker: Shared database connection across tabs
|
* - SharedWorker: Shared database connection across tabs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = 'v1';
|
// v3: lower per-cache limits (5000 → 1500) and counter-based eviction to
|
||||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
// prevent Safari memory-pressure reloads.
|
||||||
const TILES_CACHE = `tiles-${CACHE_VERSION}`;
|
// 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 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
|
// Per-provider tile limits.
|
||||||
// Vite will generate hashed filenames, so we cache the entry points
|
// • OSM and Topographic are the providers offered for active offline
|
||||||
// and let the browser handle the hashed assets
|
// 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 = [
|
const SHELL_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@ -58,14 +110,22 @@ self.addEventListener('activate', (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys()
|
caches.keys()
|
||||||
.then((cacheNames) => {
|
.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(
|
return Promise.all(
|
||||||
cacheNames
|
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) => {
|
.filter((name) => {
|
||||||
// Delete old version caches
|
const isOurs =
|
||||||
return (name.startsWith('shell-') && name !== SHELL_CACHE) ||
|
name.startsWith('shell-') ||
|
||||||
(name.startsWith('tiles-') && name !== TILES_CACHE) ||
|
name.startsWith('tiles-') ||
|
||||||
(name.startsWith('modules-') && name !== MODULES_CACHE) ||
|
name.startsWith('modules-') ||
|
||||||
(name.startsWith('api-') && name !== API_CACHE);
|
name.startsWith('api-');
|
||||||
|
return isOurs && !keep.has(name);
|
||||||
})
|
})
|
||||||
.map((name) => {
|
.map((name) => {
|
||||||
console.log('[SW] Deleting old cache:', name);
|
console.log('[SW] Deleting old cache:', name);
|
||||||
@ -91,10 +151,21 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Skip chrome-extension and other non-http(s) requests
|
// Skip chrome-extension and other non-http(s) requests
|
||||||
if (!url.protocol.startsWith('http')) return;
|
if (!url.protocol.startsWith('http')) return;
|
||||||
|
|
||||||
// Route to appropriate caching strategy
|
// Skip worker files and Vite dev-server node_modules requests —
|
||||||
if (isMapTile(url)) {
|
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
|
||||||
event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES));
|
if (url.pathname.includes('node_modules') ||
|
||||||
} else if (isApiRequest(url)) {
|
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));
|
event.respondWith(networkFirst(request, API_CACHE));
|
||||||
} else if (isModuleAsset(url)) {
|
} else if (isModuleAsset(url)) {
|
||||||
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
||||||
@ -108,15 +179,36 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// URL CLASSIFICATION
|
// URL CLASSIFICATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function isMapTile(url) {
|
/**
|
||||||
// Common tile server patterns for all our base maps
|
* Classify a URL into the appropriate tile cache.
|
||||||
return url.hostname.includes('tile.openstreetmap.org') ||
|
* Returns `null` for non-tile requests, or for tile providers we deliberately
|
||||||
url.hostname.includes('opentopomap.org') ||
|
* do NOT cache (e.g. Google — caching is forbidden by their ToS).
|
||||||
url.hostname.includes('arcgisonline.com') ||
|
*/
|
||||||
url.hostname.includes('basemaps.cartocdn.com') ||
|
function getTileCacheName(url) {
|
||||||
url.hostname.includes('tiles.') ||
|
const host = url.hostname;
|
||||||
url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) ||
|
|
||||||
url.pathname.match(/\/tile\/\d+\/\d+\/\d+/);
|
// 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) {
|
function isApiRequest(url) {
|
||||||
@ -129,7 +221,6 @@ function isModuleAsset(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAppAsset(url) {
|
function isAppAsset(url) {
|
||||||
// Same origin, common asset extensions
|
|
||||||
return url.origin === self.location.origin &&
|
return url.origin === self.location.origin &&
|
||||||
(url.pathname.endsWith('.html') ||
|
(url.pathname.endsWith('.html') ||
|
||||||
url.pathname.endsWith('.css') ||
|
url.pathname.endsWith('.css') ||
|
||||||
@ -144,8 +235,8 @@ function isAppAsset(url) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache First - Use cache, fallback to network
|
* Cache First — Use cache, fallback to network.
|
||||||
* Best for: App shell, static assets
|
* Best for: App shell, static assets.
|
||||||
*/
|
*/
|
||||||
async function cacheFirst(request, cacheName) {
|
async function cacheFirst(request, cacheName) {
|
||||||
const cached = await caches.match(request);
|
const cached = await caches.match(request);
|
||||||
@ -159,7 +250,6 @@ async function cacheFirst(request, cacheName) {
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Return offline page for navigation requests
|
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
return caches.match('/offline.html');
|
return caches.match('/offline.html');
|
||||||
}
|
}
|
||||||
@ -168,8 +258,8 @@ async function cacheFirst(request, cacheName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Network First - Try network, fallback to cache
|
* Network First — Try network, fallback to cache.
|
||||||
* Best for: API requests, dynamic content
|
* Best for: API requests, dynamic content.
|
||||||
*/
|
*/
|
||||||
async function networkFirst(request, cacheName) {
|
async function networkFirst(request, cacheName) {
|
||||||
try {
|
try {
|
||||||
@ -187,8 +277,8 @@ async function networkFirst(request, cacheName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stale While Revalidate - Return cache immediately, update in background
|
* Stale While Revalidate — Return cache immediately, update in background.
|
||||||
* Best for: Module assets, frequently updated content
|
* Best for: Module assets, frequently updated content.
|
||||||
*/
|
*/
|
||||||
async function staleWhileRevalidate(request, cacheName) {
|
async function staleWhileRevalidate(request, cacheName) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
@ -205,37 +295,72 @@ async function staleWhileRevalidate(request, cacheName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache Then Network with limit - Cache tiles with size limit
|
* Tile Cache then Network — Per-host bucket with size limit.
|
||||||
* Best for: Map tiles
|
* 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 cache = await caches.open(cacheName);
|
||||||
const cached = await cache.match(request);
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Check cache size and trim if needed
|
// Bump the counter; periodically run a real eviction sweep
|
||||||
const keys = await cache.keys();
|
const count = (_tileInsertCounters.get(cacheName) || 0) + 1;
|
||||||
if (keys.length >= maxItems) {
|
_tileInsertCounters.set(cacheName, count);
|
||||||
// Remove oldest entries (first 10%)
|
|
||||||
const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1));
|
if (count % EVICTION_CHECK_INTERVAL === 0) {
|
||||||
await Promise.all(toDelete.map(key => cache.delete(key)));
|
// 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;
|
return response;
|
||||||
} catch (error) {
|
} 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' });
|
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
|
// MESSAGE HANDLING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -257,15 +382,32 @@ self.addEventListener('message', (event) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'GET_CACHE_STATUS':
|
case 'GET_CACHE_STATUS':
|
||||||
getCacheStatus().then(status => {
|
getCacheStatus().then((status) => {
|
||||||
event.source.postMessage({ type: 'CACHE_STATUS', status });
|
event.source.postMessage({ type: 'CACHE_STATUS', status });
|
||||||
});
|
});
|
||||||
break;
|
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) {
|
async function cacheModules(moduleNames) {
|
||||||
const cache = await caches.open(MODULES_CACHE);
|
const cache = await caches.open(MODULES_CACHE);
|
||||||
@ -279,7 +421,6 @@ async function cacheModules(moduleNames) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
await cache.addAll(moduleAssets.filter(async (url) => {
|
await cache.addAll(moduleAssets.filter(async (url) => {
|
||||||
// Only cache assets that exist
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
const response = await fetch(url, { method: 'HEAD' });
|
||||||
return response.ok;
|
return response.ok;
|
||||||
@ -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() {
|
async function clearUserCaches() {
|
||||||
await caches.delete(API_CACHE);
|
await caches.delete(API_CACHE);
|
||||||
@ -305,7 +447,7 @@ async function clearUserCaches() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cache status information
|
* Get summary status of all caches (count of entries in each).
|
||||||
*/
|
*/
|
||||||
async function getCacheStatus() {
|
async function getCacheStatus() {
|
||||||
const cacheNames = await caches.keys();
|
const cacheNames = await caches.keys();
|
||||||
@ -319,3 +461,76 @@ async function getCacheStatus() {
|
|||||||
|
|
||||||
return status;
|
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">
|
<html lang="en" data-bs-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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="theme-color" content="#005eb8">
|
||||||
<meta name="description" content="LUPMIS2 Drawing Tools">
|
<meta name="description" content="LUPMIS2 Drawing Tools">
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="apple-touch-icon" href="/icons/luspa.icon">
|
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
|
||||||
<link rel="icon" href="/icons/luspa.icon">
|
<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) -->
|
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<style>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
/* Bebas Neue 400 — latin-ext */
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Exo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
@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>
|
<title>LUPMIS2 Drawing Tools</title>
|
||||||
|
|
||||||
@ -106,6 +151,295 @@
|
|||||||
--radius-2xl: 1rem;
|
--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 */
|
/* Full height layout */
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -214,9 +548,12 @@
|
|||||||
font-family: var(--font-body);
|
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 {
|
.app-container {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@ -244,6 +581,35 @@
|
|||||||
height: 100%;
|
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 */
|
||||||
#offline-indicator {
|
#offline-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
@ -301,9 +667,14 @@
|
|||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix ol-ext LayerSwitcher z-index */
|
/* OL controls stacking context fix — OpenLayers sets z-index:0 on
|
||||||
.ol-layerswitcher {
|
.ol-overlaycontainer-stopevent, trapping all controls below the
|
||||||
z-index: 100;
|
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 */
|
/* Alert hint box */
|
||||||
@ -419,7 +790,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.offcanvas-toggle-bottom {
|
.offcanvas-toggle-bottom {
|
||||||
bottom: 80px; /* Above the dock */
|
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); /* Above the dock */
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
@ -432,7 +803,10 @@
|
|||||||
transform: translateX(-50%) scale(0.95);
|
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 {
|
.bottom-dock {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@ -441,7 +815,7 @@
|
|||||||
z-index: 600;
|
z-index: 600;
|
||||||
background-color: var(--card);
|
background-color: var(--card);
|
||||||
border-top: 3px solid var(--primary);
|
border-top: 3px solid var(--primary);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -503,6 +877,13 @@
|
|||||||
justify-content: center;
|
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 */
|
/* Touch-friendly improvements for forms and buttons */
|
||||||
.form-control, .form-select {
|
.form-control, .form-select {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@ -541,6 +922,18 @@
|
|||||||
color: var(--foreground);
|
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-ext GeolocationButton styling */
|
||||||
.ol-geobt {
|
.ol-geobt {
|
||||||
top: auto !important;
|
top: auto !important;
|
||||||
@ -830,6 +1223,7 @@
|
|||||||
/* Locations list in offcanvas - can be taller now without form */
|
/* Locations list in offcanvas - can be taller now without form */
|
||||||
.offcanvas-end .locations-list {
|
.offcanvas-end .locations-list {
|
||||||
max-height: calc(100vh - 280px);
|
max-height: calc(100vh - 280px);
|
||||||
|
max-height: calc(100dvh - 280px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -921,17 +1315,32 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ScaleLine - position above the bottom dock */
|
/* ScaleBar - position above the bottom dock with 4px gap */
|
||||||
.ol-scale-line {
|
.ol-scale-bar {
|
||||||
bottom: 76px !important;
|
bottom: calc(85px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
left: 10px !important;
|
left: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-scale-line-inner {
|
.ol-scale-bar .ol-scale-step-text {
|
||||||
border-color: var(--foreground) !important;
|
|
||||||
color: var(--foreground) !important;
|
color: var(--foreground) !important;
|
||||||
font-family: var(--font-body) !important;
|
font-family: var(--font-body) !important;
|
||||||
font-size: 11px !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 */
|
/* ol-ext Bar overrides */
|
||||||
@ -980,6 +1389,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="map"></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 -->
|
<!-- Offcanvas toggle buttons -->
|
||||||
<button class="offcanvas-toggle offcanvas-toggle-left"
|
<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">
|
<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
|
<i class="bi bi-database me-2"></i>Local Data
|
||||||
</button>
|
</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 id="local-data-stats" class="d-none">
|
||||||
<div class="card">
|
<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>
|
<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>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<table class="table table-sm table-striped mb-0">
|
<table class="table table-sm table-striped mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-3">Table</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="local-data-tbody">
|
<tbody id="local-data-tbody">
|
||||||
@ -1142,6 +1575,10 @@
|
|||||||
<span class="message-text"></span>
|
<span class="message-text"></span>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</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 -->
|
<!-- Tip -->
|
||||||
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
|
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
|
||||||
@ -1181,20 +1618,268 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Offcanvas -->
|
<!-- Bottom Offcanvas -->
|
||||||
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
|
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
|
||||||
<div class="offcanvas-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
<p>This is the bottom offcanvas panel.</p>
|
<div class="row g-3">
|
||||||
<p>You can add a data table, charts, or other wide content here.</p>
|
<!-- 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>
|
||||||
|
</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) -->
|
<!-- Polyfill crypto.randomUUID for non-secure contexts (HTTP) -->
|
||||||
<!-- Must run before module imports (SQLocal/coincident require it) -->
|
<!-- 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",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lupmis-pwa",
|
"name": "lupmis2-pwa",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"jspdf-autotable": "^5.0.7",
|
||||||
"ol": "^10.3.0",
|
"ol": "^10.3.0",
|
||||||
"ol-ext": "^4.0.24",
|
"ol-ext": "^4.0.24",
|
||||||
|
"shpjs": "^6.2.0",
|
||||||
"sqlocal": "^0.16.0"
|
"sqlocal": "^0.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -22,6 +25,15 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@ -795,12 +807,32 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/rbush": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||||
@ -813,6 +845,16 @@
|
|||||||
"integrity": "sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==",
|
"integrity": "sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.8",
|
"version": "5.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||||
@ -848,6 +890,32 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/coincident": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz",
|
||||||
@ -863,6 +931,38 @@
|
|||||||
"ws": "^8.16.0"
|
"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": {
|
"node_modules/earcut": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||||
@ -911,6 +1011,17 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@ -968,12 +1085,64 @@
|
|||||||
"node": ">=10.19"
|
"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": {
|
"node_modules/lerc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@ -1031,6 +1200,12 @@
|
|||||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pbf": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||||
@ -1043,6 +1218,13 @@
|
|||||||
"pbf": "bin/pbf"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -1092,6 +1274,19 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/protocol-buffers-schema": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||||
@ -1122,6 +1317,16 @@
|
|||||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/rbush": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||||
@ -1131,6 +1336,13 @@
|
|||||||
"quickselect": "^3.0.0"
|
"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": {
|
"node_modules/resolve-protobuf-schema": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.55.3",
|
"version": "4.55.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz",
|
||||||
@ -1185,6 +1407,17 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@ -1254,6 +1517,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
@ -1335,6 +1608,12 @@
|
|||||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
|||||||
@ -9,11 +9,14 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"bootstrap-icons": "^1.11.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": "^10.3.0",
|
||||||
"ol-ext": "^4.0.24",
|
"ol-ext": "^4.0.24",
|
||||||
|
"shpjs": "^6.2.0",
|
||||||
"sqlocal": "^0.16.0"
|
"sqlocal": "^0.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"name": "LUPMIS2 Drawing Tools",
|
||||||
"short_name": "LUPMIS",
|
"short_name": "LUPMIS2",
|
||||||
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
||||||
"start_url": "/",
|
"start_url": "./",
|
||||||
"scope": "/",
|
"scope": "./",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#005eb8",
|
"theme_color": "#005eb8",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-72.png",
|
"src": "./icons/luspa-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-96.png",
|
"src": "./icons/luspa-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-128.png",
|
"src": "./icons/luspa-128x128.png",
|
||||||
"sizes": "128x128",
|
"sizes": "128x128",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-144.png",
|
"src": "./icons/luspa-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-152.png",
|
"src": "./icons/luspa-152x152.png",
|
||||||
"sizes": "152x152",
|
"sizes": "152x152",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/luspa-192x192.png",
|
"src": "./icons/luspa-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-384.png",
|
"src": "./icons/luspa-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/luspa-512x512.png",
|
"src": "./icons/luspa-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
|
|||||||
321
public/sw.js
@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Handles caching of:
|
* Handles caching of:
|
||||||
* - App shell (HTML, CSS, JS)
|
* - App shell (HTML, CSS, JS)
|
||||||
* - Map tiles (runtime caching)
|
* - Map tiles (passive runtime caching, per-host buckets)
|
||||||
* - API responses (network-first)
|
* - API responses (network-first)
|
||||||
*
|
*
|
||||||
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
|
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
|
||||||
@ -12,18 +12,70 @@
|
|||||||
* - SharedWorker: Shared database connection across tabs
|
* - SharedWorker: Shared database connection across tabs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = 'v1';
|
// v3: lower per-cache limits (5000 → 1500) and counter-based eviction to
|
||||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
// prevent Safari memory-pressure reloads.
|
||||||
const TILES_CACHE = `tiles-${CACHE_VERSION}`;
|
// 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 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
|
// Per-provider tile limits.
|
||||||
// Vite will generate hashed filenames, so we cache the entry points
|
// • OSM and Topographic are the providers offered for active offline
|
||||||
// and let the browser handle the hashed assets
|
// 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 = [
|
const SHELL_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@ -58,14 +110,22 @@ self.addEventListener('activate', (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys()
|
caches.keys()
|
||||||
.then((cacheNames) => {
|
.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(
|
return Promise.all(
|
||||||
cacheNames
|
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) => {
|
.filter((name) => {
|
||||||
// Delete old version caches
|
const isOurs =
|
||||||
return (name.startsWith('shell-') && name !== SHELL_CACHE) ||
|
name.startsWith('shell-') ||
|
||||||
(name.startsWith('tiles-') && name !== TILES_CACHE) ||
|
name.startsWith('tiles-') ||
|
||||||
(name.startsWith('modules-') && name !== MODULES_CACHE) ||
|
name.startsWith('modules-') ||
|
||||||
(name.startsWith('api-') && name !== API_CACHE);
|
name.startsWith('api-');
|
||||||
|
return isOurs && !keep.has(name);
|
||||||
})
|
})
|
||||||
.map((name) => {
|
.map((name) => {
|
||||||
console.log('[SW] Deleting old cache:', name);
|
console.log('[SW] Deleting old cache:', name);
|
||||||
@ -91,10 +151,21 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Skip chrome-extension and other non-http(s) requests
|
// Skip chrome-extension and other non-http(s) requests
|
||||||
if (!url.protocol.startsWith('http')) return;
|
if (!url.protocol.startsWith('http')) return;
|
||||||
|
|
||||||
// Route to appropriate caching strategy
|
// Skip worker files and Vite dev-server node_modules requests —
|
||||||
if (isMapTile(url)) {
|
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
|
||||||
event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES));
|
if (url.pathname.includes('node_modules') ||
|
||||||
} else if (isApiRequest(url)) {
|
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));
|
event.respondWith(networkFirst(request, API_CACHE));
|
||||||
} else if (isModuleAsset(url)) {
|
} else if (isModuleAsset(url)) {
|
||||||
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
||||||
@ -108,15 +179,36 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// URL CLASSIFICATION
|
// URL CLASSIFICATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function isMapTile(url) {
|
/**
|
||||||
// Common tile server patterns for all our base maps
|
* Classify a URL into the appropriate tile cache.
|
||||||
return url.hostname.includes('tile.openstreetmap.org') ||
|
* Returns `null` for non-tile requests, or for tile providers we deliberately
|
||||||
url.hostname.includes('opentopomap.org') ||
|
* do NOT cache (e.g. Google — caching is forbidden by their ToS).
|
||||||
url.hostname.includes('arcgisonline.com') ||
|
*/
|
||||||
url.hostname.includes('basemaps.cartocdn.com') ||
|
function getTileCacheName(url) {
|
||||||
url.hostname.includes('tiles.') ||
|
const host = url.hostname;
|
||||||
url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) ||
|
|
||||||
url.pathname.match(/\/tile\/\d+\/\d+\/\d+/);
|
// 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) {
|
function isApiRequest(url) {
|
||||||
@ -129,7 +221,6 @@ function isModuleAsset(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAppAsset(url) {
|
function isAppAsset(url) {
|
||||||
// Same origin, common asset extensions
|
|
||||||
return url.origin === self.location.origin &&
|
return url.origin === self.location.origin &&
|
||||||
(url.pathname.endsWith('.html') ||
|
(url.pathname.endsWith('.html') ||
|
||||||
url.pathname.endsWith('.css') ||
|
url.pathname.endsWith('.css') ||
|
||||||
@ -144,8 +235,8 @@ function isAppAsset(url) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache First - Use cache, fallback to network
|
* Cache First — Use cache, fallback to network.
|
||||||
* Best for: App shell, static assets
|
* Best for: App shell, static assets.
|
||||||
*/
|
*/
|
||||||
async function cacheFirst(request, cacheName) {
|
async function cacheFirst(request, cacheName) {
|
||||||
const cached = await caches.match(request);
|
const cached = await caches.match(request);
|
||||||
@ -159,7 +250,6 @@ async function cacheFirst(request, cacheName) {
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Return offline page for navigation requests
|
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
return caches.match('/offline.html');
|
return caches.match('/offline.html');
|
||||||
}
|
}
|
||||||
@ -168,8 +258,8 @@ async function cacheFirst(request, cacheName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Network First - Try network, fallback to cache
|
* Network First — Try network, fallback to cache.
|
||||||
* Best for: API requests, dynamic content
|
* Best for: API requests, dynamic content.
|
||||||
*/
|
*/
|
||||||
async function networkFirst(request, cacheName) {
|
async function networkFirst(request, cacheName) {
|
||||||
try {
|
try {
|
||||||
@ -187,8 +277,8 @@ async function networkFirst(request, cacheName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stale While Revalidate - Return cache immediately, update in background
|
* Stale While Revalidate — Return cache immediately, update in background.
|
||||||
* Best for: Module assets, frequently updated content
|
* Best for: Module assets, frequently updated content.
|
||||||
*/
|
*/
|
||||||
async function staleWhileRevalidate(request, cacheName) {
|
async function staleWhileRevalidate(request, cacheName) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
@ -205,37 +295,72 @@ async function staleWhileRevalidate(request, cacheName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache Then Network with limit - Cache tiles with size limit
|
* Tile Cache then Network — Per-host bucket with size limit.
|
||||||
* Best for: Map tiles
|
* 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 cache = await caches.open(cacheName);
|
||||||
const cached = await cache.match(request);
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Check cache size and trim if needed
|
// Bump the counter; periodically run a real eviction sweep
|
||||||
const keys = await cache.keys();
|
const count = (_tileInsertCounters.get(cacheName) || 0) + 1;
|
||||||
if (keys.length >= maxItems) {
|
_tileInsertCounters.set(cacheName, count);
|
||||||
// Remove oldest entries (first 10%)
|
|
||||||
const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1));
|
if (count % EVICTION_CHECK_INTERVAL === 0) {
|
||||||
await Promise.all(toDelete.map(key => cache.delete(key)));
|
// 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;
|
return response;
|
||||||
} catch (error) {
|
} 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' });
|
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
|
// MESSAGE HANDLING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -257,15 +382,32 @@ self.addEventListener('message', (event) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'GET_CACHE_STATUS':
|
case 'GET_CACHE_STATUS':
|
||||||
getCacheStatus().then(status => {
|
getCacheStatus().then((status) => {
|
||||||
event.source.postMessage({ type: 'CACHE_STATUS', status });
|
event.source.postMessage({ type: 'CACHE_STATUS', status });
|
||||||
});
|
});
|
||||||
break;
|
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) {
|
async function cacheModules(moduleNames) {
|
||||||
const cache = await caches.open(MODULES_CACHE);
|
const cache = await caches.open(MODULES_CACHE);
|
||||||
@ -279,7 +421,6 @@ async function cacheModules(moduleNames) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
await cache.addAll(moduleAssets.filter(async (url) => {
|
await cache.addAll(moduleAssets.filter(async (url) => {
|
||||||
// Only cache assets that exist
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
const response = await fetch(url, { method: 'HEAD' });
|
||||||
return response.ok;
|
return response.ok;
|
||||||
@ -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() {
|
async function clearUserCaches() {
|
||||||
await caches.delete(API_CACHE);
|
await caches.delete(API_CACHE);
|
||||||
@ -305,7 +447,7 @@ async function clearUserCaches() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cache status information
|
* Get summary status of all caches (count of entries in each).
|
||||||
*/
|
*/
|
||||||
async function getCacheStatus() {
|
async function getCacheStatus() {
|
||||||
const cacheNames = await caches.keys();
|
const cacheNames = await caches.keys();
|
||||||
@ -319,3 +461,76 @@ async function getCacheStatus() {
|
|||||||
|
|
||||||
return status;
|
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 { getLength, getArea } from 'ol/sphere';
|
||||||
import Feature from 'ol/Feature';
|
import Feature from 'ol/Feature';
|
||||||
import { unByKey } from 'ol/Observable';
|
import { unByKey } from 'ol/Observable';
|
||||||
|
import { formatLength, formatArea, formatCircleExtent } from '../units.js';
|
||||||
|
|
||||||
// ol-ext imports
|
// ol-ext imports
|
||||||
import EditBar from 'ol-ext/control/EditBar';
|
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
|
* Create measurement tooltip overlay
|
||||||
*/
|
*/
|
||||||
@ -231,8 +198,8 @@ export class MapTools {
|
|||||||
|
|
||||||
if (geom instanceof Circle) {
|
if (geom instanceof Circle) {
|
||||||
const radius = geom.getRadius();
|
const radius = geom.getRadius();
|
||||||
const area = this.formatCircleExtent(radius);
|
const area = formatCircleExtent(radius);
|
||||||
const radiusFormatted = this.formatLength(radius);
|
const radiusFormatted = formatLength(radius);
|
||||||
|
|
||||||
const output = `<strong>${radiusFormatted}</strong><br><small>${area}</small>`;
|
const output = `<strong>${radiusFormatted}</strong><br><small>${area}</small>`;
|
||||||
|
|
||||||
@ -311,7 +278,7 @@ export class MapTools {
|
|||||||
listener = sketch.getGeometry().on('change', (e) => {
|
listener = sketch.getGeometry().on('change', (e) => {
|
||||||
const geom = e.target;
|
const geom = e.target;
|
||||||
const length = getLength(geom);
|
const length = getLength(geom);
|
||||||
const output = this.formatLength(length);
|
const output = formatLength(length);
|
||||||
|
|
||||||
this.measureTooltipElement.innerHTML = output;
|
this.measureTooltipElement.innerHTML = output;
|
||||||
this.measureTooltip.setPosition(geom.getLastCoordinate());
|
this.measureTooltip.setPosition(geom.getLastCoordinate());
|
||||||
@ -364,7 +331,7 @@ export class MapTools {
|
|||||||
listener = sketch.getGeometry().on('change', (e) => {
|
listener = sketch.getGeometry().on('change', (e) => {
|
||||||
const geom = e.target;
|
const geom = e.target;
|
||||||
const area = getArea(geom);
|
const area = getArea(geom);
|
||||||
const output = this.formatArea(area);
|
const output = formatArea(area);
|
||||||
|
|
||||||
this.measureTooltipElement.innerHTML = output;
|
this.measureTooltipElement.innerHTML = output;
|
||||||
this.measureTooltip.setPosition(geom.getInteriorPoint().getCoordinates());
|
this.measureTooltip.setPosition(geom.getInteriorPoint().getCoordinates());
|
||||||
@ -376,6 +343,10 @@ export class MapTools {
|
|||||||
const geom = feature.getGeometry();
|
const geom = feature.getGeometry();
|
||||||
const area = getArea(geom);
|
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.className = 'measure-tooltip measure-tooltip-static';
|
||||||
this.measureTooltipElement = null;
|
this.measureTooltipElement = null;
|
||||||
this.createMeasureTooltip();
|
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
|
// Create indexes
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
|
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
|
await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
|
||||||
@ -541,17 +552,20 @@ export async function getLocalCollectorZones() {
|
|||||||
export async function saveParcels(parcels) {
|
export async function saveParcels(parcels) {
|
||||||
try {
|
try {
|
||||||
await sql`DELETE FROM parcels`;
|
await sql`DELETE FROM parcels`;
|
||||||
|
let saved = 0;
|
||||||
for (const p of parcels) {
|
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;
|
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`
|
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)
|
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) {
|
} catch (error) {
|
||||||
console.error('[Database] ✗ Failed to save parcels:', error);
|
console.error('[Database] ✗ Failed to save parcels:', error);
|
||||||
throw 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
|
// 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.
|
* Get a list of all tables with their row counts.
|
||||||
* @returns {Promise<Array<{name: string, count: number}>>}
|
* @returns {Promise<Array<{name: string, count: number}>>}
|
||||||
@ -890,6 +1044,12 @@ export default {
|
|||||||
insertNewParcel,
|
insertNewParcel,
|
||||||
saveBuildingFootprints,
|
saveBuildingFootprints,
|
||||||
getLocalBuildingFootprints,
|
getLocalBuildingFootprints,
|
||||||
|
saveOSMRoads,
|
||||||
|
getLocalOSMRoads,
|
||||||
|
CACHED_LAYER_TABLES,
|
||||||
|
isCachedLayerTable,
|
||||||
|
clearTable,
|
||||||
|
clearAllCachedLayers,
|
||||||
exportDatabase,
|
exportDatabase,
|
||||||
exportToGeoJSON,
|
exportToGeoJSON,
|
||||||
importDatabase,
|
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
|
// Auto-initialization
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -313,5 +465,10 @@ export default {
|
|||||||
applyUpdate,
|
applyUpdate,
|
||||||
postToServiceWorker,
|
postToServiceWorker,
|
||||||
cacheModules,
|
cacheModules,
|
||||||
clearUserCaches
|
clearUserCaches,
|
||||||
|
getTileCacheStats,
|
||||||
|
clearTileCaches,
|
||||||
|
getStorageEstimate,
|
||||||
|
getActiveServiceWorker,
|
||||||
|
onServiceWorkerControllerChange,
|
||||||
};
|
};
|
||||||
|
|||||||
133
src/remotedb.js
@ -21,13 +21,89 @@ const API_CREDENTIALS = {
|
|||||||
api_token: '1c46538c712e9b5b'
|
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
|
// 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.
|
* Perform a GET request to the remote API.
|
||||||
* Credentials are sent as URL query parameters.
|
* 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 {string} endpoint - API endpoint filename (e.g. 'get_district_boundary.php')
|
||||||
* @param {Object} [params={}] - Additional query parameters
|
* @param {Object} [params={}] - Additional query parameters
|
||||||
@ -45,13 +121,15 @@ export async function remoteGet(endpoint, params = {}, options = {}) {
|
|||||||
|
|
||||||
console.log('[RemoteDB] GET', url.toString());
|
console.log('[RemoteDB] GET', url.toString());
|
||||||
|
|
||||||
|
const timeout = withTimeout(options);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
},
|
},
|
||||||
...options
|
...options,
|
||||||
|
signal: timeout.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -63,14 +141,21 @@ export async function remoteGet(endpoint, params = {}, options = {}) {
|
|||||||
return data;
|
return data;
|
||||||
|
|
||||||
} catch (error) {
|
} 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);
|
console.error('[RemoteDB] GET failed:', endpoint, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
timeout.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a POST request to the remote API.
|
* Perform a POST request to the remote API.
|
||||||
* Credentials are included in the JSON body.
|
* 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 {string} endpoint - API endpoint filename (e.g. 'some_endpoint.php')
|
||||||
* @param {Object} [body={}] - Request payload (credentials are merged in)
|
* @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);
|
console.log('[RemoteDB] POST', url);
|
||||||
|
|
||||||
|
const timeout = withTimeout(options);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -92,7 +178,8 @@ export async function remotePost(endpoint, body = {}, options = {}) {
|
|||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
...options
|
...options,
|
||||||
|
signal: timeout.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -104,8 +191,14 @@ export async function remotePost(endpoint, body = {}, options = {}) {
|
|||||||
return data;
|
return data;
|
||||||
|
|
||||||
} catch (error) {
|
} 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);
|
console.error('[RemoteDB] POST failed:', endpoint, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
timeout.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,16 +263,50 @@ export async function getBuildingFootprints() {
|
|||||||
return remotePost('get_all_footprint_per_district.php');
|
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
|
// Exports
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
checkServerReachable,
|
||||||
|
isServerReachable,
|
||||||
remoteGet,
|
remoteGet,
|
||||||
remotePost,
|
remotePost,
|
||||||
getDistrictBoundary,
|
getDistrictBoundary,
|
||||||
getLayers,
|
getLayers,
|
||||||
getDistrictParcels,
|
getDistrictParcels,
|
||||||
getCollectorZones,
|
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 modern browsers that support OPFS
|
||||||
target: 'esnext',
|
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: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (id.includes('node_modules/ol/')) return 'openlayers';
|
if (id.includes('node_modules/ol/')) return 'openlayers';
|
||||||
if (id.includes('node_modules/ol-ext/')) return 'ol-ext';
|
if (id.includes('node_modules/ol-ext/')) return 'ol-ext';
|
||||||
if (id.includes('node_modules/bootstrap/')) return 'bootstrap';
|
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';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||