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>
This commit is contained in:
ekke 2026-05-26 10:55:30 +02:00
parent 3009a11b33
commit ef12e4477b
84 changed files with 10808 additions and 1499 deletions

Binary file not shown.

BIN
OpenTopography_Workflow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

181
OpenTopography_Workflow.svg Normal file
View 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

Binary file not shown.

23
dist/assets/html2canvas.esm-B0tyYwQk.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

636
dist/assets/index-B4XzHtZX.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-B4XzHtZX.js.map vendored Normal file

File diff suppressed because one or more lines are too long

19
dist/assets/index.es-CRPDPo17.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index.es-CRPDPo17.js.map vendored Normal file

File diff suppressed because one or more lines are too long

172
dist/assets/jspdf-Cu-2SCgw.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/jspdf-Cu-2SCgw.js.map vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/assets/ol-ext-CSk2UikI.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/ol-ext-CSk2UikI.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

573
dist/assets/openlayers-CUDtI0S3.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/assets/pdf-export-Vpiz8VA4.js vendored Normal file
View 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

File diff suppressed because one or more lines are too long

3
dist/assets/purify.es-BgtpMKW3.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/purify.es-BgtpMKW3.js.map vendored Normal file

File diff suppressed because one or more lines are too long

5
dist/assets/shpjs-CNrRgkgn.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/shpjs-CNrRgkgn.js.map vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/fonts/bebas-neue-latin-ext.woff2 vendored Normal file

Binary file not shown.

BIN
dist/fonts/bebas-neue-latin.woff2 vendored Normal file

Binary file not shown.

BIN
dist/fonts/exo-latin-ext.woff2 vendored Normal file

Binary file not shown.

BIN
dist/fonts/exo-latin.woff2 vendored Normal file

Binary file not shown.

BIN
dist/fonts/exo-vietnamese.woff2 vendored Normal file

Binary file not shown.

View 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
dist/icons/luspa-128x128.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
dist/icons/luspa-144x144.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
dist/icons/luspa-152x152.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
dist/icons/luspa-384x384.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
dist/icons/luspa-72x72.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
dist/icons/luspa-96x96.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
dist/icons/luspa-pdf.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

745
dist/index.html vendored
View File

