Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Geospatial custom fields and Map/Attribute viewer to Record view #221

Merged
merged 16 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions invenio.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This file was automatically generated by 'invenio-cli init'.
For the full list of settings and their values, see
https://inveniordm.docs.cern.ch/reference/configuration/.
"""
from invenio_records_resources.services.custom_fields import TextCF, BooleanCF
#required to use custom permissions
from ultraviolet_saml.handlers import acs_handler_factory
from ultraviolet_permissions.policies import UltraVioletPermissionPolicy
Expand Down Expand Up @@ -71,6 +72,13 @@ APP_DEFAULT_SECURE_HEADERS = {
"blob:", # for pdf preview
# Add your own policies here (e.g. analytics)
],
'img-src': [
"'self'",
'data:',
"https://*.basemaps.cartocdn.com",
"https://maps-public.geo.nyu.edu",
"https://maps-restricted.geo.nyu.edu",
Comment on lines +78 to +80
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are here to allow pulling content from external sources on the front-end.

]
},
'content_security_policy_report_only': False,
'content_security_policy_report_uri': None,
Expand Down Expand Up @@ -421,6 +429,8 @@ if APP_ENVIRONMENT == 'local':
'[email protected]': 'adminUV'
}

RATELIMIT_ENABLED = False
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only enabled in local and to allow bulk loading data via the REST API.



# UltraViolet custom vars
# =======================
Expand Down Expand Up @@ -451,3 +461,72 @@ NYU_LIBRARIES_HOMEPAGE = 'https://library.nyu.edu'

# Max file size for displaying download buttons and link (50 GB)
MAX_FILE_SIZE = 50 * 1024 * 1024 * 1024

RDM_NAMESPACES = {
"geoserver": "https://geoserver.org/"
}

RDM_CUSTOM_FIELDS = [
BooleanCF(name="geoserver:has_wms_layer"),
BooleanCF(name="geoserver:has_wfs_layer"),
TextCF(name="geoserver:layer_name"),
TextCF(name="geoserver:bounds"),
]

GEOSERVER_PUBLIC_URL = "https://maps-public.geo.nyu.edu/geoserver/sdr"
GEOSERVER_RESTRICTED_URL = "https://maps-restricted.geo.nyu.edu/geoserver/sdr"

RDM_CUSTOM_FIELDS_UI = [
{
"section": _("GeoServer"),
"hide_from_landing_page": True,
"fields": [
dict(
field="geoserver:layer_name",
ui_widget="Input",
props=dict(
label="Layer Name",
placeholder="sdr:nyu_2451_12345",
icon="pencil",
description="Name of the GeoServer Layer this data can be found in",
required=False
)
),
dict(
field="geoserver:has_wms_layer",
ui_widget="BooleanCheckbox",
props=dict(
label="WMS Layer?",
icon="check",
description="Does this record have a WMS layer in GeoServer?",
required=False,
trueLabel="Yes",
falseLabel="No",
)
),
dict(
field="geoserver:has_wfs_layer",
ui_widget="BooleanCheckbox",
props=dict(
label="WFS Layer?",
icon="check",
description="Does this record have a WFS layer in GeoServer?",
required=False,
trueLabel="Yes",
falseLabel="No",
)
),
dict(
field="geoserver:bounds",
ui_widget="Input",
props=dict(
label="Bounds",
placeholder="ENVELOPE(-178.2176, -66.969275, 71.406235818, 18.921781818)",
icon="pencil",
description="The envelope for the bounds of this layer",
required=False
)
)
]
}
]
Empty file.
16 changes: 16 additions & 0 deletions site/ultraviolet/assets/semantic-ui/css/ultraviolet/geoserver.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@import "leaflet/dist/leaflet.css";
@import 'ol/ol.css';

#map {
width: 100%;
height: 440px;
margin-top: 1em;
cursor: crosshair !important;
border: 1px solid #ced4da;
z-index: 99; /* Place map under invenio-menu */
}

.attributes {
max-height: 450px;
overflow-y: scroll;
}
162 changes: 162 additions & 0 deletions site/ultraviolet/assets/semantic-ui/js/ultraviolet/geoserver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import L from "leaflet"
import "leaflet/dist/leaflet.css";

const populateAttributeTable = (data) => {
const attributesElement = document.getElementById("attributes");
attributesElement.innerHTML = '';

Object.keys(data.properties).sort().forEach(property => {
const nameTd = document.createElement('td');
nameTd.textContent = property
const typeTd = document.createElement("td")
typeTd.textContent = data.properties[property]

const tr = document.createElement("tr")
tr.appendChild(nameTd)
tr.appendChild(typeTd)
attributesElement.appendChild(tr)
})
}

const retrieveAttributeTypes = (wfsUrl, layerNames) => {
const attributesElement = document.getElementById("attributes");

const formData = new FormData();
formData.append("url", wfsUrl);
formData.append("layers", layerNames);

fetch("/geoserver/describe_feature_type", {
method: "POST", body: formData
})
.then(response => response.json())
.then(data => {
const attributes = data.featureTypes[0].properties.sort((a, b) => {
return a.name.localeCompare(b.name)
})

attributes.forEach(attribute => {
const nameTd = document.createElement('td');
nameTd.textContent = attribute.name
const typeTd = document.createElement("td")
typeTd.textContent = attribute.localType

const tr = document.createElement("tr")
tr.appendChild(nameTd)
tr.appendChild(typeTd)
attributesElement.appendChild(tr)
})
})
.catch(error => {
console.error('Error:', error);
});
};

const addFeatureInspectionHandler = (map, url, layerNames) => {
map.on("click", async (e) => {
const attributesElement = document.getElementById("attributes");
attributesElement.innerHTML = '<tr><td colspan="2">Loading...</td>';

try {
const response = await fetch("/geoserver/get_feature_info", {
method: "POST", headers: {
"Content-Type": "application/json",
}, body: JSON.stringify({
url: url,
layers: layerNames,
bbox: map.getBounds().toBBoxString(),
width: Math.round(document.getElementById("map").clientWidth),
height: Math.round(document.getElementById("map").clientHeight),
query_layers: layerNames,
x: Math.round(e.containerPoint.x),
y: Math.round(e.containerPoint.y),
}),
});

if (!response.ok) throw new Error("Network response was not ok.");

const response_data = await response.json();

if (response_data.hasOwnProperty("error") || response_data.hasOwnProperty('exceptions') || response_data.features.length === 0) {
const attributesElement = document.getElementById("attributes");
attributesElement.innerHTML = '<tr><td colspan="2">No feature found</td>';

return;
}
const data = response_data.features[0];

populateAttributeTable(data);
} catch (error) {
console.error("Fetch error: ", error);
}
});
}

const zoomMapToBoundingBox = (map) => {
const mapElement = document.getElementById("map");
const preview = mapElement.getAttribute("data-preview");
const bounds = mapElement.getAttribute("data-bounds")

const regex = /ENVELOPE\(([-\d.]+), ([-\d.]+), ([-\d.]+), ([-\d.]+)\)/;
const match = bounds.match(regex);

if (match) {
let [_, minLon, maxLon, minLat, maxLat] = match;

const bounds = [[minLat, minLon], [maxLat, maxLon]];

map.fitBounds(bounds);

if (preview === "False") {
map.addLayer(L.rectangle(bounds, {color: "#3388FF", weight: 3}));
}
}
};

const addWmsLayer = (map) => {
const mapElement = document.getElementById("map");
const baseUrl = mapElement.getAttribute("data-wms-url")
const layerName = mapElement.getAttribute("data-layer-name")

const wmsLayer = L.tileLayer.wms(baseUrl, {
layers: layerName,
format: 'image/png',
transparent: true,
opacity: 0.75
});

wmsLayer.addTo(map);
wmsLayer.setOpacity(0.75);
};

const addWfsInspection = map => {
const attributesElement = document.getElementById("attributes");

if (attributesElement) {
const wfsUrl = attributesElement.getAttribute("data-wfs-url");
const layerNames = attributesElement.getAttribute("data-layer-names");

addFeatureInspectionHandler(map, wfsUrl, layerNames);
retrieveAttributeTypes(wfsUrl, layerNames);
}
};

document.addEventListener("DOMContentLoaded", async () => {
const mapElement = document.getElementById("map");
const preview = mapElement.getAttribute("data-preview");

const map = L.map('map').setView([0, 0], 13);

L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{retina}.png', {
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, &copy; <a href="http://carto.com/attributions">Carto</a>',
maxZoom: 18,
worldCopyJump: true,
retina: "@2x",
}).addTo(map);

if (preview === "True") {
addWmsLayer(map);
addWfsInspection(map);
}

zoomMapToBoundingBox(map);
});
Empty file.
27 changes: 27 additions & 0 deletions site/ultraviolet/geoserver/describe_feature_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import urllib

from flask import request, Response
from flask.views import MethodView

import requests


class DescribeFeatureType(MethodView):
"""Proxy for GeoServer DescribeFeatureType requests."""

def post(self):
"""Pass DescribeFeatureType requests to GeoServer and hand back results."""
url = request.form.get('url', default=None)
layers = request.form.get('layers', default=None)

query_string = urllib.parse.urlencode({
"outputFormat": "application/json",
"request": "DescribeFeatureType",
"service": "WFS",
"typeName": layers,
"version": "1.1.0",
})

response = requests.get("{0}?{1}".format(url, query_string))

return Response(response.text, mimetype='application/json')
35 changes: 35 additions & 0 deletions site/ultraviolet/geoserver/get_feature_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import urllib

from flask import request, Response
from flask.views import MethodView

import requests


class GetFeatureInfo(MethodView):
"""Proxy for GeoServer GetFeatureInfo requests."""

def post(self):
"""Pass GetFeatureInfo requests to GeoServer and hand back results."""
data = request.get_json()

url = data.get('url', None)
query_string = urllib.parse.urlencode({
"service": "WMS",
"version": "1.1.1",
"request": "GetFeatureInfo",
"layers": data.get('layers', None),
"query_layers": data.get('layers', None),
"bbox": data.get('bbox', None),
"width": data.get('width', None),
"height": data.get('height', None),
"x": data.get('x', None),
"y": data.get('y', None),
"srs": "EPSG:4326",
"info_format": "application/json",
"styles": ""
})

response = requests.get("{0}?{1}".format(url, query_string))

return Response(response.text, mimetype='application/json')
Loading