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>
296 lines
9.3 KiB
JavaScript
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 };
|
|
}
|