Contours layer via new API; Analyse guards; measurements read-only

Contours hillshade layer
- Wired to the new endpoint POST /api/contours/get_by_district.php. It sits in
  its own /contours module rather than /spatial_planning, so remotedb gained a
  resolveEndpoint() helper: a bare filename still resolves inside
  /spatial_planning, a path resolves against the API root. The endpoint also
  names the district parameter `districtid` (numeric, no underscore) unlike
  every other endpoint, which is an easy trap — documented at the call site.
- Each contour is labelled with its `elev` value. Labels are decluttered
  (addGeoJSONLayer gained a declutter passthrough, since OpenLayers only allows
  it at construction time) and gated by resolution, so the map shows an
  occasional elevation value rather than one per segment.
- Verified against the live endpoint: district 1 returns 2,676 features, 11
  elevation values at 10 m intervals, median feature ~50 m. The response uses
  the standard {success, data:[…]} envelope; the reader also tolerates a bare
  array.

Analyse: fail loudly instead of silently
- Overlays are polygon-only, but line inputs were being filtered out silently,
  producing an empty result and the misleading "the layers may not overlap".
  They now raise a message naming the geometry actually found and pointing at
  Zonal statistics. Union checks each layer separately, so a line layer paired
  with a polygon layer can no longer be dropped unnoticed.
- Zonal statistics returns warnings alongside the result — shown in the panel
  and written into the Excel workbook — for figures that are valid but easy to
  misread: total area over non-polygon inputs (always 0), and mean over line
  inputs, which is not an area-weighted average of the surface. The mean
  warning is restricted to lines; averaging over points is a normal statistic.

Measurements are read-only
- The Measurements layer carries selectable:false, honoured by both the Select
  interaction and box-select. Previously a measured circle showed vertex
  handles while the Digitise tool was active, and dragging one reshaped the
  measurement: the intersection test followed the new shape while the reported
  radius and area did not. Double-click analysis is unaffected — it hit-tests
  the map directly rather than going through Select.
- Circle Analysis now reads the radius from the geometry instead of the
  `_radius` snapshot taken at draw time, so the reported figure cannot drift
  from the area analysed (that figure also feeds the PDF export). The property
  is kept but marked as a snapshot, not a source of truth.

Documents
- New Buffer (Circle) tool user guide: the tool is named Circle rather than
  Buffer, it takes two clicks rather than a drag, and the analysis only appears
  on a double-click — the three things users were getting stuck on.
- New technician training plan: 12 topics over 60 days, derived from the
  technologies actually used in this code base, including Docker.
- Analyse guide: elevation per zone from the contours layer, with the
  min/max-versus-mean caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ekke 2026-08-11 12:20:26 +02:00
parent da6f968725
commit 7d26c740df
18 changed files with 293 additions and 122 deletions

Binary file not shown.

Binary file not shown.

BIN
LUPMIS2_Training_Plan.docx Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11
dist/assets/analysis-modal-BBuJH4BF.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

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

File diff suppressed because one or more lines are too long

2
dist/index.html vendored
View File

@ -1601,7 +1601,7 @@
}
}
</style>
<script type="module" crossorigin src="/assets/index-Cyeghdft.js"></script>
<script type="module" crossorigin src="/assets/index-D9GGSG-a.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-J9qS6Th1.js">
<link rel="modulepreload" crossorigin href="/assets/pako-Xa-UToif.js">
<link rel="modulepreload" crossorigin href="/assets/geotiff-BaoeLn6q.js">

49
main.js
View File

