pwaLUPMIS2/src/geom/polygonSplit.js
ekke ef12e4477b 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>
2026-05-26 10:55:30 +02:00

396 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (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];
}