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>
396 lines
12 KiB
JavaScript
396 lines
12 KiB
JavaScript
/**
|
||
* Pure geometry functions for splitting a polygon by a line.
|
||
*
|
||
* No OpenLayers dependency — operates on raw coordinate arrays.
|
||
*/
|
||
|
||
/**
|
||
* Compute the intersection point of two 2D line segments.
|
||
* Segment A: p1→p2, Segment B: p3→p4.
|
||
*
|
||
* @param {number[]} p1
|
||
* @param {number[]} p2
|
||
* @param {number[]} p3
|
||
* @param {number[]} p4
|
||
* @param {number} [eps=1e-10] tolerance for parallel check
|
||
* @returns {{ point: number[], t: number, u: number } | null}
|
||
* t = parametric position on segment A (0–1),
|
||
* u = parametric position on segment B (0–1)
|
||
*/
|
||
function segmentIntersection(p1, p2, p3, p4, eps = 1e-10) {
|
||
const dx1 = p2[0] - p1[0];
|
||
const dy1 = p2[1] - p1[1];
|
||
const dx2 = p4[0] - p3[0];
|
||
const dy2 = p4[1] - p3[1];
|
||
|
||
const denom = dx1 * dy2 - dy1 * dx2;
|
||
if (Math.abs(denom) < eps) return null; // parallel / collinear
|
||
|
||
const dx3 = p3[0] - p1[0];
|
||
const dy3 = p3[1] - p1[1];
|
||
|
||
const t = (dx3 * dy2 - dy3 * dx2) / denom;
|
||
const u = (dx3 * dy1 - dy3 * dx1) / denom;
|
||
|
||
if (t < -eps || t > 1 + eps || u < -eps || u > 1 + eps) return null;
|
||
|
||
return {
|
||
point: [p1[0] + t * dx1, p1[1] + t * dy1],
|
||
t: Math.max(0, Math.min(1, t)),
|
||
u: Math.max(0, Math.min(1, u)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Signed area of a ring (shoelace formula).
|
||
* Positive = counter-clockwise, negative = clockwise.
|
||
*/
|
||
function signedArea(ring) {
|
||
let area = 0;
|
||
for (let i = 0, n = ring.length; i < n - 1; i++) {
|
||
area += (ring[i][0] * ring[i + 1][1]) - (ring[i + 1][0] * ring[i][1]);
|
||
}
|
||
return area / 2;
|
||
}
|
||
|
||
/**
|
||
* Test whether a point is inside a ring (ray-casting algorithm).
|
||
*/
|
||
function pointInRing(pt, ring) {
|
||
let inside = false;
|
||
for (let i = 0, j = ring.length - 2; i < ring.length - 1; j = i++) {
|
||
const xi = ring[i][0], yi = ring[i][1];
|
||
const xj = ring[j][0], yj = ring[j][1];
|
||
if (((yi > pt[1]) !== (yj > pt[1])) &&
|
||
(pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi)) {
|
||
inside = !inside;
|
||
}
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
/**
|
||
* Squared distance between two points.
|
||
*/
|
||
function dist2(a, b) {
|
||
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
|
||
}
|
||
|
||
/**
|
||
* Find all intersection points between a cutting line and a polygon ring.
|
||
*
|
||
* @param {number[][]} ring Closed ring coordinates (first === last)
|
||
* @param {number[][]} line LineString coordinates (2+ points)
|
||
* @returns {Array<{ point: number[], ringSegIdx: number, ringT: number, lineSegIdx: number, lineT: number }>}
|
||
*/
|
||
function findIntersections(ring, line) {
|
||
const hits = [];
|
||
const eps = 1e-10;
|
||
|
||
for (let li = 0; li < line.length - 1; li++) {
|
||
for (let ri = 0; ri < ring.length - 1; ri++) {
|
||
const ix = segmentIntersection(ring[ri], ring[ri + 1], line[li], line[li + 1], eps);
|
||
if (!ix) continue;
|
||
|
||
// Skip if intersection is at the very start of the ring segment
|
||
// but was already caught as the end of the previous segment
|
||
const pt = ix.point;
|
||
|
||
// Avoid duplicate hits at shared vertices
|
||
let isDup = false;
|
||
for (const h of hits) {
|
||
if (dist2(h.point, pt) < 1e-6) {
|
||
isDup = true;
|
||
break;
|
||
}
|
||
}
|
||
if (isDup) continue;
|
||
|
||
hits.push({
|
||
point: pt,
|
||
ringSegIdx: ri,
|
||
ringT: ix.t,
|
||
lineSegIdx: li,
|
||
lineT: ix.u,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort by position along the cutting line
|
||
hits.sort((a, b) => {
|
||
if (a.lineSegIdx !== b.lineSegIdx) return a.lineSegIdx - b.lineSegIdx;
|
||
return a.lineT - b.lineT;
|
||
});
|
||
|
||
return hits;
|
||
}
|
||
|
||
/**
|
||
* Insert intersection points into a ring, returning the expanded ring
|
||
* and the new indices of the inserted points.
|
||
*
|
||
* @param {number[][]} ring Closed ring (first === last)
|
||
* @param {Array<{ point: number[], ringSegIdx: number, ringT: number }>} hits
|
||
* Sorted by ringSegIdx then ringT.
|
||
* @returns {{ ring: number[][], indices: number[] }}
|
||
*/
|
||
function insertPointsIntoRing(ring, hits) {
|
||
// Sort hits by ring position (segment index, then parametric t) so
|
||
// we can insert from back to front without shifting earlier indices.
|
||
const sorted = hits.map((h, i) => ({ ...h, origOrder: i }));
|
||
sorted.sort((a, b) => {
|
||
if (a.ringSegIdx !== b.ringSegIdx) return a.ringSegIdx - b.ringSegIdx;
|
||
return a.ringT - b.ringT;
|
||
});
|
||
|
||
const expanded = ring.slice(); // copy
|
||
const indices = new Array(sorted.length);
|
||
|
||
// Insert from the end so that earlier insertions don't shift later indices.
|
||
for (let k = sorted.length - 1; k >= 0; k--) {
|
||
const h = sorted[k];
|
||
const insertIdx = h.ringSegIdx + 1;
|
||
|
||
// Check if this point is essentially identical to an existing vertex
|
||
const snapDist = 1e-6;
|
||
if (dist2(h.point, expanded[h.ringSegIdx]) < snapDist) {
|
||
indices[h.origOrder] = h.ringSegIdx;
|
||
continue;
|
||
}
|
||
if (dist2(h.point, expanded[h.ringSegIdx + 1]) < snapDist) {
|
||
indices[h.origOrder] = h.ringSegIdx + 1;
|
||
continue;
|
||
}
|
||
|
||
// Insert the new point
|
||
expanded.splice(insertIdx, 0, h.point);
|
||
indices[h.origOrder] = insertIdx;
|
||
|
||
// Adjust indices for all previously recorded insertions
|
||
// that reference a position >= insertIdx
|
||
for (let j = k + 1; j < sorted.length; j++) {
|
||
if (indices[sorted[j].origOrder] >= insertIdx) {
|
||
indices[sorted[j].origOrder]++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ring: expanded, indices };
|
||
}
|
||
|
||
/**
|
||
* Extract a slice of a ring from index i0 to i1 (going forward, wrapping).
|
||
* Both endpoints are included.
|
||
*
|
||
* @param {number[][]} ring Closed ring (first === last); length includes closing vertex
|
||
* @param {number} i0 Start index (inclusive)
|
||
* @param {number} i1 End index (inclusive)
|
||
* @returns {number[][]}
|
||
*/
|
||
function ringSlice(ring, i0, i1) {
|
||
const n = ring.length - 1; // number of unique vertices (ring is closed)
|
||
// Normalise indices into the [0, n-1] range
|
||
const start = ((i0 % n) + n) % n;
|
||
const end = ((i1 % n) + n) % n;
|
||
const result = [];
|
||
let idx = start;
|
||
while (true) {
|
||
result.push(ring[idx]);
|
||
if (idx === end) break;
|
||
idx = (idx + 1) % n;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Extract the cutting line segment between two intersection points.
|
||
*
|
||
* @param {number[][]} line Full cutting line coordinates
|
||
* @param {{ point: number[], lineSegIdx: number, lineT: number }} hit0
|
||
* @param {{ point: number[], lineSegIdx: number, lineT: number }} hit1
|
||
* @returns {number[][]} Coordinates from hit0.point to hit1.point along the line
|
||
*/
|
||
function cuttingLineSlice(line, hit0, hit1) {
|
||
const result = [hit0.point];
|
||
|
||
// Include all intermediate line vertices between the two hit segments
|
||
const startSeg = hit0.lineSegIdx;
|
||
const endSeg = hit1.lineSegIdx;
|
||
|
||
for (let i = startSeg + 1; i <= endSeg; i++) {
|
||
result.push(line[i]);
|
||
}
|
||
|
||
// Add the end intersection point if it's not the same as the last vertex
|
||
if (dist2(result[result.length - 1], hit1.point) > 1e-10) {
|
||
result.push(hit1.point);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Ensure a ring has the desired winding order.
|
||
* @param {number[][]} ring Closed ring
|
||
* @param {boolean} ccw true for counter-clockwise
|
||
* @returns {number[][]}
|
||
*/
|
||
function ensureWinding(ring, ccw) {
|
||
const area = signedArea(ring);
|
||
if ((ccw && area < 0) || (!ccw && area > 0)) {
|
||
return ring.slice().reverse();
|
||
}
|
||
return ring;
|
||
}
|
||
|
||
/**
|
||
* Close a ring (ensure first === last).
|
||
*/
|
||
function closeRing(coords) {
|
||
if (coords.length < 2) return coords;
|
||
const first = coords[0];
|
||
const last = coords[coords.length - 1];
|
||
if (dist2(first, last) > 1e-10) {
|
||
return [...coords, first.slice()];
|
||
}
|
||
return coords;
|
||
}
|
||
|
||
/**
|
||
* Extend a cutting line so that both endpoints lie outside the polygon ring.
|
||
* If an endpoint is inside, we extend the first/last segment outward past the
|
||
* bounding box diagonal so it definitely exits.
|
||
*
|
||
* @param {number[][]} line Cutting line coordinates
|
||
* @param {number[][]} ring Closed polygon ring
|
||
* @returns {number[][]} Extended line (may be the original if already outside)
|
||
*/
|
||
function extendLineOutsideRing(line, ring) {
|
||
// Compute bounding-box diagonal for a generous extension distance
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const pt of ring) {
|
||
if (pt[0] < minX) minX = pt[0];
|
||
if (pt[1] < minY) minY = pt[1];
|
||
if (pt[0] > maxX) maxX = pt[0];
|
||
if (pt[1] > maxY) maxY = pt[1];
|
||
}
|
||
const diag = Math.sqrt((maxX - minX) ** 2 + (maxY - minY) ** 2) || 1;
|
||
|
||
const result = line.slice();
|
||
|
||
// Extend start if inside
|
||
if (pointInRing(result[0], ring)) {
|
||
const p0 = result[0];
|
||
const p1 = result[1];
|
||
const dx = p0[0] - p1[0];
|
||
const dy = p0[1] - p1[1];
|
||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const scale = diag * 2 / len;
|
||
result[0] = [p0[0] + dx * scale, p0[1] + dy * scale];
|
||
}
|
||
|
||
// Extend end if inside
|
||
const last = result.length - 1;
|
||
if (pointInRing(result[last], ring)) {
|
||
const pN = result[last];
|
||
const pN1 = result[last - 1];
|
||
const dx = pN[0] - pN1[0];
|
||
const dy = pN[1] - pN1[1];
|
||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const scale = diag * 2 / len;
|
||
result[last] = [pN[0] + dx * scale, pN[1] + dy * scale];
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Split a polygon by a cutting line.
|
||
*
|
||
* The cutting line can start or end inside the polygon — the algorithm will
|
||
* automatically extend it outward so it crosses the boundary at exactly 2
|
||
* points. Multi-vertex cutting lines (with corners or approximated arcs)
|
||
* are fully supported.
|
||
*
|
||
* @param {number[][][]} polygonCoords Polygon coordinates:
|
||
* [exteriorRing, ...holeRings] where each ring is closed (first === last)
|
||
* @param {number[][]} lineCoords Cutting line coordinates (2+ points)
|
||
* @returns {number[][][][] | null} Two polygon coordinate arrays, or null if split failed
|
||
*/
|
||
export function splitPolygonByLine(polygonCoords, lineCoords) {
|
||
const exteriorRing = polygonCoords[0];
|
||
const holes = polygonCoords.slice(1);
|
||
|
||
// Extend the cutting line if its endpoints are inside the polygon
|
||
const extendedLine = extendLineOutsideRing(lineCoords, exteriorRing);
|
||
|
||
// 1. Find intersections between cutting line and exterior ring
|
||
const hits = findIntersections(exteriorRing, extendedLine);
|
||
|
||
// We need exactly 2 intersection points for a simple split
|
||
if (hits.length !== 2) {
|
||
console.warn(`[polygonSplit] Expected 2 intersections, got ${hits.length}`);
|
||
return null;
|
||
}
|
||
|
||
const [hit0, hit1] = hits;
|
||
|
||
// 2. Insert intersection points into the ring
|
||
const { ring: expandedRing, indices } = insertPointsIntoRing(exteriorRing, hits);
|
||
const idx0 = indices[0];
|
||
const idx1 = indices[1];
|
||
|
||
// Ensure idx0 < idx1 for consistent traversal
|
||
const [iA, iB] = idx0 < idx1 ? [idx0, idx1] : [idx1, idx0];
|
||
const [hitA, hitB] = idx0 < idx1 ? [hit0, hit1] : [hit1, hit0];
|
||
|
||
// 3. Get the cutting line segment between the two intersection points
|
||
const cutForward = idx0 < idx1
|
||
? cuttingLineSlice(extendedLine, hit0, hit1)
|
||
: cuttingLineSlice(extendedLine, hit1, hit0);
|
||
const cutReverse = cutForward.slice().reverse();
|
||
|
||
// 4. Build two polygon rings
|
||
// Ring A: walk ring from iA to iB (forward), then cutting line reversed back to iA
|
||
const sliceAB = ringSlice(expandedRing, iA, iB);
|
||
const ringA = closeRing([...sliceAB, ...cutReverse.slice(1)]);
|
||
|
||
// Ring B: walk ring from iB to iA (wrapping), then cutting line forward back to iB
|
||
const sliceBA = ringSlice(expandedRing, iB, iA);
|
||
const ringB = closeRing([...sliceBA, ...cutForward.slice(1)]);
|
||
|
||
// 5. Match winding order to original
|
||
const originalCCW = signedArea(exteriorRing) > 0;
|
||
const finalA = ensureWinding(ringA, originalCCW);
|
||
const finalB = ensureWinding(ringB, originalCCW);
|
||
|
||
// 6. Build polygon coordinate arrays, assigning holes to the correct piece
|
||
const polyA = [finalA];
|
||
const polyB = [finalB];
|
||
|
||
for (const hole of holes) {
|
||
// Use the centroid of the hole to determine containment
|
||
const centroid = holeCentroid(hole);
|
||
if (pointInRing(centroid, finalA)) {
|
||
polyA.push(hole);
|
||
} else {
|
||
polyB.push(hole);
|
||
}
|
||
}
|
||
|
||
return [polyA, polyB];
|
||
}
|
||
|
||
/**
|
||
* Compute the centroid of a closed ring.
|
||
*/
|
||
function holeCentroid(ring) {
|
||
let cx = 0, cy = 0;
|
||
const n = ring.length - 1; // exclude closing vertex
|
||
for (let i = 0; i < n; i++) {
|
||
cx += ring[i][0];
|
||
cy += ring[i][1];
|
||
}
|
||
return [cx / n, cy / n];
|
||
}
|