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:
parent
da6f968725
commit
7d26c740df
Binary file not shown.
BIN
LUPMIS2_Buffer_Circle_Tutorial.docx
Normal file
BIN
LUPMIS2_Buffer_Circle_Tutorial.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Training_Plan.docx
Normal file
BIN
LUPMIS2_Training_Plan.docx
Normal file
Binary file not shown.
11
dist/assets/analysis-modal-05Uf_OF7.js
vendored
11
dist/assets/analysis-modal-05Uf_OF7.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/analysis-modal-05Uf_OF7.js.map
vendored
1
dist/assets/analysis-modal-05Uf_OF7.js.map
vendored
File diff suppressed because one or more lines are too long
11
dist/assets/analysis-modal-BBuJH4BF.js
vendored
Normal file
11
dist/assets/analysis-modal-BBuJH4BF.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/analysis-modal-BBuJH4BF.js.map
vendored
Normal file
1
dist/assets/analysis-modal-BBuJH4BF.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-Cyeghdft.js.map
vendored
1
dist/assets/index-Cyeghdft.js.map
vendored
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
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
2
dist/index.html
vendored
@ -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
49
main.js
@ -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]));
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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]]);
|
||||
|
||||
|
||||
@ -85,6 +85,8 @@ function num(value) {
|
||||
* @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
|
||||
* 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. */
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user