pwaLUPMIS2/src/geom/polygonDivide.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

296 lines
9.3 KiB
JavaScript

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