feat: add parcel search to map visualizer
This commit is contained in:
@@ -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("'", "''");
|
||||
}
|
||||
|
||||
+22
@@ -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>
|
||||
|
||||
+10
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user