Compare commits

..
2 Commits
Author SHA1 Message Date
jfig 5d19c5f085 feat: add parcel search to map visualizer 2026-07-20 00:26:24 +01:00
jfig 440ee6cc9a chore: support local GeoServer development 2026-07-20 00:26:24 +01:00
5 changed files with 124 additions and 7 deletions
+87 -4
View File
@@ -1,37 +1,52 @@
const DEFAULT_CENTER = [-8.65, 39.55];
const API_CAPABILITIES = "/api/capabilities";
const API_WMS = "/api/wms";
const PARCEL_LAYER_NAME = "xpro:parcelas";
const els = {
status: document.getElementById("status"),
layerList: document.getElementById("layer-list"),
layerCount: document.getElementById("layer-count"),
parcelSearchForm: document.getElementById("parcel-search-form"),
parcelSearchInput: document.getElementById("parcel-search-input"),
parcelSearchStatus: document.getElementById("parcel-search-status"),
};
const osmLayer = new ol.layer.Tile({
source: new ol.source.OSM(),
});
const wmsSource = new ol.source.TileWMS({
// Use one WMS image for the viewport so GeoServer can de-conflict parcel
// labels across the whole map, rather than placing the same label per tile.
const wmsSource = new ol.source.ImageWMS({
url: API_WMS,
params: {
LAYERS: "",
TILED: true,
FORMAT: "image/png",
TRANSPARENT: true,
},
ratio: 1,
crossOrigin: "anonymous",
});
const wmsLayer = new ol.layer.Tile({
const wmsLayer = new ol.layer.Image({
opacity: 0.8,
visible: false,
source: wmsSource,
});
const parcelSearchSource = new ol.source.Vector();
const parcelSearchLayer = new ol.layer.Vector({
source: parcelSearchSource,
style: new ol.style.Style({
stroke: new ol.style.Stroke({ color: "#d9582b", width: 3 }),
fill: new ol.style.Fill({ color: "rgba(255, 201, 82, 0.22)" }),
}),
});
const map = new ol.Map({
target: "map",
layers: [osmLayer, wmsLayer],
layers: [osmLayer, wmsLayer, parcelSearchLayer],
view: new ol.View({
center: ol.proj.fromLonLat(DEFAULT_CENTER),
zoom: 7,
@@ -41,8 +56,67 @@ const map = new ol.Map({
let availableLayers = [];
let selectedLayers = [];
els.parcelSearchForm.addEventListener("submit", searchParcela);
loadLayers();
async function searchParcela(event) {
event.preventDefault();
const parcelNumber = els.parcelSearchInput.value.trim();
if (!parcelNumber) {
updateParcelSearchStatus("Enter a parcel number to search.", "error");
return;
}
updateParcelSearchStatus("Searching for parcel…");
parcelSearchSource.clear();
try {
const params = new URLSearchParams({
service: "WFS",
version: "2.0.0",
request: "GetFeature",
typeNames: PARCEL_LAYER_NAME,
outputFormat: "application/json",
srsName: "EPSG:3857",
CQL_FILTER: `cod_parcela = '${escapeCqlLiteral(parcelNumber)}'`,
});
const response = await fetch(`${API_WMS}?${params}`);
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}.`);
}
const featureCollection = await response.json();
const features = new ol.format.GeoJSON().readFeatures(featureCollection, {
featureProjection: "EPSG:3857",
});
if (!features.length) {
updateParcelSearchStatus(`No parcel found with number “${parcelNumber}”.`, "error");
return;
}
parcelSearchSource.addFeatures(features);
map.getView().fit(parcelSearchSource.getExtent(), {
padding: [64, 64, 64, 64],
duration: 500,
maxZoom: features.length === 1 ? 19 : 16,
});
updateParcelSearchStatus(
features.length === 1
? `Found parcel ${parcelNumber}.`
: `Found ${features.length} parcels numbered ${parcelNumber}.`,
"success",
);
} catch (error) {
console.error(error);
updateParcelSearchStatus(error.message || "Unable to search for that parcel.", "error");
}
}
async function loadLayers() {
updateStatus("Loading WMS layers...");
@@ -198,3 +272,12 @@ function updateStatus(message, tone) {
els.status.textContent = message;
els.status.className = tone ? `status ${tone}` : "status";
}
function updateParcelSearchStatus(message, tone) {
els.parcelSearchStatus.textContent = message;
els.parcelSearchStatus.className = tone ? `status ${tone}` : "status";
}
function escapeCqlLiteral(value) {
return value.replaceAll("'", "''");
}
+4 -2
View File
@@ -7,9 +7,11 @@ services:
restart: unless-stopped
environment:
PORT: ${APP_PORT:-4173}
WMS_ENDPOINT: ${WMS_ENDPOINT:-https://xpro-viz.jfig.net/geoserver/xpro/wms}
ALLOWED_WMS_ENDPOINTS: ${ALLOWED_WMS_ENDPOINTS:-https://xpro-viz.jfig.net/geoserver/xpro/wms}
WMS_ENDPOINT: ${DOCKER_WMS_ENDPOINT:-${WMS_ENDPOINT:-https://xpro-viz.jfig.net/geoserver/xpro/wms}}
ALLOWED_WMS_ENDPOINTS: ${DOCKER_ALLOWED_WMS_ENDPOINTS:-${ALLOWED_WMS_ENDPOINTS:-https://xpro-viz.jfig.net/geoserver/xpro/wms}}
GEOSERVER_USERNAME: ${GEOSERVER_USERNAME:-admin}
GEOSERVER_PASSWORD: ${GEOSERVER_PASSWORD}
ports:
- "${APP_PORT:-4173}:${APP_PORT:-4173}"
extra_hosts:
- "host.docker.internal:host-gateway"
+22
View File
@@ -14,6 +14,28 @@
<h1>XPro Map Visualizer</h1>
</div>
<section class="panel">
<div class="panel-heading">
<h2>Find parcel</h2>
</div>
<form id="parcel-search-form" class="parcel-search-form">
<label for="parcel-search-input">
<span>Parcel number</span>
<input
id="parcel-search-input"
name="parcel"
type="search"
inputmode="text"
placeholder="e.g. 57 or 2669.1"
autocomplete="off"
required
/>
</label>
<button type="submit">Find on map</button>
</form>
<p id="parcel-search-status" class="status" aria-live="polite"></p>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Layers</h2>
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node server.js"
"start": "node --env-file-if-exists=.env server.js"
},
"dependencies": {
"ol": "10.6.1"
+10
View File
@@ -83,6 +83,16 @@ form.panel {
gap: 14px;
}
.parcel-search-form {
display: grid;
gap: 12px;
margin-top: 16px;
}
.parcel-search-form button {
width: 100%;
}
label {
display: flex;
flex-direction: column;