@ -2119,6 +2119,10 @@ async function loadContoursHillshade() {
strokeWidth: 0.8,
typeDescription: 'Vector / Line',
fillColor: 'rgba(0,0,0,0)',
// Elevation labels are dense — declutter drops overlapping ones so the
// map shows a readable scattering rather than a solid block of text.
// Must be set at construction time (OpenLayers has no setDeclutter).
declutter: true,
};
const biophysGroup = mapView?.getLayerGroupByTitle('Biophysical Environment');
@ -2133,6 +2137,40 @@ async function loadContoursHillshade() {
}
contoursLayer.setVisible(false);
// Style: the contour line, plus its elevation as a label.
//
// The API returns one feature per contour segment — roughly 2,700 per
// district, most of them only ~50 m across — so a label per feature is far
// larger than the feature itself. Decluttering (set above) drops the
// overlaps, which gives the conventional cartographic result: an occasional
// elevation value along the contours rather than one on every segment.
// The resolution gate exists so we don't build text styles for every feature
// when zoomed out to regional or national level.
const CONTOUR_LABEL_MAX_RESOLUTION = 10; // m/px, ≈ 1:35,000
contoursLayer.setStyle((feature, resolution) => {
const styles = [
new Style({
stroke: new Stroke({ color: '#78716c', width: 0.8 }),
}),
];
if (resolution <= CONTOUR_LABEL_MAX_RESOLUTION) {
const elev = feature.get('elev');
if (elev !== null && elev !== undefined && elev !== '') {
styles.push(new Style({
text: new OlText({
text: String(elev),
font: '600 11px Arial, sans-serif',
fill: new Fill({ color: '#57534e' }),
stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3 }),
overflow: true, // label short segments too
}),
}));
}
}
return styles;
});
// Warn when the user enables the layer but it has no data
contoursLayer.on('change:visible', () => {
if (contoursLayer.getVisible() && contoursLayer.getSource().getFeatures().length === 0) {
@ -2150,12 +2188,17 @@ async function loadContoursHillshade() {
console.log('[App] Fetching contours_hillshade from API...');
const apiResponse = await getContoursHillshade();
if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
// Accept either the {success, data:[…]} envelope used by the
// /spatial_planning endpoints or a bare array, since the contours endpoint
// is a newer module and documented only by example.
const rows = Array.isArray(apiResponse) ? apiResponse
: Array.isArray(apiResponse?.data) ? apiResponse.data
: null;
if (!rows) {
console.warn('[App] getContoursHillshade API response invalid:', apiResponse);
return;
}
const rows = apiResponse.data;
console.log('[App] Contours hillshade from API:', rows.length, 'rows');
if (rows.length > 0) {
console.log('[App] First row keys:', Object.keys(rows[0]));

View File

@ -211,6 +211,7 @@ function onRun() {
(mode === 'overlay' ? 'overlay_result' : 'zonal_stats');
let geojson, summary;
let zonalWarnings = [];
if (mode === 'overlay') {
lastZonal = null;
const op = els.op.value;
@ -235,6 +236,7 @@ function onRun() {
stats, field, membership: els.membership.value,
});
geojson = res.geojson;
zonalWarnings = res.warnings || [];
summary = renderRows(res.rows);
// Keep the table AND the settings that produced it, so the Excel export
@ -243,6 +245,7 @@ function onRun() {
const membershipLabel = (MEMBERSHIP_MODES.find((m) => m.key === els.membership.value) || {}).label;
lastZonal = {
rows: res.rows,
warnings: res.warnings || [],
params: {
'Analysis': 'Zonal statistics',
'Zone layer': labelOfSelect(els.zoneLayer),
@ -270,8 +273,13 @@ function onRun() {
}, group);
syncExportButton();
const warnHtml = zonalWarnings.length
? `<div class="alert alert-warning py-2 px-3 mb-2" style="font-size:0.85rem;">` +
zonalWarnings.map((w) => `<div>${escapeHtml(w)}</div>`).join('') + `</div>`
: '';
els.result.innerHTML =
`<div class="alert alert-success py-2 px-3 mb-2">Added layer “${escapeHtml(name)}”.</div>` +
warnHtml +
(typeof summary === 'string' && summary.startsWith('<') ? summary : `<div class="small text-muted">${escapeHtml(summary)}</div>`);
showToast(`Analysis complete — “${name}” added to the Analysis group.`, 'success', 4000);
} catch (err) {
@ -306,7 +314,7 @@ async function onExportTable() {
try {
const { exportXlsx } = await import('./analysis/xlsx.js');
const { rows, params } = lastZonal;
const { rows, params, warnings } = lastZonal;
const keys = Object.keys(rows[0] || {}).filter((k) => k !== 'zone');
const resultRows = [
{ cells: ['Zone', ...keys], bold: true },
@ -319,6 +327,11 @@ async function onExportTable() {
{ cells: ['Parameter', 'Value'], bold: true },
...Object.entries(params).map(([k, v]) => [k, v]),
['Exported', new Date().toLocaleString()],
// Carry any caveats into the workbook, so a figure cannot be read out of
// context once it leaves the application.
...((warnings && warnings.length)
? [[], { cells: ['Notes'], bold: true }, ...warnings.map((w) => ['', w])]
: []),
];
const base = (params['Result layer'] || 'zonal_stats')

View File

@ -90,6 +90,29 @@ export function bboxContainsPoint(box, x, y) {
return x >= box[0] && x <= box[2] && y >= box[1] && y <= box[3];
}
/**
* Reject a non-polygon input with a message that says what is actually wrong.
*
* Overlay operations are polygon-only, but the inputs are silently filtered by
* polygonsOnly() so a line layer (contours, roads) used to produce an empty
* result and the misleading "the layers may not overlap". Failing loudly here
* tells the user the real reason.
*
* @param {Array} polygons the surviving polygonal features
* @param {Array} original everything that was passed in
* @param {string} role 'Layer A' / 'Layer B'
*/
function assertPolygons(polygons, original, role) {
if (polygons.length > 0 || original.length === 0) return;
const types = [...new Set(original.map((f) => f?.geometry?.type).filter(Boolean))];
const found = types.length ? types.join(', ') : 'no geometry';
throw new Error(
`${role} contains no polygons (found ${found}). Overlay operations work on ` +
`polygon layers only — line layers such as contours or roads cannot be used here. ` +
`To summarise a line layer per zone, use Zonal statistics instead.`
);
}
/** Keep only polygonal features — overlay is undefined for points/lines here. */
function polygonsOnly(gjFeatures) {
return (gjFeatures || []).filter((f) => {
@ -123,8 +146,12 @@ function fc(features) {
*/
export function intersectFeatures(aFeatures, bFeatures, opts = {}) {
const { keepBAttributes = true } = opts;
const A = polygonsOnly(toGeoJSONFeatures(aFeatures));
const B = polygonsOnly(toGeoJSONFeatures(bFeatures));
const gjA = toGeoJSONFeatures(aFeatures);
const gjB = toGeoJSONFeatures(bFeatures);
const A = polygonsOnly(gjA);
const B = polygonsOnly(gjB);
assertPolygons(A, gjA, 'Layer A');
assertPolygons(B, gjB, 'Layer B');
// Pre-compute B's boxes once, then only test pairs that can possibly meet.
const bBoxes = B.map(bboxOf);
@ -163,8 +190,12 @@ export function clipFeatures(aFeatures, clipFeatures_, opts = {}) {
* @returns {Object} GeoJSON FeatureCollection (WGS84)
*/
export function differenceFeatures(aFeatures, bFeatures) {
const A = polygonsOnly(toGeoJSONFeatures(aFeatures));
const B = polygonsOnly(toGeoJSONFeatures(bFeatures));
const gjA = toGeoJSONFeatures(aFeatures);
const gjB = toGeoJSONFeatures(bFeatures);
const A = polygonsOnly(gjA);
const B = polygonsOnly(gjB);
assertPolygons(A, gjA, 'Layer A');
assertPolygons(B, gjB, 'Layer B');
const bBoxes = B.map(bboxOf);
let candidates = 0;
@ -199,10 +230,16 @@ export function differenceFeatures(aFeatures, bFeatures) {
* @returns {Object} GeoJSON FeatureCollection with 0 or 1 feature (WGS84)
*/
export function unionFeatures(aFeatures, bFeatures = []) {
const all = polygonsOnly([
...toGeoJSONFeatures(aFeatures),
...toGeoJSONFeatures(bFeatures),
]);
// Check each layer separately rather than the combined set: if only the
// second layer had polygons, a combined check would pass and the first
// layer's features would be dropped without the user being told.
const gjA = toGeoJSONFeatures(aFeatures);
const gjB = toGeoJSONFeatures(bFeatures);
const polyA = polygonsOnly(gjA);
const polyB = polygonsOnly(gjB);
assertPolygons(polyA, gjA, 'Layer A');
assertPolygons(polyB, gjB, 'Layer B'); // no-op when no second layer is given
const all = [...polyA, ...polyB];
if (all.length === 0) return fc([]);
if (all.length === 1) return fc([all[0]]);

View File

@ -83,8 +83,10 @@ function num(value) {
* @param {string} [params.membership] 'centroid' | 'intersects'
* @param {string} [params.prefix='zs_'] prefix for the new attribute names
* @returns {{ geojson: Object, rows: Array<Object> }}
* geojson the zones with statistics attached (WGS84), ready to add as a layer
* rows a plain table of the same numbers, for display/export
* geojson the zones with statistics attached (WGS84), ready to add as a layer
* rows a plain table of the same numbers, for display/export
* warnings figures that are valid but easy to misread (e.g. area over a
* line layer); shown to the user rather than thrown
*/
export function zonalStatistics({
zoneFeatures,
@ -102,6 +104,36 @@ export function zonalStatistics({
if (zones.length === 0) throw new Error('The zone layer contains no polygons.');
// Warnings are returned alongside the result rather than thrown: the run is
// still valid and useful, but some figures would be silently meaningless
// without an explanation.
const warnings = [];
const inputTypes = new Set(inputs.map((f) => f?.geometry?.type).filter(Boolean));
const inputsHavePolygons = inputTypes.has('Polygon') || inputTypes.has('MultiPolygon');
// Lines and points have no area, so "Total area" would report a silent 0.
if (stats.includes('area') && !inputsHavePolygons && inputs.length > 0) {
warnings.push(
`Total area is 0 because the features being summarised are ` +
`${[...inputTypes].join('/')} — only polygons have an area.`
);
}
// Line inputs only. Averaging a field over line segments is not an
// area-weighted average of the surface those lines describe — the classic
// trap being "mean elevation per parcel" from contours, where segment count
// has no relation to the area at each elevation. (Averaging over points is a
// normal statistic, so it is not flagged.)
const inputsAreLines = inputTypes.has('LineString') || inputTypes.has('MultiLineString');
if (stats.includes('mean') && inputsAreLines && !inputsHavePolygons && field) {
warnings.push(
`Mean is the average of “${field}” across the individual line features in ` +
`each zone, not an area-weighted average of the surface they describe. ` +
`For contour data, Minimum and Maximum (the elevation range) are the ` +
`figures you can rely on.`
);
}
// Pre-compute once: each input's centroid (centroid mode) or bbox
// (intersects mode), and every zone's bbox. The bbox comparisons below are
// cheap arithmetic; only survivors reach the real geometry test.
@ -167,7 +199,7 @@ export function zonalStatistics({
rows.push({ ...labelOf(zone), ...result });
}
return { geojson: { type: 'FeatureCollection', features: zones }, rows };
return { geojson: { type: 'FeatureCollection', features: zones }, rows, warnings };
}
/** @private Raised only when genuinely plausible pairs exceed the budget. */

View File

@ -37,6 +37,11 @@ export class MapTools {
style: this.getMeasureStyle(),
title: 'Measurements',
zIndex: 100,
// Measurements are read-only. Without this the Digitise tool treats a
// measured circle like any other geometry: it shows vertex handles, and
// dragging one silently reshapes the measurement — the intersection test
// follows the new shape while the reported radius and area do not.
selectable: false,
});
// Create drawing layer (utility layer for temporary draw interactions;
@ -220,6 +225,9 @@ export class MapTools {
// Tag the circle feature so the dblclick handler can identify it
feature.set('_layerType', 'measure_circle');
// Snapshot only — NOT the source of truth. Anything reporting the radius
// must read it from the geometry (getRadius()), otherwise the figure can
// drift from the shape actually being analysed.
feature.set('_radius', radius);
feature.set('_center', center);

View File

@ -394,7 +394,11 @@ export class MapView {
condition: clickCondition,
toggleCondition: shiftKeyOnly,
filter: (feature, layer) => !!layer,
layers: (layer) => layer instanceof VectorLayer,
// A layer can opt out of selection with selectable:false. The
// Measurements layer does — measurements are results to read, not
// geometry to reshape, and editing them would desynchronise the
// reported radius/area from the shape actually being analysed.
layers: (layer) => layer instanceof VectorLayer && layer.get('selectable') !== false,
});
this._selectInteraction.setActive(false);
this.map.addInteraction(this._selectInteraction);
@ -806,6 +810,7 @@ export class MapView {
if (layer === this._vertexOverlayLayer ||
layer === this._gpsTrailLayer ||
layer === this._gpsPositionLayer ||
layer.get('selectable') === false || // e.g. Measurements
layer.get('displayInLayerSwitcher') === false) return;
const source = layer.getSource && layer.getSource();
if (!source || typeof source.forEachFeatureIntersectingExtent !== 'function') return;
@ -1650,7 +1655,12 @@ export class MapView {
const circlePoly = fromCircle(circleGeom, 64);
const circleExtent = circlePoly.getExtent();
const radius = circleFeature.get('_radius') || circleGeom.getRadius();
// Read the radius from the geometry, not from the `_radius` property that
// was written once at draw time. The intersection test below uses the live
// geometry, so taking the radius from anywhere else risks reporting one
// figure while analysing a different area — and that figure ends up in the
// PDF export.
const radius = circleGeom.getRadius();
// Collect intersecting features grouped by layer type
const parcelFeatures = [];
@ -3559,6 +3569,11 @@ export class MapView {
title: title,
source: source,
style: layerStyle,
// Decluttering can only be set at construction time (there is no
// setDeclutter), so it has to come through the style options. Layers with
// dense labels — contours especially — need it to avoid a pile-up of
// overlapping text.
...(styleOptions.declutter ? { declutter: styleOptions.declutter } : {}),
});
layer.set('typeTag', styleOptions.typeTag || 'VEC');

View File

@ -14,7 +14,22 @@
// Configuration
// ============================================================================
const API_BASE = 'https://api.lupmis4luspa.org/api/spatial_planning';
// Most endpoints live under the /spatial_planning module, but not all — the
// contours endpoint sits in its own module. Endpoint names containing a slash
// are resolved against API_ROOT instead (see resolveEndpoint below).
const API_ROOT = 'https://api.lupmis4luspa.org/api';
const API_BASE = `${API_ROOT}/spatial_planning`;
/**
* Build the full URL for an endpoint.
* A bare filename ("get_layers.php") resolves inside /spatial_planning;
* a path ("contours/get_by_district.php") resolves against the API root.
* @param {string} endpoint
* @returns {string}
*/
function resolveEndpoint(endpoint) {
return endpoint.includes('/') ? `${API_ROOT}/${endpoint}` : `${API_BASE}/${endpoint}`;
}
/**
* Per-request credentials sent with every API call.
@ -192,7 +207,7 @@ function withTimeout(options, ms = REQUEST_TIMEOUT) {
* @returns {Promise<Object>} Parsed JSON response
*/
export async function remoteGet(endpoint, params = {}, options = {}) {
const url = new URL(`${API_BASE}/${endpoint}`);
const url = new URL(resolveEndpoint(endpoint));
// Attach credentials and any extra params as query string
const allParams = { ...API_CREDENTIALS, ...params };
@ -244,7 +259,7 @@ export async function remoteGet(endpoint, params = {}, options = {}) {
* @returns {Promise<Object>} Parsed JSON response
*/
export async function remotePost(endpoint, body = {}, options = {}) {
const url = `${API_BASE}/${endpoint}`;
const url = resolveEndpoint(endpoint);
const payload = { ...API_CREDENTIALS, ...body };
@ -347,18 +362,26 @@ export async function getBuildingFootprints() {
/**
* Fetch the Contours hillshade elevation layer from the server.
*
* Source: table `be_contour_hillside` in the local PostgreSQL `public` schema
* (imported from OpenTopography's viz.hh_hillshade).
* Endpoint: POST /api/contours/get_by_district.php
* Source: table `spatial.be_contours_hillshade` elevation contours produced
* with gdal_contour from OpenTopography's SRTM 30 m / Copernicus 30 m
* DEM, scoped per district.
*
* The current district_id is passed automatically via API_CREDENTIALS.
*
* Expected response:
* { success: true, data: [{ id, elevation, geom: "LINESTRING(...)" | "MULTILINESTRING(...)" | "POLYGON(...)", ... }, ...] }
* Response rows carry the elevation in `elev` (metres) and the geometry as WKT
* in `geom`:
* { fid, id, elev: 180, districtid, geom: "LINESTRING(lon lat, …)" }
*
* @returns {Promise<Object>} Contours hillshade list
*/
export async function getContoursHillshade() {
return remotePost('get_contours_hillshade.php');
// This endpoint lives in its own /contours module and names the district
// parameter `districtid` (no underscore, numeric), unlike the
// /spatial_planning endpoints which use `district_id`. remotePost still
// merges in the standard credentials, so api_token is sent as usual.
const districtId = API_CREDENTIALS.district_id;
return remotePost('contours/get_by_district.php', {
districtid: districtId == null ? null : Number(districtId),
});
}
/**