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

Integrate STAC Catalog and Bbox field on Databrowser #72

Merged
merged 12 commits into from
Feb 12, 2025
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ static/*
assets/bundles/*
webpack-stats.json
base/migrations/*

stac-browser/
59 changes: 54 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,18 @@ setup-django:

setup-rest:
TEMP_DIR=$$(mktemp -d) && \
git clone https://github.com/FREVA-CLINT/freva-nextgen.git $$TEMP_DIR &&\
git clone -b stac-catalog https://github.com/FREVA-CLINT/freva-nextgen.git $$TEMP_DIR &&\
python -m pip install $$TEMP_DIR/freva-rest $$TEMP_DIR/freva-data-portal-worker &&\
rm -rf $$TEMP_DIR

setup-stac: # this is a setup only for STAC-API and STAC-Browser, and not static STAC
python -m pip install stac-fastapi.opensearch
@echo "STAC FastAPI dependencies installed successfully"
@if [ ! -d "stac-browser" ]; then \
git clone https://github.com/radiantearth/stac-browser.git stac-browser; \
fi
cd stac-browser && npm install

setup-node:
npm install

Expand All @@ -47,7 +55,7 @@ runrest:
--key-file $(REDIS_SSL_KEYFILE)
python -m data_portal_worker -c .data-portal-cluster-config.json > rest.log 2>&1 &
python docker/config/dev-utils.py oidc http://localhost:8080/realms/freva/.well-known/openid-configuration
python -m freva_rest.cli -p 7777 --tls-key $(REDIS_SSL_KEYFILE) --tls-cert $(REDIS_SSL_CERTFILE) --debug --dev >> rest.log 2>&1 &
python -m freva_rest.cli -p 7777 --redis-ssl-keyfile $(REDIS_SSL_KEYFILE) --redis-ssl-certfile $(REDIS_SSL_CERTFILE) --debug --dev >> rest.log 2>&1 &
@echo "To watch the freva-rest logs, run 'tail -f rest.log'"

runfrontend:
Expand All @@ -56,14 +64,55 @@ runfrontend:
@echo "npm development server is running..."
@echo "To watch the npm logs, run 'tail -f npm.log'"

wait-for-opensearch:
@echo "wait until openseach wakes up"
@until curl -s "localhost:9202/_cluster/health" > /dev/null; do \
echo "we wait 2 secs for OpenSearch to wake up"; \
sleep 2; \
done
@echo "OpenSearch is ready to go, now we have to enable auto index creation"
@curl -XPUT "localhost:9202/_cluster/settings" -H 'Content-Type: application/json' -d'{ \
"persistent": { \
"cluster.blocks.create_index": null, \
"action.auto_create_index": true \
} \
}'

runstac: wait-for-opensearch
ES_HOST=localhost \
ES_PORT=9202 \
ES_USE_SSL=false \
ES_VERIFY_CERTS=false \
BACKEND=opensearch \
ENVIRONMENT=local \
APP_HOST=0.0.0.0 \
APP_PORT=8083 \
STAC_USERNAME=stac \
STAC_PASSWORD=secret \
STAC_FASTAPI_TITLE="Freva STAC Service" \
STAC_FASTAPI_DESCRIPTION="Freva STAC Service provides a SpatioTemporal Asset Catalog API" \
STAC_FASTAPI_ROUTE_DEPENDENCIES='[{"routes":[{"path":"/collections/{collection_id}/items/{item_id}","method":["PUT","DELETE"]},{"path":"/collections/{collection_id}/items","method":["POST"]},{"path":"/collections","method":["POST"]},{"path":"/collections/{collection_id}","method":["PUT","DELETE"]},{"path":"/collections/{collection_id}/bulk_items","method":["POST"]},{"path":"/aggregations","method":["POST"]},{"path":"/collections/{collection_id}/aggregations","method":["POST"]},{"path":"/aggregate","method":["POST"]},{"path":"/aggregate","method":["POST"]},{"path":"/collections/{collection_id}/aggregate","method":["POST"]}],"dependencies":[{"method":"stac_fastapi.core.basic_auth.BasicAuth","kwargs":{"credentials":[{"username":"stac","password":"secret"}]}}]}]' \
python -m stac_fastapi.opensearch.app >> stac.log 2>&1 &
@echo "STAC service is running..."
@echo "To watch the STAC logs, run 'tail -f stac.log'"
cd stac-browser && npm start -- --open --catalogUrl=http://localhost:8083 --port 8085 > stac-browser.log 2>&1 &
@echo "STAC Browser is running..."
@echo "To watch the STAC Browser logs, run 'tail -f stac-browser.log'"

stopserver:
ps aux | grep '[f]reva_rest.cli' | awk '{print $$2}' | xargs -r kill
ps aux | grep '[d]ata_portal_worker' | awk '{print $$2}' | xargs -r kill
ps aux | grep '[m]anage.py runserver' | awk '{print $$2}' | xargs -r kill
@if pgrep -f '[s]tac_fastapi.opensearch.app' > /dev/null; then \
ps aux | grep '[s]tac_fastapi.opensearch.app' | awk '{print $$2}' | head -n 1 | xargs kill; \
sleep 1; \
fi
pkill -f "stac-browser" || true
rm -fr .data-portal-cluster-config.json
echo "Stopped Django development server..." > runserver.log
echo "Stopped freva-rest development server..." > rest.log

echo "Stopped STAC service..." > stac.log
echo "Stopped STAC Browser..." >> stac-browser.log

stopfrontend:
pkill -f "npm run dev"
Expand All @@ -72,9 +121,9 @@ stopfrontend:
stop: stopserver stopfrontend
@echo "All services have been stopped."

setup: setup-rest setup-node setup-django dummy-data
setup: setup-rest setup-node setup-django dummy-data setup-stac

run: runrest runfrontend runserver
run: runrest runfrontend runserver runstac

lint: setup-node
npm run lint-format
Expand Down
267 changes: 267 additions & 0 deletions assets/js/Containers/Databrowser/BBoxSelector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { withRouter } from "react-router";
import queryString from "query-string";
import {
BsRecordCircleFill,
BsRecordCircle,
BsCircleSquare,
} from "react-icons/bs";
import {
Button,
InputGroup,
Form,
OverlayTrigger,
Tooltip,
Dropdown,
DropdownButton,
} from "react-bootstrap";

import {
BBOX_RANGE_FILE,
BBOX_RANGE_FLEXIBLE,
BBOX_RANGE_STRICT,
} from "./constants";

function BBoxSelector({ databrowser, router, location }) {
const [selector, setSelector] = useState(BBOX_RANGE_FLEXIBLE);
const [minLon, setMinLon] = useState("");
const [maxLon, setMaxLon] = useState("");
const [minLat, setMinLat] = useState("");
const [maxLat, setMaxLat] = useState("");

useEffect(() => {
setMinLon(databrowser.minLon || "");
setMaxLon(databrowser.maxLon || "");
setMinLat(databrowser.minLat || "");
setMaxLat(databrowser.maxLat || "");
setSelector(databrowser.bboxSelector || BBOX_RANGE_FLEXIBLE);
}, [databrowser]);

function applyChanges() {
const currentLocation = location.pathname;
const {
bboxSelector: ignore1,
minLon: ignore2,
maxLon: ignore3,
minLat: ignore4,
maxLat: ignore5,
...queryObject
} = location.query;

const query = queryString.stringify({
...queryObject,
minLon,
maxLon,
minLat,
maxLat,
bboxSelector: selector,
});
router.push(currentLocation + "?" + query);
}

function onKeyPress(errorMessage, e) {
const enterKey = 13;
if (!errorMessage && e.charCode === enterKey) {
applyChanges();
}
}

const isValidLongitude = (value) => {
const num = Number(value);
return Number.isFinite(num) && num >= -180 && num <= 180;
};

const isValidLatitude = (value) => {
const num = Number(value);
return Number.isFinite(num) && num >= -90 && num <= 90;
};

const minLonError =
minLon && !isValidLongitude(minLon)
? "Invalid longitude (-180 to 180)"
: minLon && maxLon && parseFloat(minLon) > parseFloat(maxLon)
? "Max longitude must be greater than min longitude"
: "";

const maxLonError =
maxLon && !isValidLongitude(maxLon)
? "Invalid longitude (-180 to 180)"
: minLon && maxLon && parseFloat(minLon) > parseFloat(maxLon)
? "Max longitude must be greater than min longitude"
: "";
const minLatError =
minLat && !isValidLatitude(minLat)
? "Invalid latitude (-90 to 90)"
: minLat && maxLat && parseFloat(minLat) > parseFloat(maxLat)
? "Max latitude must be greater than min latitude"
: "";

const maxLatError =
maxLat && !isValidLatitude(maxLat)
? "Invalid latitude (-90 to 90)"
: minLat && maxLat && parseFloat(minLat) > parseFloat(maxLat)
? "Max latitude must be greater than min latitude"
: "";

let errorMessage = minLonError || maxLonError || minLatError || maxLatError;

if (!minLon || !maxLon || !minLat || !maxLat) {
errorMessage = "All coordinates are required";
}
if (
!errorMessage &&
minLon &&
maxLon &&
parseFloat(minLon) > parseFloat(maxLon)
) {
errorMessage = "Max longitude must be greater than min longitude";
}
if (
!errorMessage &&
minLat &&
maxLat &&
parseFloat(minLat) > parseFloat(maxLat)
) {
errorMessage = "Max latitude must be greater than min latitude";
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would like it if error messages like this would also appear in the minLatError or the maxLatError (one is enough I guess) and not only in the tooltip.

}

let applyButton;
if (errorMessage) {
const tooltip = <Tooltip id="boxWarning">{errorMessage}</Tooltip>;
applyButton = (
<OverlayTrigger overlay={tooltip} placement="top">
<span>
<Button variant="danger" disabled>
Apply
</Button>
</span>
</OverlayTrigger>
);
} else {
applyButton = (
<Button variant="primary" onClick={applyChanges.bind(this)}>
Apply
</Button>
);
}

return (
<div onKeyPress={onKeyPress.bind(this, errorMessage)}>
<InputGroup className="mb-4">
<InputGroup.Text id="bbox-text">Operator</InputGroup.Text>
<DropdownButton
className="selector-button"
variant="outline-secondary"
title={selector}
id="time-operator-dropdown"
>
<Dropdown.Item
onClick={() => setSelector(BBOX_RANGE_FLEXIBLE)}
href="#"
>
<BsCircleSquare /> {BBOX_RANGE_FLEXIBLE}
</Dropdown.Item>
<Dropdown.Item
onClick={() => setSelector(BBOX_RANGE_STRICT)}
href="#"
>
<BsRecordCircleFill /> {BBOX_RANGE_STRICT}
</Dropdown.Item>
<Dropdown.Item onClick={() => setSelector(BBOX_RANGE_FILE)} href="#">
<BsRecordCircle /> {BBOX_RANGE_FILE}
</Dropdown.Item>
</DropdownButton>
</InputGroup>

<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "1rem",
marginBottom: "1rem",
}}
>
<div>
<InputGroup>
<InputGroup.Text>Min Lon</InputGroup.Text>
<Form.Control
value={minLon}
placeholder="-180"
onChange={(e) => setMinLon(e.target.value)}
isInvalid={!!minLonError}
/>
</InputGroup>
<div className="text-danger small">{minLonError}&nbsp;</div>
</div>
<div>
<InputGroup>
<InputGroup.Text>Max Lon</InputGroup.Text>
<Form.Control
value={maxLon}
placeholder="180"
onChange={(e) => setMaxLon(e.target.value)}
isInvalid={!!maxLonError}
/>
</InputGroup>
<div className="text-danger small">{maxLonError}&nbsp;</div>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "1rem",
marginBottom: "1.5rem",
}}
>
<div>
<InputGroup>
<InputGroup.Text>Min Lat</InputGroup.Text>
<Form.Control
value={minLat}
placeholder="-90"
onChange={(e) => setMinLat(e.target.value)}
isInvalid={!!minLatError}
/>
</InputGroup>
<div className="text-danger small">{minLatError}&nbsp;</div>
</div>
<div>
<InputGroup>
<InputGroup.Text>Max Lat</InputGroup.Text>
<Form.Control
value={maxLat}
placeholder="90"
onChange={(e) => setMaxLat(e.target.value)}
isInvalid={!!maxLatError}
/>
</InputGroup>
<div className="text-danger small">{maxLatError}&nbsp;</div>
</div>
</div>

{applyButton}
</div>
);
}

BBoxSelector.propTypes = {
databrowser: PropTypes.shape({
minLon: PropTypes.string,
maxLon: PropTypes.string,
minLat: PropTypes.string,
maxLat: PropTypes.string,
bboxSelector: PropTypes.string,
}),
location: PropTypes.object.isRequired,
router: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};

const mapStateToProps = (state) => ({
databrowser: state.databrowserReducer,
});

export default withRouter(connect(mapStateToProps)(BBoxSelector));
Loading