@ -2,19 +2,64 @@
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#005eb8">
<meta name="description" content="LUPMIS2 Drawing Tools">
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/luspa.icon">
<link rel="icon" href="/icons/luspa.icon">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.png">
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Exo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
<style>
/* Bebas Neue 400 — latin-ext */
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/bebas-neue-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Bebas Neue 400 — latin */
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/bebas-neue-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Exo 300-800 — vietnamese */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* Exo 300-800 — latin-ext */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Exo 300-800 — latin */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
</style>
<title>LUPMIS2 Drawing Tools</title>
@ -106,6 +151,295 @@
--radius-2xl: 1rem;
}
/* ─── Fieldwork Mode ─── high-contrast + larger touch targets ─── */
.fieldwork-mode {
--foreground: #000;
--background: #fff;
--card: #fff;
--card-foreground: #000;
--primary: #0044aa;
--primary-foreground: #fff;
--primary-hover: #003080;
--muted: #e0e0e0;
--muted-foreground: #333;
--accent: #cce0ff;
--accent-foreground: #000;
--border: rgba(0,0,0,0.25);
--success: #005a00;
--success-foreground: #fff;
--warning: #b36b00;
--warning-foreground: #000;
--destructive: #b80000;
--destructive-foreground: #fff;
--ring: #0044aa;
--bs-body-color: #000;
}
/* Fieldwork: larger dock buttons */
.fieldwork-mode .dock-btn {
min-width: 72px;
min-height: 58px;
font-size: 1.6rem;
border-width: 2px;
}
.fieldwork-mode .dock-btn-label {
font-size: 0.75rem;
font-weight: 600;
}
/* Fieldwork: bolder navbar */
.fieldwork-mode .navbar {
border-bottom-width: 4px;
}
.fieldwork-mode .navbar .navbar-brand {
font-size: 1.6rem;
}
/* Fieldwork: larger offcanvas toggle buttons */
.fieldwork-mode .offcanvas-toggle {
width: 44px;
height: 44px;
font-size: 1.2rem;
}
/* Fieldwork: thicker bottom dock border */
.fieldwork-mode .bottom-dock {
border-top-width: 4px;
}
/* Fieldwork: larger text in cards / lists */
.fieldwork-mode .card-header h6 {
font-size: 1rem;
}
.fieldwork-mode .list-group-item {
font-size: 0.95rem;
padding: 0.65rem 1rem;
}
/* Fieldwork: larger buttons globally */
.fieldwork-mode .btn {
font-size: 0.95rem;
padding: 0.5rem 1rem;
font-weight: 600;
}
.fieldwork-mode .btn-sm {
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
}
/* Fieldwork: stronger borders on inputs / form controls */
.fieldwork-mode .form-control,
.fieldwork-mode .form-select {
border-width: 2px;
border-color: #555;
font-size: 1rem;
}
/* Fieldwork: bolder map controls (ol-ext) */
.fieldwork-mode .ol-control button {
font-size: 1.3rem;
width: 2.2em;
height: 2.2em;
}
/* Fieldwork: scale bar text legibility */
.fieldwork-mode .ol-scale-bar .ol-scale-step-text,
.fieldwork-mode .ol-scale-bar .ol-scale-text {
font-size: 12px;
font-weight: 700;
text-shadow: 0 0 4px #fff, 0 0 8px #fff;
}
/* ─── Dark Mode ─── reversed colour scheme ─── */
.dark-mode {
--foreground: #e0dff0;
--background: #131325;
--card: #1e1e38;
--card-foreground: #e0dff0;
--primary: #4d9de6;
--primary-foreground: #fff;
--primary-hover: #6fb3f0;
--muted: #272745;
--muted-foreground: #9594a8;
--accent: #1e3a5f;
--accent-foreground: #e0dff0;
--border: rgba(255,255,255,0.12);
--ring: #4d9de6;
--success: #2dd46a;
--success-foreground: #131325;
--warning: #ffb84d;
--warning-foreground: #131325;
--destructive: #f04040;
--destructive-foreground: #fff;
--bs-body-color: #e0dff0;
--bs-body-bg: #131325;
--bs-tertiary-bg: #1e1e38;
color-scheme: dark;
}
/* Dark: navbar */
.dark-mode .navbar {
background-color: #1a1a30 !important;
box-shadow: 0 1px 6px rgba(0,0,0,0.4);
}
/* Dark: bottom dock */
.dark-mode .bottom-dock {
background-color: #1a1a30;
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
}
.dark-mode .dock-btn {
border-color: var(--primary);
color: var(--foreground);
}
.dark-mode .dock-btn:hover {
background-color: var(--muted);
}
.dark-mode .dock-btn.active {
background-color: var(--primary);
color: var(--primary-foreground);
}
/* Dark: offcanvas panels */
.dark-mode .offcanvas {
background-color: var(--background) !important;
color: var(--foreground) !important;
}
.dark-mode .offcanvas-header {
border-bottom-color: var(--border) !important;
}
.dark-mode .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Dark: cards */
.dark-mode .card {
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border-color: var(--border) !important;
}
/* Dark: offcanvas toggle buttons */
.dark-mode .offcanvas-toggle {
background-color: var(--card);
color: var(--foreground);
border-color: var(--border);
}
.dark-mode .offcanvas-toggle:hover {
background-color: var(--primary);
color: var(--primary-foreground);
}
/* Dark: form controls */
.dark-mode .form-control,
.dark-mode .form-select {
background-color: var(--muted) !important;
color: var(--foreground) !important;
border-color: var(--border) !important;
}
.dark-mode .form-check-input {
background-color: var(--muted);
border-color: var(--muted-foreground);
}
.dark-mode .form-check-input:checked {
background-color: var(--primary);
border-color: var(--primary);
}
/* Dark: list groups */
.dark-mode .list-group-item {
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border-color: var(--border) !important;
}
/* Dark: buttons */
.dark-mode .btn-outline-primary {
color: var(--primary);
border-color: var(--primary);
}
.dark-mode .btn-outline-danger {
color: var(--destructive);
border-color: var(--destructive);
}
/* Dark: text utilities */
.dark-mode .text-muted {
color: var(--muted-foreground) !important;
}
/* Dark: measurement tooltips */
.dark-mode .measure-tooltip {
background: rgba(30, 30, 56, 0.95);
color: var(--foreground);
border-color: var(--primary);
}
.dark-mode .measure-tooltip::before {
border-right-color: var(--primary);
}
/* Dark: OL controls */
.dark-mode .ol-control button {
background-color: var(--card) !important;
color: var(--foreground) !important;
}
.dark-mode .ol-control button:hover {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
}
.dark-mode .ol-attribution,
.dark-mode .ol-attribution a {
color: var(--muted-foreground) !important;
}
/* Dark: scale bar */
.dark-mode .ol-scale-bar .ol-scale-step-text,
.dark-mode .ol-scale-bar .ol-scale-text {
color: #fff !important;
text-shadow: 0 0 4px #000, 0 0 8px #000 !important;
}
.dark-mode .ol-scale-bar .ol-scale-singlebar-even {
background-color: #fff !important;
}
.dark-mode .ol-scale-bar .ol-scale-singlebar-odd {
background-color: #999 !important;
}
/* Dark: map drop overlay */
.dark-mode .map-drop-overlay {
background: rgba(19, 19, 37, 0.85);
border-color: var(--primary);
color: var(--foreground);
}
/* Dark: ol-ext LayerSwitcher */
.dark-mode .ol-layerswitcher {
background-color: var(--card) !important;
}
.dark-mode .ol-layerswitcher .panel {
background-color: var(--card) !important;
color: var(--foreground) !important;
}
.dark-mode .ol-layerswitcher .panel li {
color: var(--foreground);
}
.dark-mode .ol-layerswitcher .ol-switchertopdiv,
.dark-mode .ol-layerswitcher .ol-switcherbottomdiv {
background: var(--card) !important;
}
/* Dark: alert boxes */
.dark-mode .alert-danger {
background-color: rgba(240, 64, 64, 0.15) !important;
color: var(--destructive) !important;
border-color: var(--destructive) !important;
}
.dark-mode .alert-success {
background-color: rgba(45, 212, 106, 0.15) !important;
color: var(--success) !important;
border-color: var(--success) !important;
}
/* Full height layout */
html, body {
height: 100%;
@ -214,9 +548,12 @@
font-family: var(--font-body);
}
/* Main container - full height */
/* Main container - full height.
100dvh accounts for mobile browser chrome and OS nav bars.
Falls back to 100vh for older browsers. */
.app-container {
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
}
@ -244,6 +581,35 @@
height: 100%;
}
/* Drag-and-drop overlay shown when files are dragged over the map */
.map-drop-overlay {
position: absolute;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 94, 184, 0.15);
border: 3px dashed var(--primary, #005eb8);
border-radius: 8px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.map-container.drag-over .map-drop-overlay {
opacity: 1;
}
.map-drop-overlay span {
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 1.15rem;
font-weight: 600;
color: var(--primary, #005eb8);
background: var(--card, #fff);
padding: 0.6rem 1.4rem;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
/* Offline indicator */
#offline-indicator {
display: none;
@ -301,9 +667,14 @@
z-index: 1050;
}
/* Fix ol-ext LayerSwitcher z-index */
.ol-layerswitcher {
z-index: 100;
/* OL controls stacking context fix — OpenLayers sets z-index:0 on
.ol-overlaycontainer-stopevent, trapping all controls below the
offcanvas-toggle buttons (z-index:500). Raising the container
to 501 lets the LayerSwitcher dropdown render above the toggles.
pointer-events:none on the container still lets clicks through
to the toggle buttons underneath. */
.ol-overlaycontainer-stopevent {
z-index: 501 !important;
}
/* Alert hint box */
@ -419,7 +790,7 @@
}
.offcanvas-toggle-bottom {
bottom: 80px; /* Above the dock */
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); /* Above the dock */
left: 50%;
transform: translateX(-50%);
}
@ -432,7 +803,10 @@
transform: translateX(-50%) scale(0.95);
}
/* Bottom Dock — white card style with blue-strong accent */
/* Bottom Dock — white card style with blue-strong accent.
env(safe-area-inset-bottom) adds padding on devices with a
home indicator / gesture bar (e.g. iPhone notch models).
The value is 0 on devices without an inset. */
.bottom-dock {
position: absolute;
bottom: 0;
@ -441,7 +815,7 @@
z-index: 600;
background-color: var(--card);
border-top: 3px solid var(--primary);
padding: 8px 16px;
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
display: flex;
justify-content: space-around;
align-items: center;
@ -503,6 +877,13 @@
justify-content: center;
}
/* Snap-guides toggle — highlighted when active */
.ol-snap-toggle.ol-active button {
background: var(--primary) !important;
color: var(--primary-foreground, #fff) !important;
border-radius: 3px;
}
/* Touch-friendly improvements for forms and buttons */
.form-control, .form-select {
min-height: 44px;
@ -541,6 +922,18 @@
color: var(--foreground);
}
/* Message log in the right panel */
.message-log {
max-height: 260px;
overflow-y: auto;
}
.message-log-entry {
font-size: 0.82rem;
border-color: var(--border, #eee) !important;
background: transparent;
}
/* ol-ext GeolocationButton styling */
.ol-geobt {
top: auto !important;
@ -830,6 +1223,7 @@
/* Locations list in offcanvas - can be taller now without form */
.offcanvas-end .locations-list {
max-height: calc(100vh - 280px);
max-height: calc(100dvh - 280px);
overflow-y: auto;
}
@ -921,17 +1315,32 @@
font-size: 18px;
}
/* ScaleLine - position above the bottom dock */
.ol-scale-line {
bottom: 76px !important;
/* ScaleBar - position above the bottom dock with 4px gap */
.ol-scale-bar {
bottom: calc(85px + env(safe-area-inset-bottom, 0px)) !important;
left: 10px !important;
}
.ol-scale-line-inner {
border-color: var(--foreground) !important;
.ol-scale-bar .ol-scale-step-text {
color: var(--foreground) !important;
font-family: var(--font-body) !important;
font-size: 11px !important;
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
}
.ol-scale-bar .ol-scale-text {
color: var(--foreground) !important;
font-family: var(--font-body) !important;
font-size: 11px !important;
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
}
.ol-scale-bar .ol-scale-singlebar-even {
background-color: var(--foreground) !important;
}
.ol-scale-bar .ol-scale-singlebar-odd {
background-color: var(--muted-foreground) !important;
}
/* ol-ext Bar overrides */
@ -950,12 +1359,12 @@
gap: 2px;
}
</style>
<script type="module" crossorigin src="/assets/index-2WHoRhxp.js"></script>
<script type="module" crossorigin src="/assets/index-B4XzHtZX.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-CUDtI0S3.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/openlayers-D2I-bVN2.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-DytxBANR.js">
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-CSk2UikI.js">
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
<link rel="stylesheet" crossorigin href="/assets/ol-ext-BgKrOIxx.css">
<link rel="stylesheet" crossorigin href="/assets/index-BnwqsTiD.css">
</head>
@ -988,6 +1397,7 @@
</span>
</div>
<div id="map"></div>
<div class="map-drop-overlay"><span><i class="bi bi-file-earmark-arrow-up me-2"></i>Drop file to import (.shp .geojson .kml)</span></div>
<!-- Offcanvas toggle buttons -->
<button class="offcanvas-toggle offcanvas-toggle-left"
@ -1112,17 +1522,40 @@
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="local-data-btn">
<i class="bi bi-database me-2"></i>Local Data
</button>
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-shp-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import .shp
</button>
<input type="file" id="shp-file-input" accept=".zip,.shp,.dbf,.shx,.prj" multiple class="d-none">
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-geojson-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import GeoJSON
</button>
<input type="file" id="geojson-file-input" accept=".geojson,.json" class="d-none">
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-kml-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import KML
</button>
<input type="file" id="kml-file-input" accept=".kml,.kmz" class="d-none">
<div id="file-import-alert" class="alert alert-danger alert-dismissible fade show d-none mb-3" role="alert">
<small class="message-text"></small>
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div id="imported-layers-info" class="d-none mb-3"></div>
<div id="local-data-stats" class="d-none">
<div class="card">
<div class="card-header bg-primary py-2">
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-database me-2"></i>Local Database Tables</h6>
<button type="button" class="btn btn-sm btn-outline-light"
id="clear-all-cached-btn"
title="Delete all cached map layers. They will be re-downloaded on next app start.">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh cached layers
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th class="ps-3">Table</th>
<th class="text-end pe-3">Records</th>
<th class="text-end">Records</th>
<th class="text-end pe-3" style="width:3rem;"></th>
</tr>
</thead>
<tbody id="local-data-tbody">
@ -1150,6 +1583,10 @@
<span class="message-text"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div id="warning-message" class="alert alert-warning alert-dismissible fade show d-none" role="alert">
<span class="message-text"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<!-- Tip -->
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
@ -1189,20 +1626,268 @@
</div>
</div>
</div>
<!-- Message Log -->
<div class="card mt-3" id="message-log-card">
<div class="card-header bg-transparent py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="font-family:var(--font-body);font-weight:700;"><i class="bi bi-journal-text me-1"></i> Messages</h6>
<button class="btn btn-sm btn-link text-muted p-0" id="clear-message-log" title="Clear messages">
<i class="bi bi-trash3"></i>
</button>
</div>
<div class="card-body p-0">
<div id="message-log" class="message-log list-group list-group-flush">
<div class="text-center text-muted py-3">
<small>No messages yet.</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Offcanvas -->
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-chevron-down me-2"></i>Bottom Panel</h5>
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-gear me-2"></i>Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
</div>
<div class="offcanvas-body">
<p>This is the bottom offcanvas panel.</p>
<p>You can add a data table, charts, or other wide content here.</p>
<div class="row g-3">
<!-- Fieldwork Mode -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Fieldwork Mode</h6>
<small class="text-muted">High-contrast colours and larger touch targets for bright sunlight and field conditions.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="fieldwork-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
</div>
</div>
</div>
</div>
</div>
<!-- Dark Mode -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Dark Mode</h6>
<small class="text-muted">Reduce glare and save battery with a dark colour scheme.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="dark-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
</div>
</div>
</div>
</div>
</div>
<!-- Measurement System -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Measurement System</h6>
<small class="text-muted">Switch between Metric (m, km) and Imperial (ft, mi, acres) units.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="measurement-system-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
<label class="form-check-label ms-1" id="measurement-system-label" for="measurement-system-toggle" style="font-size:0.8rem;font-weight:600;min-width:55px;">Metric</label>
</div>
</div>
</div>
</div>
</div>
<!-- Default Base Map -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Default Base Map</h6>
<small class="text-muted">Base map shown on app start. Saved on this device.</small>
</div>
<div class="ms-3" style="min-width:140px;">
<select class="form-select form-select-sm" id="default-basemap-select" aria-label="Default base map">
<option value="topo">Topographic</option>
<option value="osm">OpenStreetMap</option>
<option value="satellite">Satellite</option>
<option value="googlesat">Google Sat</option>
<option value="carto-light">Carto Light</option>
<option value="carto-dark">Carto Dark</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Offline Map Tiles -->
<div class="col-12 col-md-6 col-lg-8">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between mb-2">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">
<i class="bi bi-map me-1"></i>Offline Map Tiles
</h6>
<small class="text-muted">
Map tiles you've already viewed are cached on this device so they work offline.
Tiles are cached automatically as you browse, or you can pre-download a region.
</small>
</div>
<div class="ms-3 d-flex gap-2 flex-shrink-0">
<button type="button" class="btn btn-sm btn-primary"
id="download-tiles-btn" style="white-space:nowrap;">
<i class="bi bi-cloud-download me-1"></i>Download offline map
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
id="clear-tiles-btn" style="white-space:nowrap;">
<i class="bi bi-trash3 me-1"></i>Clear cached tiles
</button>
</div>
</div>
<div id="tile-cache-stats" class="small">
<div class="text-muted fst-italic">Loading…</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Download Offline Map modal -->
<div class="modal fade" id="offline-download-modal" tabindex="-1" aria-labelledby="offline-download-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="offline-download-title">
<i class="bi bi-cloud-download me-2"></i>Download Offline Map
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="offline-download-close-btn"></button>
</div>
<!-- Form view (shown until Start is clicked) -->
<div class="modal-body" id="offline-download-form-view">
<p class="text-muted small mb-3">
Pre-fetch map tiles so they're available when you're offline.
Only the OpenStreetMap and Topographic base maps can be downloaded;
other providers don't permit bulk caching.
</p>
<!-- Base map -->
<div class="mb-3">
<label for="offline-basemap-select" class="form-label fw-bold">Base map</label>
<select class="form-select form-select-sm" id="offline-basemap-select">
<option value="topo">Topographic</option>
<option value="osm">OpenStreetMap</option>
</select>
</div>
<!-- Area -->
<div class="mb-3">
<label class="form-label fw-bold mb-1">Area to download</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-view" value="view" checked>
<label class="form-check-label" for="offline-area-view">
Current map view
<span class="text-muted small" id="offline-area-view-info"></span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-district" value="district">
<label class="form-check-label" for="offline-area-district">
District boundary
<span class="text-muted small" id="offline-area-district-info"></span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-ghana" value="ghana">
<label class="form-check-label" for="offline-area-ghana">
Entire Ghana <span class="text-muted small">(very large — only attempt over fast Wi-Fi)</span>
</label>
</div>
</div>
<!-- Zoom range -->
<div class="mb-3">
<label class="form-label fw-bold mb-1">Zoom levels</label>
<div class="row g-2 align-items-center">
<div class="col-auto"><label for="offline-min-zoom" class="form-label small mb-0">Min</label></div>
<div class="col-auto">
<input type="number" class="form-control form-control-sm" id="offline-min-zoom" min="6" max="19" value="10" style="width:5em;">
</div>
<div class="col-auto"><label for="offline-max-zoom" class="form-label small mb-0">Max</label></div>
<div class="col-auto">
<input type="number" class="form-control form-control-sm" id="offline-max-zoom" min="6" max="19" value="15" style="width:5em;">
</div>
<div class="col text-muted small">10 = regional · 13 = neighbourhood · 16 = building</div>
</div>
</div>
<!-- Estimate -->
<div class="alert alert-info py-2 px-3 mb-3" id="offline-estimate" style="font-size:0.9em;">
<strong>Estimated download:</strong>
<span id="offline-estimate-detail">Calculating…</span>
</div>
<!-- Acknowledgement -->
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="offline-ack-check">
<label class="form-check-label small" for="offline-ack-check">
I understand this counts against the tile provider's usage quota
and will use mobile data if I'm not on Wi-Fi.
</label>
</div>
</div>
<!-- Progress view (shown during download) -->
<div class="modal-body d-none" id="offline-download-progress-view">
<div class="text-center mb-3">
<div class="fs-5 fw-bold" id="offline-progress-percent">0%</div>
<div class="small text-muted" id="offline-progress-counts">0 of 0 tiles</div>
</div>
<div class="progress mb-3" style="height:1.25em;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" id="offline-progress-bar"
aria-valuemin="0" aria-valuemax="100" style="width:0%;"></div>
</div>
<div class="row text-center small text-muted">
<div class="col"><div class="fw-bold text-body" id="offline-progress-ok">0</div>fetched</div>
<div class="col"><div class="fw-bold text-body" id="offline-progress-failed">0</div>failed</div>
<div class="col"><div class="fw-bold text-body" id="offline-progress-eta"></div>remaining</div>
</div>
</div>
<!-- Done view (shown after completion) -->
<div class="modal-body d-none" id="offline-download-done-view">
<div class="text-center py-3">
<i class="bi bi-check-circle-fill text-success" style="font-size:3rem;"></i>
<div class="fs-5 fw-bold mt-2" id="offline-done-title">Download complete</div>
<div class="text-muted" id="offline-done-detail"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="offline-download-cancel-btn">
Cancel
</button>
<button type="button" class="btn btn-primary" id="offline-download-start-btn" disabled>
<i class="bi bi-cloud-download me-1"></i>Start download
</button>
<button type="button" class="btn btn-primary d-none" id="offline-download-close-done-btn" data-bs-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<!-- Polyfill crypto.randomUUID for non-secure contexts (HTTP) -->
<!-- Must run before module imports (SQLocal/coincident require it) -->

22
dist/manifest.json vendored
View File

@ -1,58 +1,58 @@
{
"name": "LUPMIS2 Drawing Tools",
"short_name": "LUPMIS",
"short_name": "LUPMIS2",
"description": "Map and GIS functions for Land Use Planning in Ghana",
"start_url": "/",
"scope": "/",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#005eb8",
"orientation": "any",
"icons": [
{
"src": "/icons/icon-72.png",
"src": "./icons/luspa-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-96.png",
"src": "./icons/luspa-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-128.png",
"src": "./icons/luspa-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-144.png",
"src": "./icons/luspa-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-152.png",
"src": "./icons/luspa-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/luspa-192x192.png",
"src": "./icons/luspa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384.png",
"src": "./icons/luspa-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/luspa-512x512.png",
"src": "./icons/luspa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"

321
dist/sw.js vendored
View File

@ -3,7 +3,7 @@
*
* Handles caching of:
* - App shell (HTML, CSS, JS)
* - Map tiles (runtime caching)
* - Map tiles (passive runtime caching, per-host buckets)
* - API responses (network-first)
*
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
@ -12,18 +12,70 @@
* - SharedWorker: Shared database connection across tabs
*/
const CACHE_VERSION = 'v1';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const TILES_CACHE = `tiles-${CACHE_VERSION}`;
// v3: lower per-cache limits (5000 → 1500) and counter-based eviction to
// prevent Safari memory-pressure reloads.
// v4: raise OSM and Topographic limits to 8000 to support active offline
// downloads (Phase 2). Other providers stay at 1500.
const CACHE_VERSION = 'v4';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
// Maximum number of tiles to cache
const MAX_TILES = 500;
// ----------------------------------------------------------------------------
// Tile caches — one per provider so users can clear them independently.
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
// which covers a Ghana district at zoom 1015 (typical field-work range).
// ----------------------------------------------------------------------------
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
// App shell assets - precached on install
// Vite will generate hashed filenames, so we cache the entry points
// and let the browser handle the hashed assets
// Per-provider tile limits.
// • OSM and Topographic are the providers offered for active offline
// download (Phase 2 dialog), so they get a higher cap (~240 MB each at
// ~30 KB/tile) — enough for a typical Ghana district at zoom 1015.
// • 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 580 KB; 30 KB is a good middle ground.
const AVG_TILE_BYTES = 30 * 1024;
// ----------------------------------------------------------------------------
// App shell assets — precached on install.
// ----------------------------------------------------------------------------
const SHELL_ASSETS = [
'/',
'/index.html',
@ -58,14 +110,22 @@ self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((cacheNames) => {
// Build the set of caches that should remain
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
return Promise.all(
cacheNames
// Delete anything that:
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
// • but is NOT in the current keep set
// This includes the legacy "tiles-v1" single bucket.
.filter((name) => {
// Delete old version caches
return (name.startsWith('shell-') && name !== SHELL_CACHE) ||
(name.startsWith('tiles-') && name !== TILES_CACHE) ||
(name.startsWith('modules-') && name !== MODULES_CACHE) ||
(name.startsWith('api-') && name !== API_CACHE);
const isOurs =
name.startsWith('shell-') ||
name.startsWith('tiles-') ||
name.startsWith('modules-') ||
name.startsWith('api-');
return isOurs && !keep.has(name);
})
.map((name) => {
console.log('[SW] Deleting old cache:', name);
@ -91,10 +151,21 @@ self.addEventListener('fetch', (event) => {
// Skip chrome-extension and other non-http(s) requests
if (!url.protocol.startsWith('http')) return;
// Route to appropriate caching strategy
if (isMapTile(url)) {
event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES));
} else if (isApiRequest(url)) {
// Skip worker files and Vite dev-server node_modules requests —
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') ||
url.search.includes('worker_file') ||
request.destination === 'worker') return;
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
const tileCache = getTileCacheName(url);
if (tileCache) {
event.respondWith(tileCacheThenNetwork(request, tileCache));
return;
}
// ----- OTHER ROUTES (unchanged) -----
if (isApiRequest(url)) {
event.respondWith(networkFirst(request, API_CACHE));
} else if (isModuleAsset(url)) {
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
@ -108,15 +179,36 @@ self.addEventListener('fetch', (event) => {
// URL CLASSIFICATION
// ============================================================================
function isMapTile(url) {
// Common tile server patterns for all our base maps
return url.hostname.includes('tile.openstreetmap.org') ||
url.hostname.includes('opentopomap.org') ||
url.hostname.includes('arcgisonline.com') ||
url.hostname.includes('basemaps.cartocdn.com') ||
url.hostname.includes('tiles.') ||
url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) ||
url.pathname.match(/\/tile\/\d+\/\d+\/\d+/);
/**
* Classify a URL into the appropriate tile cache.
* Returns `null` for non-tile requests, or for tile providers we deliberately
* do NOT cache (e.g. Google caching is forbidden by their ToS).
*/
function getTileCacheName(url) {
const host = url.hostname;
// OpenStreetMap — tile.openstreetmap.org and a/b/c subdomains
if (host.endsWith('tile.openstreetmap.org')) return TILES_OSM;
// OpenTopoMap — a/b/c.tile.opentopomap.org
if (host.endsWith('tile.opentopomap.org') || host.endsWith('opentopomap.org')) return TILES_TOPO;
// Carto Basemaps — light_all / dark_all distinguished by path
if (host.endsWith('basemaps.cartocdn.com')) {
if (url.pathname.includes('/light_all/')) return TILES_CARTO_LIGHT;
if (url.pathname.includes('/dark_all/')) return TILES_CARTO_DARK;
return null; // unknown Carto style
}
// Esri — server.arcgisonline.com
if (host.endsWith('arcgisonline.com')) return TILES_SATELLITE;
// Google — caching forbidden by ToS, do not store
if (host.endsWith('google.com') || host.endsWith('googleapis.com')) return null;
// Other tile providers (WMS endpoints, OWS, custom) — not cached at this layer
// (the user's "online only" toast handles those).
return null;
}
function isApiRequest(url) {
@ -129,7 +221,6 @@ function isModuleAsset(url) {
}
function isAppAsset(url) {
// Same origin, common asset extensions
return url.origin === self.location.origin &&
(url.pathname.endsWith('.html') ||
url.pathname.endsWith('.css') ||
@ -144,8 +235,8 @@ function isAppAsset(url) {
// ============================================================================
/**
* Cache First - Use cache, fallback to network
* Best for: App shell, static assets
* Cache First Use cache, fallback to network.
* Best for: App shell, static assets.
*/
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
@ -159,7 +250,6 @@ async function cacheFirst(request, cacheName) {
}
return response;
} catch (error) {
// Return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/offline.html');
}
@ -168,8 +258,8 @@ async function cacheFirst(request, cacheName) {
}
/**
* Network First - Try network, fallback to cache
* Best for: API requests, dynamic content
* Network First Try network, fallback to cache.
* Best for: API requests, dynamic content.
*/
async function networkFirst(request, cacheName) {
try {
@ -187,8 +277,8 @@ async function networkFirst(request, cacheName) {
}
/**
* Stale While Revalidate - Return cache immediately, update in background
* Best for: Module assets, frequently updated content
* Stale While Revalidate Return cache immediately, update in background.
* Best for: Module assets, frequently updated content.
*/
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
@ -205,37 +295,72 @@ async function staleWhileRevalidate(request, cacheName) {
}
/**
* Cache Then Network with limit - Cache tiles with size limit
* Best for: Map tiles
* Tile Cache then Network Per-host bucket with size limit.
* Cache first; on miss, fetch from network and store.
*
* Memory-conservative eviction:
* Increments an in-memory counter on every successful insert
* Only calls cache.keys() (which materialises all Request objects) every
* EVICTION_CHECK_INTERVAL inserts so the cost is amortised
* Eviction drops the oldest 10 % when over the per-host limit
*
* On network failure (offline), serves a 408 so the map renders a blank tile
* rather than throwing.
*/
async function cacheThenNetwork(request, cacheName, maxItems) {
async function tileCacheThenNetwork(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
// Check cache size and trim if needed
const keys = await cache.keys();
if (keys.length >= maxItems) {
// Remove oldest entries (first 10%)
const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1));
await Promise.all(toDelete.map(key => cache.delete(key)));
// Bump the counter; periodically run a real eviction sweep
const count = (_tileInsertCounters.get(cacheName) || 0) + 1;
_tileInsertCounters.set(cacheName, count);
if (count % EVICTION_CHECK_INTERVAL === 0) {
// Reset the counter — next sweep is another EVICTION_CHECK_INTERVAL away
_tileInsertCounters.set(cacheName, 0);
await maybeEvict(cache, cacheName);
}
cache.put(request, response.clone());
// Don't await put() — it can run after we return the response, keeping
// the fetch hot path lightweight.
cache.put(request, response.clone()).catch((err) => {
// QuotaExceededError → run an immediate eviction sweep and retry once
if (err && err.name === 'QuotaExceededError') {
maybeEvict(cache, cacheName, /* force */ true).catch(() => {});
}
});
}
return response;
} catch (error) {
// For tiles, just fail silently - map will show blank tile
// Offline — let the map renderer show a blank tile
return new Response('', { status: 408, statusText: 'Offline' });
}
}
/**
* Run an eviction sweep on a cache, dropping the oldest 10 % of entries
* when over the per-cache limit. Heavy: only call periodically.
*/
async function maybeEvict(cache, cacheName, force = false) {
try {
const limit = TILE_LIMITS[cacheName] || 1500;
const keys = await cache.keys();
if (force || keys.length >= limit) {
const drop = Math.max(1, Math.ceil(limit * 0.1));
const toDelete = keys.slice(0, drop);
await Promise.all(toDelete.map((k) => cache.delete(k)));
}
} catch (err) {
console.warn('[SW] eviction sweep failed for', cacheName, err);
}
}
// ============================================================================
// MESSAGE HANDLING
// ============================================================================
@ -257,15 +382,32 @@ self.addEventListener('message', (event) => {
break;
case 'GET_CACHE_STATUS':
getCacheStatus().then(status => {
getCacheStatus().then((status) => {
event.source.postMessage({ type: 'CACHE_STATUS', status });
});
break;
// ----- Tile-cache management (Phase 1 offline maps) -----
case 'GET_TILE_STATS':
getTileStats().then((stats) => {
event.source.postMessage({ type: 'TILE_STATS', stats });
});
break;
case 'CLEAR_TILE_CACHES':
clearTileCaches().then(() => {
event.source.postMessage({ type: 'TILE_CACHES_CLEARED' });
});
break;
}
});
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Cache specific modules on demand
* Cache specific modules on demand.
*/
async function cacheModules(moduleNames) {
const cache = await caches.open(MODULES_CACHE);
@ -279,7 +421,6 @@ async function cacheModules(moduleNames) {
];
await cache.addAll(moduleAssets.filter(async (url) => {
// Only cache assets that exist
try {
const response = await fetch(url, { method: 'HEAD' });
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() {
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() {
const cacheNames = await caches.keys();
@ -319,3 +461,76 @@ async function getCacheStatus() {
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]));
}

View File

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

View File

@ -2,19 +2,64 @@
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#005eb8">
<meta name="description" content="LUPMIS2 Drawing Tools">
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/luspa.icon">
<link rel="icon" href="/icons/luspa.icon">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" sizes="192x192" href="icons/luspa-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/luspa-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/luspa-16x16.png">
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Exo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
<style>
/* Bebas Neue 400 — latin-ext */
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/bebas-neue-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Bebas Neue 400 — latin */
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/bebas-neue-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Exo 300-800 — vietnamese */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* Exo 300-800 — latin-ext */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Exo 300-800 — latin */
@font-face {
font-family: 'Exo';
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/exo-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
</style>
<title>LUPMIS2 Drawing Tools</title>
@ -106,6 +151,295 @@
--radius-2xl: 1rem;
}
/* ─── Fieldwork Mode ─── high-contrast + larger touch targets ─── */
.fieldwork-mode {
--foreground: #000;
--background: #fff;
--card: #fff;
--card-foreground: #000;
--primary: #0044aa;
--primary-foreground: #fff;
--primary-hover: #003080;
--muted: #e0e0e0;
--muted-foreground: #333;
--accent: #cce0ff;
--accent-foreground: #000;
--border: rgba(0,0,0,0.25);
--success: #005a00;
--success-foreground: #fff;
--warning: #b36b00;
--warning-foreground: #000;
--destructive: #b80000;
--destructive-foreground: #fff;
--ring: #0044aa;
--bs-body-color: #000;
}
/* Fieldwork: larger dock buttons */
.fieldwork-mode .dock-btn {
min-width: 72px;
min-height: 58px;
font-size: 1.6rem;
border-width: 2px;
}
.fieldwork-mode .dock-btn-label {
font-size: 0.75rem;
font-weight: 600;
}
/* Fieldwork: bolder navbar */
.fieldwork-mode .navbar {
border-bottom-width: 4px;
}
.fieldwork-mode .navbar .navbar-brand {
font-size: 1.6rem;
}
/* Fieldwork: larger offcanvas toggle buttons */
.fieldwork-mode .offcanvas-toggle {
width: 44px;
height: 44px;
font-size: 1.2rem;
}
/* Fieldwork: thicker bottom dock border */
.fieldwork-mode .bottom-dock {
border-top-width: 4px;
}
/* Fieldwork: larger text in cards / lists */
.fieldwork-mode .card-header h6 {
font-size: 1rem;
}
.fieldwork-mode .list-group-item {
font-size: 0.95rem;
padding: 0.65rem 1rem;
}
/* Fieldwork: larger buttons globally */
.fieldwork-mode .btn {
font-size: 0.95rem;
padding: 0.5rem 1rem;
font-weight: 600;
}
.fieldwork-mode .btn-sm {
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
}
/* Fieldwork: stronger borders on inputs / form controls */
.fieldwork-mode .form-control,
.fieldwork-mode .form-select {
border-width: 2px;
border-color: #555;
font-size: 1rem;
}
/* Fieldwork: bolder map controls (ol-ext) */
.fieldwork-mode .ol-control button {
font-size: 1.3rem;
width: 2.2em;
height: 2.2em;
}
/* Fieldwork: scale bar text legibility */
.fieldwork-mode .ol-scale-bar .ol-scale-step-text,
.fieldwork-mode .ol-scale-bar .ol-scale-text {
font-size: 12px;
font-weight: 700;
text-shadow: 0 0 4px #fff, 0 0 8px #fff;
}
/* ─── Dark Mode ─── reversed colour scheme ─── */
.dark-mode {
--foreground: #e0dff0;
--background: #131325;
--card: #1e1e38;
--card-foreground: #e0dff0;
--primary: #4d9de6;
--primary-foreground: #fff;
--primary-hover: #6fb3f0;
--muted: #272745;
--muted-foreground: #9594a8;
--accent: #1e3a5f;
--accent-foreground: #e0dff0;
--border: rgba(255,255,255,0.12);
--ring: #4d9de6;
--success: #2dd46a;
--success-foreground: #131325;
--warning: #ffb84d;
--warning-foreground: #131325;
--destructive: #f04040;
--destructive-foreground: #fff;
--bs-body-color: #e0dff0;
--bs-body-bg: #131325;
--bs-tertiary-bg: #1e1e38;
color-scheme: dark;
}
/* Dark: navbar */
.dark-mode .navbar {
background-color: #1a1a30 !important;
box-shadow: 0 1px 6px rgba(0,0,0,0.4);
}
/* Dark: bottom dock */
.dark-mode .bottom-dock {
background-color: #1a1a30;
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
}
.dark-mode .dock-btn {
border-color: var(--primary);
color: var(--foreground);
}
.dark-mode .dock-btn:hover {
background-color: var(--muted);
}
.dark-mode .dock-btn.active {
background-color: var(--primary);
color: var(--primary-foreground);
}
/* Dark: offcanvas panels */
.dark-mode .offcanvas {
background-color: var(--background) !important;
color: var(--foreground) !important;
}
.dark-mode .offcanvas-header {
border-bottom-color: var(--border) !important;
}
.dark-mode .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Dark: cards */
.dark-mode .card {
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border-color: var(--border) !important;
}
/* Dark: offcanvas toggle buttons */
.dark-mode .offcanvas-toggle {
background-color: var(--card);
color: var(--foreground);
border-color: var(--border);
}
.dark-mode .offcanvas-toggle:hover {
background-color: var(--primary);
color: var(--primary-foreground);
}
/* Dark: form controls */
.dark-mode .form-control,
.dark-mode .form-select {
background-color: var(--muted) !important;
color: var(--foreground) !important;
border-color: var(--border) !important;
}
.dark-mode .form-check-input {
background-color: var(--muted);
border-color: var(--muted-foreground);
}
.dark-mode .form-check-input:checked {
background-color: var(--primary);
border-color: var(--primary);
}
/* Dark: list groups */
.dark-mode .list-group-item {
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border-color: var(--border) !important;
}
/* Dark: buttons */
.dark-mode .btn-outline-primary {
color: var(--primary);
border-color: var(--primary);
}
.dark-mode .btn-outline-danger {
color: var(--destructive);
border-color: var(--destructive);
}
/* Dark: text utilities */
.dark-mode .text-muted {
color: var(--muted-foreground) !important;
}
/* Dark: measurement tooltips */
.dark-mode .measure-tooltip {
background: rgba(30, 30, 56, 0.95);
color: var(--foreground);
border-color: var(--primary);
}
.dark-mode .measure-tooltip::before {
border-right-color: var(--primary);
}
/* Dark: OL controls */
.dark-mode .ol-control button {
background-color: var(--card) !important;
color: var(--foreground) !important;
}
.dark-mode .ol-control button:hover {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
}
.dark-mode .ol-attribution,
.dark-mode .ol-attribution a {
color: var(--muted-foreground) !important;
}
/* Dark: scale bar */
.dark-mode .ol-scale-bar .ol-scale-step-text,
.dark-mode .ol-scale-bar .ol-scale-text {
color: #fff !important;
text-shadow: 0 0 4px #000, 0 0 8px #000 !important;
}
.dark-mode .ol-scale-bar .ol-scale-singlebar-even {
background-color: #fff !important;
}
.dark-mode .ol-scale-bar .ol-scale-singlebar-odd {
background-color: #999 !important;
}
/* Dark: map drop overlay */
.dark-mode .map-drop-overlay {
background: rgba(19, 19, 37, 0.85);
border-color: var(--primary);
color: var(--foreground);
}
/* Dark: ol-ext LayerSwitcher */
.dark-mode .ol-layerswitcher {
background-color: var(--card) !important;
}
.dark-mode .ol-layerswitcher .panel {
background-color: var(--card) !important;
color: var(--foreground) !important;
}
.dark-mode .ol-layerswitcher .panel li {
color: var(--foreground);
}
.dark-mode .ol-layerswitcher .ol-switchertopdiv,
.dark-mode .ol-layerswitcher .ol-switcherbottomdiv {
background: var(--card) !important;
}
/* Dark: alert boxes */
.dark-mode .alert-danger {
background-color: rgba(240, 64, 64, 0.15) !important;
color: var(--destructive) !important;
border-color: var(--destructive) !important;
}
.dark-mode .alert-success {
background-color: rgba(45, 212, 106, 0.15) !important;
color: var(--success) !important;
border-color: var(--success) !important;
}
/* Full height layout */
html, body {
height: 100%;
@ -214,9 +548,12 @@
font-family: var(--font-body);
}
/* Main container - full height */
/* Main container - full height.
100dvh accounts for mobile browser chrome and OS nav bars.
Falls back to 100vh for older browsers. */
.app-container {
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
}
@ -244,6 +581,35 @@
height: 100%;
}
/* Drag-and-drop overlay shown when files are dragged over the map */
.map-drop-overlay {
position: absolute;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 94, 184, 0.15);
border: 3px dashed var(--primary, #005eb8);
border-radius: 8px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.map-container.drag-over .map-drop-overlay {
opacity: 1;
}
.map-drop-overlay span {
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 1.15rem;
font-weight: 600;
color: var(--primary, #005eb8);
background: var(--card, #fff);
padding: 0.6rem 1.4rem;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
/* Offline indicator */
#offline-indicator {
display: none;
@ -301,9 +667,14 @@
z-index: 1050;
}
/* Fix ol-ext LayerSwitcher z-index */
.ol-layerswitcher {
z-index: 100;
/* OL controls stacking context fix — OpenLayers sets z-index:0 on
.ol-overlaycontainer-stopevent, trapping all controls below the
offcanvas-toggle buttons (z-index:500). Raising the container
to 501 lets the LayerSwitcher dropdown render above the toggles.
pointer-events:none on the container still lets clicks through
to the toggle buttons underneath. */
.ol-overlaycontainer-stopevent {
z-index: 501 !important;
}
/* Alert hint box */
@ -419,7 +790,7 @@
}
.offcanvas-toggle-bottom {
bottom: 80px; /* Above the dock */
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); /* Above the dock */
left: 50%;
transform: translateX(-50%);
}
@ -432,7 +803,10 @@
transform: translateX(-50%) scale(0.95);
}
/* Bottom Dock — white card style with blue-strong accent */
/* Bottom Dock — white card style with blue-strong accent.
env(safe-area-inset-bottom) adds padding on devices with a
home indicator / gesture bar (e.g. iPhone notch models).
The value is 0 on devices without an inset. */
.bottom-dock {
position: absolute;
bottom: 0;
@ -441,7 +815,7 @@
z-index: 600;
background-color: var(--card);
border-top: 3px solid var(--primary);
padding: 8px 16px;
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
display: flex;
justify-content: space-around;
align-items: center;
@ -503,6 +877,13 @@
justify-content: center;
}
/* Snap-guides toggle — highlighted when active */
.ol-snap-toggle.ol-active button {
background: var(--primary) !important;
color: var(--primary-foreground, #fff) !important;
border-radius: 3px;
}
/* Touch-friendly improvements for forms and buttons */
.form-control, .form-select {
min-height: 44px;
@ -541,6 +922,18 @@
color: var(--foreground);
}
/* Message log in the right panel */
.message-log {
max-height: 260px;
overflow-y: auto;
}
.message-log-entry {
font-size: 0.82rem;
border-color: var(--border, #eee) !important;
background: transparent;
}
/* ol-ext GeolocationButton styling */
.ol-geobt {
top: auto !important;
@ -830,6 +1223,7 @@
/* Locations list in offcanvas - can be taller now without form */
.offcanvas-end .locations-list {
max-height: calc(100vh - 280px);
max-height: calc(100dvh - 280px);
overflow-y: auto;
}
@ -921,17 +1315,32 @@
font-size: 18px;
}
/* ScaleLine - position above the bottom dock */
.ol-scale-line {
bottom: 76px !important;
/* ScaleBar - position above the bottom dock with 4px gap */
.ol-scale-bar {
bottom: calc(85px + env(safe-area-inset-bottom, 0px)) !important;
left: 10px !important;
}
.ol-scale-line-inner {
border-color: var(--foreground) !important;
.ol-scale-bar .ol-scale-step-text {
color: var(--foreground) !important;
font-family: var(--font-body) !important;
font-size: 11px !important;
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
}
.ol-scale-bar .ol-scale-text {
color: var(--foreground) !important;
font-family: var(--font-body) !important;
font-size: 11px !important;
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
}
.ol-scale-bar .ol-scale-singlebar-even {
background-color: var(--foreground) !important;
}
.ol-scale-bar .ol-scale-singlebar-odd {
background-color: var(--muted-foreground) !important;
}
/* ol-ext Bar overrides */
@ -980,6 +1389,7 @@
</span>
</div>
<div id="map"></div>
<div class="map-drop-overlay"><span><i class="bi bi-file-earmark-arrow-up me-2"></i>Drop file to import (.shp .geojson .kml)</span></div>
<!-- Offcanvas toggle buttons -->
<button class="offcanvas-toggle offcanvas-toggle-left"
@ -1104,17 +1514,40 @@
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="local-data-btn">
<i class="bi bi-database me-2"></i>Local Data
</button>
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-shp-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import .shp
</button>
<input type="file" id="shp-file-input" accept=".zip,.shp,.dbf,.shx,.prj" multiple class="d-none">
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-geojson-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import GeoJSON
</button>
<input type="file" id="geojson-file-input" accept=".geojson,.json" class="d-none">
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-kml-btn">
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import KML
</button>
<input type="file" id="kml-file-input" accept=".kml,.kmz" class="d-none">
<div id="file-import-alert" class="alert alert-danger alert-dismissible fade show d-none mb-3" role="alert">
<small class="message-text"></small>
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div id="imported-layers-info" class="d-none mb-3"></div>
<div id="local-data-stats" class="d-none">
<div class="card">
<div class="card-header bg-primary py-2">
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-database me-2"></i>Local Database Tables</h6>
<button type="button" class="btn btn-sm btn-outline-light"
id="clear-all-cached-btn"
title="Delete all cached map layers. They will be re-downloaded on next app start.">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh cached layers
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th class="ps-3">Table</th>
<th class="text-end pe-3">Records</th>
<th class="text-end">Records</th>
<th class="text-end pe-3" style="width:3rem;"></th>
</tr>
</thead>
<tbody id="local-data-tbody">
@ -1142,6 +1575,10 @@
<span class="message-text"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div id="warning-message" class="alert alert-warning alert-dismissible fade show d-none" role="alert">
<span class="message-text"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<!-- Tip -->
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
@ -1181,20 +1618,268 @@
</div>
</div>
</div>
<!-- Message Log -->
<div class="card mt-3" id="message-log-card">
<div class="card-header bg-transparent py-2 d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="font-family:var(--font-body);font-weight:700;"><i class="bi bi-journal-text me-1"></i> Messages</h6>
<button class="btn btn-sm btn-link text-muted p-0" id="clear-message-log" title="Clear messages">
<i class="bi bi-trash3"></i>
</button>
</div>
<div class="card-body p-0">
<div id="message-log" class="message-log list-group list-group-flush">
<div class="text-center text-muted py-3">
<small>No messages yet.</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Offcanvas -->
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-chevron-down me-2"></i>Bottom Panel</h5>
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-gear me-2"></i>Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
</div>
<div class="offcanvas-body">
<p>This is the bottom offcanvas panel.</p>
<p>You can add a data table, charts, or other wide content here.</p>
<div class="row g-3">
<!-- Fieldwork Mode -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Fieldwork Mode</h6>
<small class="text-muted">High-contrast colours and larger touch targets for bright sunlight and field conditions.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="fieldwork-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
</div>
</div>
</div>
</div>
</div>
<!-- Dark Mode -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Dark Mode</h6>
<small class="text-muted">Reduce glare and save battery with a dark colour scheme.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="dark-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
</div>
</div>
</div>
</div>
</div>
<!-- Measurement System -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Measurement System</h6>
<small class="text-muted">Switch between Metric (m, km) and Imperial (ft, mi, acres) units.</small>
</div>
<div class="form-check form-switch ms-3">
<input class="form-check-input" type="checkbox" role="switch" id="measurement-system-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
<label class="form-check-label ms-1" id="measurement-system-label" for="measurement-system-toggle" style="font-size:0.8rem;font-weight:600;min-width:55px;">Metric</label>
</div>
</div>
</div>
</div>
</div>
<!-- Default Base Map -->
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Default Base Map</h6>
<small class="text-muted">Base map shown on app start. Saved on this device.</small>
</div>
<div class="ms-3" style="min-width:140px;">
<select class="form-select form-select-sm" id="default-basemap-select" aria-label="Default base map">
<option value="topo">Topographic</option>
<option value="osm">OpenStreetMap</option>
<option value="satellite">Satellite</option>
<option value="googlesat">Google Sat</option>
<option value="carto-light">Carto Light</option>
<option value="carto-dark">Carto Dark</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Offline Map Tiles -->
<div class="col-12 col-md-6 col-lg-8">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between mb-2">
<div style="flex:1;min-width:0;">
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">
<i class="bi bi-map me-1"></i>Offline Map Tiles
</h6>
<small class="text-muted">
Map tiles you've already viewed are cached on this device so they work offline.
Tiles are cached automatically as you browse, or you can pre-download a region.
</small>
</div>
<div class="ms-3 d-flex gap-2 flex-shrink-0">
<button type="button" class="btn btn-sm btn-primary"
id="download-tiles-btn" style="white-space:nowrap;">
<i class="bi bi-cloud-download me-1"></i>Download offline map
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
id="clear-tiles-btn" style="white-space:nowrap;">
<i class="bi bi-trash3 me-1"></i>Clear cached tiles
</button>
</div>
</div>
<div id="tile-cache-stats" class="small">
<div class="text-muted fst-italic">Loading…</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Download Offline Map modal -->
<div class="modal fade" id="offline-download-modal" tabindex="-1" aria-labelledby="offline-download-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="offline-download-title">
<i class="bi bi-cloud-download me-2"></i>Download Offline Map
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="offline-download-close-btn"></button>
</div>
<!-- Form view (shown until Start is clicked) -->
<div class="modal-body" id="offline-download-form-view">
<p class="text-muted small mb-3">
Pre-fetch map tiles so they're available when you're offline.
Only the OpenStreetMap and Topographic base maps can be downloaded;
other providers don't permit bulk caching.
</p>
<!-- Base map -->
<div class="mb-3">
<label for="offline-basemap-select" class="form-label fw-bold">Base map</label>
<select class="form-select form-select-sm" id="offline-basemap-select">
<option value="topo">Topographic</option>
<option value="osm">OpenStreetMap</option>
</select>
</div>
<!-- Area -->
<div class="mb-3">
<label class="form-label fw-bold mb-1">Area to download</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-view" value="view" checked>
<label class="form-check-label" for="offline-area-view">
Current map view
<span class="text-muted small" id="offline-area-view-info"></span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-district" value="district">
<label class="form-check-label" for="offline-area-district">
District boundary
<span class="text-muted small" id="offline-area-district-info"></span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-ghana" value="ghana">
<label class="form-check-label" for="offline-area-ghana">
Entire Ghana <span class="text-muted small">(very large — only attempt over fast Wi-Fi)</span>
</label>
</div>
</div>
<!-- Zoom range -->
<div class="mb-3">
<label class="form-label fw-bold mb-1">Zoom levels</label>
<div class="row g-2 align-items-center">
<div class="col-auto"><label for="offline-min-zoom" class="form-label small mb-0">Min</label></div>
<div class="col-auto">
<input type="number" class="form-control form-control-sm" id="offline-min-zoom" min="6" max="19" value="10" style="width:5em;">
</div>
<div class="col-auto"><label for="offline-max-zoom" class="form-label small mb-0">Max</label></div>
<div class="col-auto">
<input type="number" class="form-control form-control-sm" id="offline-max-zoom" min="6" max="19" value="15" style="width:5em;">
</div>
<div class="col text-muted small">10 = regional · 13 = neighbourhood · 16 = building</div>
</div>
</div>
<!-- Estimate -->
<div class="alert alert-info py-2 px-3 mb-3" id="offline-estimate" style="font-size:0.9em;">
<strong>Estimated download:</strong>
<span id="offline-estimate-detail">Calculating…</span>
</div>
<!-- Acknowledgement -->
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="offline-ack-check">
<label class="form-check-label small" for="offline-ack-check">
I understand this counts against the tile provider's usage quota
and will use mobile data if I'm not on Wi-Fi.
</label>
</div>
</div>
<!-- Progress view (shown during download) -->
<div class="modal-body d-none" id="offline-download-progress-view">
<div class="text-center mb-3">
<div class="fs-5 fw-bold" id="offline-progress-percent">0%</div>
<div class="small text-muted" id="offline-progress-counts">0 of 0 tiles</div>
</div>
<div class="progress mb-3" style="height:1.25em;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" id="offline-progress-bar"
aria-valuemin="0" aria-valuemax="100" style="width:0%;"></div>
</div>
<div class="row text-center small text-muted">
<div class="col"><div class="fw-bold text-body" id="offline-progress-ok">0</div>fetched</div>
<div class="col"><div class="fw-bold text-body" id="offline-progress-failed">0</div>failed</div>
<div class="col"><div class="fw-bold text-body" id="offline-progress-eta"></div>remaining</div>
</div>
</div>
<!-- Done view (shown after completion) -->
<div class="modal-body d-none" id="offline-download-done-view">
<div class="text-center py-3">
<i class="bi bi-check-circle-fill text-success" style="font-size:3rem;"></i>
<div class="fs-5 fw-bold mt-2" id="offline-done-title">Download complete</div>
<div class="text-muted" id="offline-done-detail"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="offline-download-cancel-btn">
Cancel
</button>
<button type="button" class="btn btn-primary" id="offline-download-start-btn" disabled>
<i class="bi bi-cloud-download me-1"></i>Start download
</button>
<button type="button" class="btn btn-primary d-none" id="offline-download-close-done-btn" data-bs-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<!-- Polyfill crypto.randomUUID for non-secure contexts (HTTP) -->
<!-- Must run before module imports (SQLocal/coincident require it) -->

1531
main.js

File diff suppressed because it is too large Load Diff

283
package-lock.json generated
View File

@ -1,18 +1,21 @@
{
"name": "lupmis-pwa",
"name": "lupmis2-pwa",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lupmis-pwa",
"name": "lupmis2-pwa",
"version": "1.0.0",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"ol": "^10.3.0",
"ol-ext": "^4.0.24",
"shpjs": "^6.2.0",
"sqlocal": "^0.16.0"
},
"devDependencies": {
@ -22,6 +25,15 @@
"node": ">=18.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -795,12 +807,32 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/rbush": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@ -813,6 +845,16 @@
"integrity": "sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==",
"license": "ISC"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@ -848,6 +890,32 @@
],
"license": "MIT"
},
"node_modules/but-unzip": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/but-unzip/-/but-unzip-0.1.10.tgz",
"integrity": "sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==",
"license": "Apache-2.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/coincident": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz",
@ -863,6 +931,38 @@
"ws": "^8.16.0"
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
@ -911,6 +1011,17 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -929,6 +1040,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -968,12 +1085,64 @@
"node": ">=10.19"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf-autotable": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
"integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
"license": "MIT",
"peerDependencies": {
"jspdf": "^2 || ^3 || ^4"
}
},
"node_modules/lerc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
"license": "Apache-2.0"
},
"node_modules/mgrs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -1031,6 +1200,12 @@
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
"license": "MIT"
},
"node_modules/parsedbf": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parsedbf/-/parsedbf-2.0.0.tgz",
"integrity": "sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==",
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
@ -1043,6 +1218,13 @@
"pbf": "bin/pbf"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1092,6 +1274,19 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proj4": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.3.tgz",
"integrity": "sha512-uKJXnf/RkHhExxnWHqQqy2J1bPc5Qo8XSGzrMSJTdPWUQDo1DkunIRBfAS0crQaP9bZCSKNjqYJdYWVov0hDXw==",
"license": "MIT",
"dependencies": {
"mgrs": "1.0.0",
"wkt-parser": "^1.5.3"
},
"funding": {
"url": "https://github.com/sponsors/ahocevar"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
@ -1122,6 +1317,16 @@
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/rbush": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
@ -1131,6 +1336,13 @@
"quickselect": "^3.0.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
@ -1140,6 +1352,16 @@
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.55.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz",
@ -1185,6 +1407,17 @@
"fsevents": "~2.3.2"
}
},
"node_modules/shpjs": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz",
"integrity": "sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==",
"license": "MIT",
"dependencies": {
"but-unzip": "^0.1.4",
"parsedbf": "^2.0.0",
"proj4": "^2.1.4"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1237,6 +1470,36 @@
}
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -1254,6 +1517,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
@ -1335,6 +1608,12 @@
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
"license": "Apache-2.0"
},
"node_modules/wkt-parser": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.3.tgz",
"integrity": "sha512-myla+RrMj+WTlnHc8Y4HEwjBcBF9dqJ3vjff/zmlrn9V3OKOM1mZVIyNjlPEmOM9Jjr/PPut0tnaTs9NyHcK8Q==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",

View File

@ -9,11 +9,14 @@
"preview": "vite preview"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"@popperjs/core": "^2.11.8",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"ol": "^10.3.0",
"ol-ext": "^4.0.24",
"shpjs": "^6.2.0",
"sqlocal": "^0.16.0"
},
"devDependencies": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/icons/luspa-pdf.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

View File

@ -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"
}
}

View File

@ -1,58 +1,58 @@
{
"name": "LUPMIS2 Drawing Tools",
"short_name": "LUPMIS",
"short_name": "LUPMIS2",
"description": "Map and GIS functions for Land Use Planning in Ghana",
"start_url": "/",
"scope": "/",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#005eb8",
"orientation": "any",
"icons": [
{
"src": "/icons/icon-72.png",
"src": "./icons/luspa-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-96.png",
"src": "./icons/luspa-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-128.png",
"src": "./icons/luspa-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-144.png",
"src": "./icons/luspa-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-152.png",
"src": "./icons/luspa-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/luspa-192x192.png",
"src": "./icons/luspa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384.png",
"src": "./icons/luspa-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/luspa-512x512.png",
"src": "./icons/luspa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"

View File

@ -3,7 +3,7 @@
*
* Handles caching of:
* - App shell (HTML, CSS, JS)
* - Map tiles (runtime caching)
* - Map tiles (passive runtime caching, per-host buckets)
* - API responses (network-first)
*
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
@ -12,18 +12,70 @@
* - SharedWorker: Shared database connection across tabs
*/
const CACHE_VERSION = 'v1';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const TILES_CACHE = `tiles-${CACHE_VERSION}`;
// v3: lower per-cache limits (5000 → 1500) and counter-based eviction to
// prevent Safari memory-pressure reloads.
// v4: raise OSM and Topographic limits to 8000 to support active offline
// downloads (Phase 2). Other providers stay at 1500.
const CACHE_VERSION = 'v4';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
// Maximum number of tiles to cache
const MAX_TILES = 500;
// ----------------------------------------------------------------------------
// Tile caches — one per provider so users can clear them independently.
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
// which covers a Ghana district at zoom 1015 (typical field-work range).
// ----------------------------------------------------------------------------
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
// App shell assets - precached on install
// Vite will generate hashed filenames, so we cache the entry points
// and let the browser handle the hashed assets
// Per-provider tile limits.
// • OSM and Topographic are the providers offered for active offline
// download (Phase 2 dialog), so they get a higher cap (~240 MB each at
// ~30 KB/tile) — enough for a typical Ghana district at zoom 1015.
// • 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 580 KB; 30 KB is a good middle ground.
const AVG_TILE_BYTES = 30 * 1024;
// ----------------------------------------------------------------------------
// App shell assets — precached on install.
// ----------------------------------------------------------------------------
const SHELL_ASSETS = [
'/',
'/index.html',
@ -58,14 +110,22 @@ self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((cacheNames) => {
// Build the set of caches that should remain
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
return Promise.all(
cacheNames
// Delete anything that:
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
// • but is NOT in the current keep set
// This includes the legacy "tiles-v1" single bucket.
.filter((name) => {
// Delete old version caches
return (name.startsWith('shell-') && name !== SHELL_CACHE) ||
(name.startsWith('tiles-') && name !== TILES_CACHE) ||
(name.startsWith('modules-') && name !== MODULES_CACHE) ||
(name.startsWith('api-') && name !== API_CACHE);
const isOurs =
name.startsWith('shell-') ||
name.startsWith('tiles-') ||
name.startsWith('modules-') ||
name.startsWith('api-');
return isOurs && !keep.has(name);
})
.map((name) => {
console.log('[SW] Deleting old cache:', name);
@ -91,10 +151,21 @@ self.addEventListener('fetch', (event) => {
// Skip chrome-extension and other non-http(s) requests
if (!url.protocol.startsWith('http')) return;
// Route to appropriate caching strategy
if (isMapTile(url)) {
event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES));
} else if (isApiRequest(url)) {
// Skip worker files and Vite dev-server node_modules requests —
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') ||
url.search.includes('worker_file') ||
request.destination === 'worker') return;
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
const tileCache = getTileCacheName(url);
if (tileCache) {
event.respondWith(tileCacheThenNetwork(request, tileCache));
return;
}
// ----- OTHER ROUTES (unchanged) -----
if (isApiRequest(url)) {
event.respondWith(networkFirst(request, API_CACHE));
} else if (isModuleAsset(url)) {
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
@ -108,15 +179,36 @@ self.addEventListener('fetch', (event) => {
// URL CLASSIFICATION
// ============================================================================
function isMapTile(url) {
// Common tile server patterns for all our base maps
return url.hostname.includes('tile.openstreetmap.org') ||
url.hostname.includes('opentopomap.org') ||
url.hostname.includes('arcgisonline.com') ||
url.hostname.includes('basemaps.cartocdn.com') ||
url.hostname.includes('tiles.') ||
url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) ||
url.pathname.match(/\/tile\/\d+\/\d+\/\d+/);
/**
* Classify a URL into the appropriate tile cache.
* Returns `null` for non-tile requests, or for tile providers we deliberately
* do NOT cache (e.g. Google caching is forbidden by their ToS).
*/
function getTileCacheName(url) {
const host = url.hostname;
// OpenStreetMap — tile.openstreetmap.org and a/b/c subdomains
if (host.endsWith('tile.openstreetmap.org')) return TILES_OSM;
// OpenTopoMap — a/b/c.tile.opentopomap.org
if (host.endsWith('tile.opentopomap.org') || host.endsWith('opentopomap.org')) return TILES_TOPO;
// Carto Basemaps — light_all / dark_all distinguished by path
if (host.endsWith('basemaps.cartocdn.com')) {
if (url.pathname.includes('/light_all/')) return TILES_CARTO_LIGHT;
if (url.pathname.includes('/dark_all/')) return TILES_CARTO_DARK;
return null; // unknown Carto style
}
// Esri — server.arcgisonline.com
if (host.endsWith('arcgisonline.com')) return TILES_SATELLITE;
// Google — caching forbidden by ToS, do not store
if (host.endsWith('google.com') || host.endsWith('googleapis.com')) return null;
// Other tile providers (WMS endpoints, OWS, custom) — not cached at this layer
// (the user's "online only" toast handles those).
return null;
}
function isApiRequest(url) {
@ -129,7 +221,6 @@ function isModuleAsset(url) {
}
function isAppAsset(url) {
// Same origin, common asset extensions
return url.origin === self.location.origin &&
(url.pathname.endsWith('.html') ||
url.pathname.endsWith('.css') ||
@ -144,8 +235,8 @@ function isAppAsset(url) {
// ============================================================================
/**
* Cache First - Use cache, fallback to network
* Best for: App shell, static assets
* Cache First Use cache, fallback to network.
* Best for: App shell, static assets.
*/
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
@ -159,7 +250,6 @@ async function cacheFirst(request, cacheName) {
}
return response;
} catch (error) {
// Return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/offline.html');
}
@ -168,8 +258,8 @@ async function cacheFirst(request, cacheName) {
}
/**
* Network First - Try network, fallback to cache
* Best for: API requests, dynamic content
* Network First Try network, fallback to cache.
* Best for: API requests, dynamic content.
*/
async function networkFirst(request, cacheName) {
try {
@ -187,8 +277,8 @@ async function networkFirst(request, cacheName) {
}
/**
* Stale While Revalidate - Return cache immediately, update in background
* Best for: Module assets, frequently updated content
* Stale While Revalidate Return cache immediately, update in background.
* Best for: Module assets, frequently updated content.
*/
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
@ -205,37 +295,72 @@ async function staleWhileRevalidate(request, cacheName) {
}
/**
* Cache Then Network with limit - Cache tiles with size limit
* Best for: Map tiles
* Tile Cache then Network Per-host bucket with size limit.
* Cache first; on miss, fetch from network and store.
*
* Memory-conservative eviction:
* Increments an in-memory counter on every successful insert
* Only calls cache.keys() (which materialises all Request objects) every
* EVICTION_CHECK_INTERVAL inserts so the cost is amortised
* Eviction drops the oldest 10 % when over the per-host limit
*
* On network failure (offline), serves a 408 so the map renders a blank tile
* rather than throwing.
*/
async function cacheThenNetwork(request, cacheName, maxItems) {
async function tileCacheThenNetwork(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
// Check cache size and trim if needed
const keys = await cache.keys();
if (keys.length >= maxItems) {
// Remove oldest entries (first 10%)
const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1));
await Promise.all(toDelete.map(key => cache.delete(key)));
// Bump the counter; periodically run a real eviction sweep
const count = (_tileInsertCounters.get(cacheName) || 0) + 1;
_tileInsertCounters.set(cacheName, count);
if (count % EVICTION_CHECK_INTERVAL === 0) {
// Reset the counter — next sweep is another EVICTION_CHECK_INTERVAL away
_tileInsertCounters.set(cacheName, 0);
await maybeEvict(cache, cacheName);
}
cache.put(request, response.clone());
// Don't await put() — it can run after we return the response, keeping
// the fetch hot path lightweight.
cache.put(request, response.clone()).catch((err) => {
// QuotaExceededError → run an immediate eviction sweep and retry once
if (err && err.name === 'QuotaExceededError') {
maybeEvict(cache, cacheName, /* force */ true).catch(() => {});
}
});
}
return response;
} catch (error) {
// For tiles, just fail silently - map will show blank tile
// Offline — let the map renderer show a blank tile
return new Response('', { status: 408, statusText: 'Offline' });
}
}
/**
* Run an eviction sweep on a cache, dropping the oldest 10 % of entries
* when over the per-cache limit. Heavy: only call periodically.
*/
async function maybeEvict(cache, cacheName, force = false) {
try {
const limit = TILE_LIMITS[cacheName] || 1500;
const keys = await cache.keys();
if (force || keys.length >= limit) {
const drop = Math.max(1, Math.ceil(limit * 0.1));
const toDelete = keys.slice(0, drop);
await Promise.all(toDelete.map((k) => cache.delete(k)));
}
} catch (err) {
console.warn('[SW] eviction sweep failed for', cacheName, err);
}
}
// ============================================================================
// MESSAGE HANDLING
// ============================================================================
@ -257,15 +382,32 @@ self.addEventListener('message', (event) => {
break;
case 'GET_CACHE_STATUS':
getCacheStatus().then(status => {
getCacheStatus().then((status) => {
event.source.postMessage({ type: 'CACHE_STATUS', status });
});
break;
// ----- Tile-cache management (Phase 1 offline maps) -----
case 'GET_TILE_STATS':
getTileStats().then((stats) => {
event.source.postMessage({ type: 'TILE_STATS', stats });
});
break;
case 'CLEAR_TILE_CACHES':
clearTileCaches().then(() => {
event.source.postMessage({ type: 'TILE_CACHES_CLEARED' });
});
break;
}
});
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Cache specific modules on demand
* Cache specific modules on demand.
*/
async function cacheModules(moduleNames) {
const cache = await caches.open(MODULES_CACHE);
@ -279,7 +421,6 @@ async function cacheModules(moduleNames) {
];
await cache.addAll(moduleAssets.filter(async (url) => {
// Only cache assets that exist
try {
const response = await fetch(url, { method: 'HEAD' });
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() {
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() {
const cacheNames = await caches.keys();
@ -319,3 +461,76 @@ async function getCacheStatus() {
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]));
}

View 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
-- ============================================================================

View File

@ -18,6 +18,7 @@ import { LineString, Circle, Polygon } from 'ol/geom';
import { getLength, getArea } from 'ol/sphere';
import Feature from 'ol/Feature';
import { unByKey } from 'ol/Observable';
import { formatLength, formatArea, formatCircleExtent } from '../units.js';
// ol-ext imports
import EditBar from 'ol-ext/control/EditBar';
@ -116,40 +117,6 @@ export class MapTools {
});
}
/**
* Format length output
*/
formatLength(length) {
if (length > 1000) {
return (Math.round(length / 1000 * 100) / 100) + ' km';
} else {
return (Math.round(length * 100) / 100) + ' m';
}
}
/**
* Format area output
*/
formatArea(area) {
if (area > 1000000) {
return (Math.round(area / 1000000 * 100) / 100) + ' km²';
} else {
return (Math.round(area * 100) / 100) + ' m²';
}
}
/**
* Format circle extent (bounding box area)
*/
formatCircleExtent(radius) {
const area = Math.PI * radius * radius;
if (area > 1000000) {
return (Math.round(area / 1000000 * 100) / 100) + ' km²';
} else {
return (Math.round(area * 100) / 100) + ' m²';
}
}
/**
* Create measurement tooltip overlay
*/
@ -231,8 +198,8 @@ export class MapTools {
if (geom instanceof Circle) {
const radius = geom.getRadius();
const area = this.formatCircleExtent(radius);
const radiusFormatted = this.formatLength(radius);
const area = formatCircleExtent(radius);
const radiusFormatted = formatLength(radius);
const output = `<strong>${radiusFormatted}</strong><br><small>${area}</small>`;
@ -311,7 +278,7 @@ export class MapTools {
listener = sketch.getGeometry().on('change', (e) => {
const geom = e.target;
const length = getLength(geom);
const output = this.formatLength(length);
const output = formatLength(length);
this.measureTooltipElement.innerHTML = output;
this.measureTooltip.setPosition(geom.getLastCoordinate());
@ -364,7 +331,7 @@ export class MapTools {
listener = sketch.getGeometry().on('change', (e) => {
const geom = e.target;
const area = getArea(geom);
const output = this.formatArea(area);
const output = formatArea(area);
this.measureTooltipElement.innerHTML = output;
this.measureTooltip.setPosition(geom.getInteriorPoint().getCoordinates());
@ -376,6 +343,10 @@ export class MapTools {
const geom = feature.getGeometry();
const area = getArea(geom);
// Tag so the double-click handler can identify it
feature.set('_layerType', 'measure_area');
feature.set('_area', area);
this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';
this.measureTooltipElement = null;
this.createMeasureTooltip();

File diff suppressed because it is too large Load Diff

View File

@ -196,6 +196,17 @@ export async function initSchema() {
)
`;
// Create osm_roads table for caching the OSM road network
console.log('[Database] Creating osm_roads table...');
await sql`
CREATE TABLE IF NOT EXISTS osm_roads (
osm_id INTEGER PRIMARY KEY,
geometry_wkt TEXT,
properties TEXT,
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`;
// Create indexes
await sql`CREATE INDEX IF NOT EXISTS idx_locations_category ON locations(category)`;
await sql`CREATE INDEX IF NOT EXISTS idx_locations_synced ON locations(synced)`;
@ -541,17 +552,20 @@ export async function getLocalCollectorZones() {
export async function saveParcels(parcels) {
try {
await sql`DELETE FROM parcels`;
let saved = 0;
for (const p of parcels) {
const props = JSON.stringify(p);
// API field names may vary — try common WKT field names
const wkt = p.polygon || p.boundary || p.geom || p.wkt || '';
const id = p.id || p.parcelid || p.parcel_id || null;
if (id == null) continue; // skip rows without a usable ID
const props = JSON.stringify(p);
// API field names: 'boundary' (WKT), 'polygon', 'geom', 'wkt'
const wkt = p.boundary || p.polygon || p.geom || p.wkt || '';
await sql`
INSERT INTO parcels (id, geometry_wkt, properties, fetched_at)
INSERT OR REPLACE INTO parcels (id, geometry_wkt, properties, fetched_at)
VALUES (${id}, ${wkt}, ${props}, CURRENT_TIMESTAMP)
`;
saved++;
}
console.log('[Database] ✓ Saved', parcels.length, 'parcels');
console.log('[Database] ✓ Saved', saved, 'parcels (from', parcels.length, 'rows,', parcels.length - saved, 'duplicates replaced)');
} catch (error) {
console.error('[Database] ✗ Failed to save parcels:', error);
throw error;
@ -680,6 +694,62 @@ export async function getLocalBuildingFootprints() {
}
}
/**
* Save OSM roads to the local SQLite table.
* Replaces all existing rows.
*
* @param {Array} roads - Array of road objects from the API
*/
export async function saveOSMRoads(roads) {
try {
if (roads.length > 0) {
const first = roads[0];
const types = {};
for (const [k, v] of Object.entries(first)) {
types[k] = v === null ? 'null' : typeof v;
}
console.log('[Database] First road field types:', types);
}
await sql`DELETE FROM osm_roads`;
for (const r of roads) {
const props = JSON.stringify(r);
// Geometry — may arrive as WKT string or GeoJSON object
let rawWkt = r.geom || r.geometry || r.wkt || r.road || r.line || '';
const wkt = typeof rawWkt === 'object' ? JSON.stringify(rawWkt) : String(rawWkt);
// osm_id must be a primitive — fall back to null if missing or malformed
let rawId = r.osm_id ?? r.osmid ?? r.id ?? null;
const osmId = (rawId !== null && typeof rawId === 'object') ? null : rawId;
await sql`
INSERT OR REPLACE INTO osm_roads (osm_id, geometry_wkt, properties, fetched_at)
VALUES (${osmId}, ${wkt}, ${props}, CURRENT_TIMESTAMP)
`;
}
console.log('[Database] ✓ Saved', roads.length, 'OSM roads');
} catch (error) {
console.error('[Database] ✗ Failed to save OSM roads:', error);
throw error;
}
}
/**
* Load all cached OSM roads from the local table.
* @returns {Promise<Array|null>} Array of road objects, or null if empty
*/
export async function getLocalOSMRoads() {
try {
const rows = await sql`SELECT properties FROM osm_roads ORDER BY osm_id`;
if (rows.length === 0) return null;
return rows.map(r => JSON.parse(r.properties));
} catch (error) {
console.error('[Database] ✗ Failed to read local OSM roads:', error);
return null;
}
}
// ============================================================================
// Export / Import
// ============================================================================
@ -769,6 +839,90 @@ export async function getDatabaseStatus() {
}
}
// ============================================================================
// Cached-Layer Management
// ============================================================================
/**
* Tables that hold data fetched from the server.
*
* Clearing these is safe the corresponding loaders (loadParcels,
* loadBuildingFootprints, loadOSMRoads, loadCollectorZones, ) will re-fetch
* the data from the API on the next app start.
*
* NOT included: user-created tables (`locations`, `pending_changes`) those
* hold local work that must not be auto-deleted.
*/
export const CACHED_LAYER_TABLES = Object.freeze([
'parcels',
'building_footprints',
'osm_roads',
'collector_zones',
'remote_data',
]);
/**
* Check whether a table name is in the cleared-layer allow-list.
* @param {string} tableName
* @returns {boolean}
*/
export function isCachedLayerTable(tableName) {
return CACHED_LAYER_TABLES.includes(tableName);
}
/**
* Delete all rows from a single cached-layer table.
* Rejects unknown table names so this can't be abused to drop user data.
*
* @param {string} tableName - One of CACHED_LAYER_TABLES
* @returns {Promise<number>} Number of rows that were in the table before deletion
*/
export async function clearTable(tableName) {
if (!isCachedLayerTable(tableName)) {
throw new Error(`Refusing to clear "${tableName}" — not a known cached-layer table`);
}
const before = await sql(`SELECT COUNT(*) AS n FROM "${tableName}"`);
const count = before[0]?.n ?? 0;
await sql(`DELETE FROM "${tableName}"`);
console.log(`[Database] ✓ Cleared "${tableName}" (${count} rows)`);
broadcastChange(tableName, 'CLEAR', null);
return count;
}
/**
* Clear every cached-layer table (whatever exists in this database).
* Tables that don't exist yet are skipped silently.
*
* @returns {Promise<{ table: string, count: number }[]>} per-table report
*/
export async function clearAllCachedLayers() {
const existing = await sql`
SELECT name FROM sqlite_master
WHERE type='table' AND name IN (
'parcels', 'building_footprints', 'osm_roads', 'collector_zones', 'remote_data'
)
`;
const existingNames = new Set(existing.map((r) => r.name));
const results = [];
for (const tableName of CACHED_LAYER_TABLES) {
if (!existingNames.has(tableName)) continue;
try {
const count = await clearTable(tableName);
results.push({ table: tableName, count });
} catch (err) {
console.error(`[Database] Failed to clear ${tableName}:`, err);
results.push({ table: tableName, count: 0, error: err.message });
}
}
const total = results.reduce((s, r) => s + r.count, 0);
console.log(`[Database] ✓ Cleared all cached layers: ${total} rows across ${results.length} tables`);
return results;
}
/**
* Get a list of all tables with their row counts.
* @returns {Promise<Array<{name: string, count: number}>>}
@ -890,6 +1044,12 @@ export default {
insertNewParcel,
saveBuildingFootprints,
getLocalBuildingFootprints,
saveOSMRoads,
getLocalOSMRoads,
CACHED_LAYER_TABLES,
isCachedLayerTable,
clearTable,
clearAllCachedLayers,
exportDatabase,
exportToGeoJSON,
importDatabase,

295
src/geom/polygonDivide.js Normal file
View 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
View 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 PQ along the shared boundary, B walks QP.
*
* 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
View 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: p1p2, Segment B: p3p4.
*
* @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 (01),
* u = parametric position on segment B (01)
*/
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];
}

View 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 = '';
}
}
}

View 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 = '';
}
}
}

View 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
View 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
View 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);
}
}

View File

@ -276,6 +276,158 @@ export function clearUserCaches() {
});
}
/**
* Get the Service Worker we can postMessage to. Resolves with:
* `navigator.serviceWorker.controller` if it's already in control of the
* page (fastest path), or
* `registration.active` once `navigator.serviceWorker.ready` resolves
* (covers the first-load case before the SW has claimed the page).
*
* Rejects after `timeoutMs` if no SW becomes available which would only
* happen in a private/incognito context, an unsupported browser, or when
* registration genuinely failed.
*
* @param {{ timeoutMs?: number }} [opts]
* @returns {Promise<ServiceWorker>}
*/
export async function getActiveServiceWorker({ timeoutMs = 10000 } = {}) {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Workers not supported in this browser');
}
// Fastest path — page is already SW-controlled
if (navigator.serviceWorker.controller) {
return navigator.serviceWorker.controller;
}
// Otherwise wait for the registration to become ready (active SW exists
// for this scope, even if it hasn't claimed THIS page yet)
const ready = navigator.serviceWorker.ready;
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Service-worker readiness timeout')), timeoutMs)
);
const registration = await Promise.race([ready, timeout]);
// The controller may have appeared while we were waiting; otherwise use
// the registration's active worker (we can still postMessage to it — caches
// are shared across the origin)
const sw = navigator.serviceWorker.controller || registration.active;
if (!sw) {
throw new Error('No active service worker available');
}
return sw;
}
/**
* Subscribe to controller-change events. The callback fires whenever a new
* Service Worker takes control of the page (e.g. after an SW update or first
* activation on initial load). Useful for re-querying SW-backed state once
* the SW has actually taken over.
*
* @param {() => void} callback
* @returns {() => void} unsubscribe function
*/
export function onServiceWorkerControllerChange(callback) {
if (!('serviceWorker' in navigator)) return () => {};
const handler = () => {
try { callback(); } catch (e) { console.error('[PWA] controllerchange handler error:', e); }
};
navigator.serviceWorker.addEventListener('controllerchange', handler);
return () => navigator.serviceWorker.removeEventListener('controllerchange', handler);
}
/**
* Send a message to the service worker and wait for a single reply with the
* given response type. Waits for the SW to become available if it isn't yet.
* Resolves with the reply payload, or rejects after a timeout.
*
* @template T
* @param {string} requestType - Message type to send (e.g. 'GET_TILE_STATS')
* @param {string} responseType - Message type expected back (e.g. 'TILE_STATS')
* @param {Object} [extra={}] - Extra fields merged into the outgoing message
* @param {number} [timeoutMs=5000] Reply timeout (after the SW is available)
* @param {number} [readyTimeoutMs=10000] Timeout for the SW to be available
* @returns {Promise<T>}
*/
async function requestFromServiceWorker(requestType, responseType, extra = {}, timeoutMs = 5000, readyTimeoutMs = 10000) {
const sw = await getActiveServiceWorker({ timeoutMs: readyTimeoutMs });
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timer = setTimeout(() => {
channel.port1.close();
reject(new Error(`Service-worker reply "${responseType}" timed out`));
}, timeoutMs);
channel.port1.onmessage = (event) => {
if (event.data?.type === responseType) {
clearTimeout(timer);
channel.port1.close();
const { type, ...rest } = event.data;
resolve(rest);
}
};
sw.postMessage({ type: requestType, ...extra }, [channel.port2]);
});
}
/**
* Get statistics about tiles cached locally on this device, broken down by
* provider. Waits up to `readyTimeoutMs` for the service worker to become
* available. Returns null only if the SW genuinely cannot be reached
* (private mode, registration failure, or timeout).
*
* @returns {Promise<{
* totals: { count: number, estBytes: number },
* byProvider: Array<{ key: string, label: string, count: number, limit: number, estBytes: number }>
* } | null>}
*/
export async function getTileCacheStats() {
try {
const reply = await requestFromServiceWorker('GET_TILE_STATS', 'TILE_STATS');
return reply.stats;
} catch (err) {
console.warn('[PWA] getTileCacheStats failed:', err);
return null;
}
}
/**
* Delete every cached tile from this device. Doesn't touch the app shell,
* modules, or API caches only the per-provider tile buckets.
* Waits for the SW to be available before sending the request.
*
* @returns {Promise<boolean>} true if the request was acknowledged
*/
export async function clearTileCaches() {
try {
await requestFromServiceWorker('CLEAR_TILE_CACHES', 'TILE_CACHES_CLEARED');
return true;
} catch (err) {
console.warn('[PWA] clearTileCaches failed:', err);
return false;
}
}
/**
* Get total disk used by this origin (Cache API + IndexedDB + OPFS).
* Returns null if the Storage API is not available.
*
* @returns {Promise<{ usage: number, quota: number } | null>}
*/
export async function getStorageEstimate() {
if (!navigator.storage?.estimate) return null;
try {
const { usage, quota } = await navigator.storage.estimate();
return { usage: usage || 0, quota: quota || 0 };
} catch (err) {
console.warn('[PWA] getStorageEstimate failed:', err);
return null;
}
}
// ============================================================================
// Auto-initialization
// ============================================================================
@ -313,5 +465,10 @@ export default {
applyUpdate,
postToServiceWorker,
cacheModules,
clearUserCaches
clearUserCaches,
getTileCacheStats,
clearTileCaches,
getStorageEstimate,
getActiveServiceWorker,
onServiceWorkerControllerChange,
};

View File

@ -21,13 +21,89 @@ const API_CREDENTIALS = {
api_token: '1c46538c712e9b5b'
};
// ============================================================================
// Server Reachability
// ============================================================================
/** Default timeout for API requests (ms) */
const REQUEST_TIMEOUT = 30_000;
/** Timeout for the fast reachability probe (ms) */
const PING_TIMEOUT = 5_000;
/** Cached result of the last reachability check */
let _serverReachable = null;
/**
* Quick probe to determine if the API server is responding.
* Sends a small POST to a lightweight endpoint with a short timeout.
* The result is cached so subsequent calls within the same page load
* return immediately.
*
* @param {boolean} [force=false] - Re-check even if a cached result exists
* @returns {Promise<boolean>} true if the server responded in time
*/
export async function checkServerReachable(force = false) {
if (_serverReachable !== null && !force) return _serverReachable;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT);
try {
const response = await fetch(`${API_BASE}/get_layers.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(API_CREDENTIALS),
signal: controller.signal,
});
_serverReachable = response.ok;
} catch {
_serverReachable = false;
} finally {
clearTimeout(timer);
}
console.log('[RemoteDB] Server reachable:', _serverReachable);
return _serverReachable;
}
/**
* Returns the cached server-reachability flag (synchronous).
* Returns null if checkServerReachable() has not been called yet.
* @returns {boolean|null}
*/
export function isServerReachable() {
return _serverReachable;
}
// ============================================================================
// Core Request Helpers
// ============================================================================
/**
* Create an AbortController that auto-aborts after `ms` milliseconds.
* If the caller already supplied a signal in `options`, it is combined
* so that either the caller's abort or the timeout will cancel the request.
*/
function withTimeout(options, ms = REQUEST_TIMEOUT) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
// If the caller provided their own signal, chain it
if (options.signal) {
options.signal.addEventListener('abort', () => controller.abort());
}
return {
signal: controller.signal,
clear: () => clearTimeout(timer),
};
}
/**
* Perform a GET request to the remote API.
* Credentials are sent as URL query parameters.
* Automatically times out after REQUEST_TIMEOUT ms.
*
* @param {string} endpoint - API endpoint filename (e.g. 'get_district_boundary.php')
* @param {Object} [params={}] - Additional query parameters
@ -45,13 +121,15 @@ export async function remoteGet(endpoint, params = {}, options = {}) {
console.log('[RemoteDB] GET', url.toString());
const timeout = withTimeout(options);
try {
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json'
},
...options
...options,
signal: timeout.signal,
});
if (!response.ok) {
@ -63,14 +141,21 @@ export async function remoteGet(endpoint, params = {}, options = {}) {
return data;
} catch (error) {
if (error.name === 'AbortError') {
console.error('[RemoteDB] GET timed out:', endpoint);
throw new Error(`Request timed out: ${endpoint}`);
}
console.error('[RemoteDB] GET failed:', endpoint, error);
throw error;
} finally {
timeout.clear();
}
}
/**
* Perform a POST request to the remote API.
* Credentials are included in the JSON body.
* Automatically times out after REQUEST_TIMEOUT ms.
*
* @param {string} endpoint - API endpoint filename (e.g. 'some_endpoint.php')
* @param {Object} [body={}] - Request payload (credentials are merged in)
@ -84,6 +169,7 @@ export async function remotePost(endpoint, body = {}, options = {}) {
console.log('[RemoteDB] POST', url);
const timeout = withTimeout(options);
try {
const response = await fetch(url, {
method: 'POST',
@ -92,7 +178,8 @@ export async function remotePost(endpoint, body = {}, options = {}) {
'Accept': 'application/json'
},
body: JSON.stringify(payload),
...options
...options,
signal: timeout.signal,
});
if (!response.ok) {
@ -104,8 +191,14 @@ export async function remotePost(endpoint, body = {}, options = {}) {
return data;
} catch (error) {
if (error.name === 'AbortError') {
console.error('[RemoteDB] POST timed out:', endpoint);
throw new Error(`Request timed out: ${endpoint}`);
}
console.error('[RemoteDB] POST failed:', endpoint, error);
throw error;
} finally {
timeout.clear();
}
}
@ -170,16 +263,50 @@ export async function getBuildingFootprints() {
return remotePost('get_all_footprint_per_district.php');
}
/**
* Fetch the Contours hillshade elevation layer from the server.
*
* Source: table `contours_hillshade` in the local PostgreSQL `public` schema
* (imported from OpenTopography's viz.hh_hillshade).
*
* Expected response:
* { success: true, data: [{ id, elevation, geom: "LINESTRING(...)" | "MULTILINESTRING(...)" | "POLYGON(...)", ... }, ...] }
*
* @returns {Promise<Object>} Contours hillshade list
*/
export async function getContoursHillshade() {
return remotePost('get_contours_hillshade.php');
}
/**
* Fetch the OSM roads layer from the server.
*
* Source: table `pi_osm_roads` in the local PostgreSQL `public` schema
* (imported from OpenStreetMap road network for the district).
*
* Expected response:
* { success: true, data: [{ id, ..., geom: "LINESTRING(...)" | "MULTILINESTRING(...)", ... }, ...] }
*
* @returns {Promise<Object>} OSM roads list
*/
export async function getOSMRoads() {
return remotePost('get_osm_roads.php');
}
// ============================================================================
// Exports
// ============================================================================
export default {
checkServerReachable,
isServerReachable,
remoteGet,
remotePost,
getDistrictBoundary,
getLayers,
getDistrictParcels,
getCollectorZones,
getBuildingFootprints
getBuildingFootprints,
getContoursHillshade,
getOSMRoads,
};

98
src/toast.js Normal file
View 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
View 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: / 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);
}

View File

@ -34,12 +34,25 @@ export default defineConfig({
// Target modern browsers that support OPFS
target: 'esnext',
// Raise the chunk-size warning threshold.
// Rationale: the two largest chunks are unavoidable —
// • openlayers (~535 kB / ~152 kB gzipped) — the OL library itself,
// used app-wide; further splitting just creates more HTTP round-trips
// • sqlite3.wasm (~856 kB) — a runtime WASM binary, not a JS chunk;
// code-splitting does not apply
// 900 kB silences the noise without hiding genuine regressions.
chunkSizeWarningLimit: 900,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/ol/')) return 'openlayers';
if (id.includes('node_modules/ol-ext/')) return 'ol-ext';
if (id.includes('node_modules/bootstrap/')) return 'bootstrap';
// shpjs (+ its jszip dependency) — dynamically imported at runtime,
// so this chunk is only fetched when the user imports a shapefile.
if (id.includes('node_modules/shpjs/') || id.includes('node_modules/jszip/')) return 'shpjs';
if (id.includes('node_modules/jspdf')) return 'jspdf';
},
},
},