diff --git a/app.js b/app.js index 21f4d38..ca37197 100644 --- a/app.js +++ b/app.js @@ -111,6 +111,9 @@ let pendingCompassHeading; let compassAnimationFrame; let deviceCompassFallbackTimer; let modalDrag; +let featureHoverRequest; +let featureHoverRequestId = 0; +let featureHoverTimer; const map = new ol.Map({ target: "map", @@ -173,6 +176,8 @@ document.addEventListener("keydown", (event) => { }); window.addEventListener("resize", keepFeatureModalInViewport); map.on("singleclick", showFeatureInformation); +map.on("pointermove", updateFeatureCursor); +map.getViewport().addEventListener("pointerleave", clearFeatureCursor); areaZoomInteraction.on("boxend", zoomToSelectedArea); areaZoomInteraction.on("boxcancel", () => { // A click or a very short drag is not a selection. Exit on the next frame so @@ -450,6 +455,7 @@ function setAreaZoomMode(active) { ); if (active) { + clearFeatureCursor(); closeFeatureModal(); } } @@ -655,6 +661,7 @@ function syncSelectedLayers() { return layerName === "parcelas" || layerName === unqualifiedLayerName(DWELLING_PARCEL_LAYER_NAME); }), ); + clearFeatureCursor(); } function isInternalLayer(name) { @@ -704,6 +711,51 @@ async function showFeatureInformation(event) { return; } + try { + const feature = await getFeatureAtCoordinate(event.coordinate); + if (feature) { + openFeatureModal(feature); + } + } catch (error) { + console.error(error); + } +} + +function updateFeatureCursor(event) { + if (event.dragging || areaZoomInteraction.getActive()) { + clearFeatureCursor(); + return; + } + + window.clearTimeout(featureHoverTimer); + const requestId = ++featureHoverRequestId; + const coordinate = [...event.coordinate]; + featureHoverTimer = window.setTimeout(async () => { + featureHoverRequest?.abort(); + featureHoverRequest = new AbortController(); + + try { + const feature = await getFeatureAtCoordinate(coordinate, featureHoverRequest.signal); + if (requestId === featureHoverRequestId) { + map.getViewport().style.cursor = feature ? "pointer" : ""; + } + } catch (error) { + if (error.name !== "AbortError" && requestId === featureHoverRequestId) { + map.getViewport().style.cursor = ""; + } + } + }, 120); +} + +function clearFeatureCursor() { + window.clearTimeout(featureHoverTimer); + featureHoverRequestId += 1; + featureHoverRequest?.abort(); + featureHoverRequest = undefined; + map.getViewport().style.cursor = ""; +} + +async function getFeatureAtCoordinate(coordinate, signal) { const queryLayers = availableLayers .map((layer) => layer.name) .filter( @@ -711,32 +763,25 @@ async function showFeatureInformation(event) { ); if (!queryLayers.length) { - return; + return null; } - const url = wmsSource.getFeatureInfoUrl(event.coordinate, map.getView().getResolution(), "EPSG:3857", { + const url = wmsSource.getFeatureInfoUrl(coordinate, map.getView().getResolution(), "EPSG:3857", { INFO_FORMAT: "application/json", QUERY_LAYERS: queryLayers.join(","), FEATURE_COUNT: 1, }); if (!url) { - return; + return null; } - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Feature information failed with status ${response.status}.`); - } - - const feature = (await response.json()).features?.[0]; - if (feature) { - openFeatureModal(feature); - } - } catch (error) { - console.error(error); + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`Feature information failed with status ${response.status}.`); } + + return (await response.json()).features?.[0] || null; } function openFeatureModal(feature) {