diff --git a/pyproject.toml b/pyproject.toml index dea8f9a1..7571f887 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,8 @@ cli = [ stac_server = [ "fastapi[standard]", "Jinja2", + "earthkit-data", + "polytope-client", ] docs = [ diff --git a/stac_server/POLYTOPE_AUTH_TESTING.md b/stac_server/POLYTOPE_AUTH_TESTING.md new file mode 100644 index 00000000..bd83e217 --- /dev/null +++ b/stac_server/POLYTOPE_AUTH_TESTING.md @@ -0,0 +1,47 @@ +# Polytope Authentication + +The STAC server's Polytope integration collects authentication credentials directly from users through the web interface and queries the **Destination Earth** Polytope service. + +## User Flow + +1. Navigate through the STAC catalogue and select your data +2. At the end of the catalogue, you'll see the "Query Data with Polytope" section +3. Enter your Destination Earth Polytope credentials: + - **Email Address**: Your email address registered with Destination Earth + - **API Key**: Your Polytope API key +4. Click "Query Polytope Service" to submit your data extraction requests + +## Getting Your Polytope API Key + +1. Visit the [Destination Earth Service Platform (DESP)](https://auth.destine.eu) +2. Log in with your Destination Earth credentials +3. Navigate to your profile or settings +4. Generate or copy your API key + +## Technical Details + +The service connects to the Destination Earth Polytope instance: +- **Address**: `polytope.lumi.apps.dte.destination-earth.eu` +- **Collection**: `destination-earth` + +## Security Notes + +- Credentials are sent securely with each request +- Credentials are not stored on the server +- Each user provides their own authentication +- Credentials are only logged in a masked format for debugging + +## For Developers + +The credentials are sent in the request body as: +```json +{ + "requests": [...], + "credentials": { + "user_email": "user@ecmwf.int", + "user_key": "your_api_key" + } +} +``` + +The backend passes these credentials directly to `earthkit.data.from_source()` when querying Polytope. diff --git a/stac_server/main.py b/stac_server/main.py index 328a6876..bd5a0f6f 100644 --- a/stac_server/main.py +++ b/stac_server/main.py @@ -120,6 +120,15 @@ async def deprecated(): @app.get("/", response_class=HTMLResponse) async def read_root(request: Request): + index_config = { + "title": os.environ.get("TITLE", "Qubed Catalogue Browser"), + } + + return templates.TemplateResponse(request, "landing.html", index_config) + + +@app.get("/browse", response_class=HTMLResponse) +async def browse_catalogue(request: Request): index_config = { "api_url": os.environ.get("API_URL", "/api/v2/"), "title": os.environ.get("TITLE", "Qubed Catalogue Browser"), @@ -147,6 +156,120 @@ async def union( return qube.to_json() +@app.post("/api/v2/polytope/query") +async def query_polytope( + body_json=Depends(get_body_json), +): + """ + Query the Destination Earth Polytope data extraction service with MARS requests. + Expects a JSON body with: + - 'requests': array of MARS request objects + - 'credentials': object with 'user_email' and 'user_key' fields + + Connects to: polytope.lumi.apps.dte.destination-earth.eu + Collection: destination-earth + """ + try: + import earthkit.data + except ImportError: + raise HTTPException( + status_code=500, + detail="earthkit.data is not installed. Please install it with 'pip install earthkit-data'", + ) + + requests = body_json.get("requests", []) + if not requests: + raise HTTPException(status_code=400, detail="No requests provided") + + # Get credentials from request body + credentials = body_json.get("credentials", {}) + user_email = credentials.get("user_email") + user_key = credentials.get("user_key") + + if not user_email or not user_key: + raise HTTPException( + status_code=400, + detail="Credentials required: provide user_email and user_key", + ) + + # Prepare kwargs for polytope connection + polytope_kwargs = { + "stream": False, + "address": "polytope.lumi.apps.dte.destination-earth.eu", + "user_email": user_email, + "user_key": user_key, + } + + logger.info(f"Querying Polytope with user email: {user_email}") + + results = [] + successful = 0 + failed = 0 + + for idx, mars_request in enumerate(requests): + try: + logger.info(f"Querying Polytope for request {idx + 1}/{len(requests)}") + logger.debug(f"Request: {mars_request}") + + # Query Polytope service + ds = earthkit.data.from_source( + "polytope", "destination-earth", mars_request, **polytope_kwargs + ) + + # Get JSON representation of the data + try: + ds_json = ds._json() + logger.info(f"Successfully extracted JSON from request {idx + 1}") + except Exception as json_error: + logger.warning( + f"Could not extract JSON from request {idx + 1}: {json_error}" + ) + ds_json = None + + # Get some basic info about the result + data_info = ( + f"Retrieved {len(ds)} fields" + if hasattr(ds, "__len__") + else "Data retrieved" + ) + + result_entry = { + "success": True, + "request_index": idx, + "message": data_info, + "data_size": str(len(ds)) if hasattr(ds, "__len__") else None, + "mars_request": mars_request, + } + + # Add JSON data if available + if ds_json is not None: + result_entry["json_data"] = ds_json + + results.append(result_entry) + successful += 1 + logger.info(f"Request {idx + 1} successful: {data_info}") + + except Exception as e: + error_msg = str(e) + logger.error(f"Request {idx + 1} failed: {error_msg}") + results.append( + { + "success": False, + "request_index": idx, + "error": error_msg, + "mars_request": mars_request, + } + ) + failed += 1 + + return { + "total": len(requests), + "successful": successful, + "failed": failed, + "results": results, + } + + def follow_query(request: dict[str, str | list[str]], qube: Qube): rel_qube = qube.select(request, consume=False) diff --git a/stac_server/static/app.js b/stac_server/static/app.js index 8250cc82..23899c84 100644 --- a/stac_server/static/app.js +++ b/stac_server/static/app.js @@ -82,7 +82,8 @@ function goToNextUrl() { ); } - const any = item.querySelector("input[type='text']"); + // Get text inputs but exclude the filter input + const any = item.querySelector("input[type='text']:not(.filter-input)"); if (any && any.value !== "") { values.push(any.value); } @@ -160,47 +161,238 @@ async function createCatalogItem(link, itemsContainer) { }

`; - if (false && key === "date") { - console.log("Date", variable, exports); + if (key === "date" && variable.enum && variable.enum.length > 30) { + console.log("Date picker enabled"); + console.log("First few dates:", variable.enum.slice(0, 10)); - itemDiv.appendChild(toHTML("")); - let dates = variable.enum; + // Create a unique ID for this date picker + const pickerId = `date-picker-${link.title}`; + const hiddenInputId = `date-input-${link.title}`; + + itemDiv.appendChild(toHTML(``)); + itemDiv.appendChild(toHTML(``)); + itemDiv.appendChild(toHTML(`
💡 Click a date twice to select it individually, or click two different dates to select a range.
`)); + + let dates = variable.enum.map(d => String(d)); itemDiv.querySelector("button.all").style.display = "none"; - let picker = new AirDatepicker("#date-picker", { + // Create a set for fast lookup (normalize to YYYY-MM-DD format) + const availableDatesSet = new Set(dates); + console.log("Available dates set size:", availableDatesSet.size); + + // Parse dates from enum to get min and max dates + let parsedDates = dates.map(d => { + // Handle both formats: "YYYY-MM-DD" or "YYYYMMDD" + const dateStr = String(d); + if (dateStr.includes('-')) { + return new Date(dateStr); + } else { + const year = parseInt(dateStr.substring(0, 4)); + const month = parseInt(dateStr.substring(4, 6)) - 1; + const day = parseInt(dateStr.substring(6, 8)); + return new Date(year, month, day); + } + }); + + let minDate = new Date(Math.min(...parsedDates)); + let maxDate = new Date(Math.max(...parsedDates)); + + console.log("Date range:", minDate.toISOString(), "to", maxDate.toISOString()); + + // Track selected dates manually for better control + let manuallySelectedDates = new Set(); + let lastClickedDate = null; + + let picker = new AirDatepicker(`#${pickerId}`, { position: "bottom center", inline: true, locale: exports.default, - range: true, - multipleDatesSeparator: " - ", + multipleDates: true, + multipleDatesSeparator: ",", + minDate: minDate, + maxDate: maxDate, + onSelect({ date, formattedDate, datepicker }) { + // Prevent default behavior - we'll handle selection manually + }, onRenderCell({ date, cellType }) { - let isDay = cellType === "day", - _date = - String(date.getFullYear()).padStart(4, "0") + - String(date.getMonth()).padStart(2, "0") + - String(date.getDate()).padStart(2, "0"), - shouldChangeContent = isDay && dates.includes(_date); - - return { - classes: shouldChangeContent ? "has-data" : undefined, - }; + if (cellType === "day") { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + // Check in the format that matches the input + let dateStr; + if (dates[0].includes('-')) { + dateStr = `${year}-${month}-${day}`; + } else { + dateStr = `${year}${month}${day}`; + } + const hasData = availableDatesSet.has(dateStr); + + return { + classes: hasData ? "has-data" : "", + disabled: !hasData, + }; + } + return {}; }, }); + + // Custom click handler for date cells + const hintElement = document.getElementById(`${pickerId}-hint`); + + // Wait for datepicker to render, then attach event handler + setTimeout(() => { + const datepickerContainer = document.querySelector(`#${pickerId}`).parentElement.querySelector('.air-datepicker'); + + if (datepickerContainer) { + datepickerContainer.addEventListener('click', (e) => { + const cell = e.target.closest('.air-datepicker-cell.-day-'); + if (!cell || cell.classList.contains('-disabled-')) return; + + // Get the date from the cell's data attributes + const dayNumber = cell.getAttribute('data-date'); + const monthNumber = cell.getAttribute('data-month'); + const yearNumber = cell.getAttribute('data-year'); + + if (!dayNumber || !monthNumber || !yearNumber) return; + + const cellDate = new Date(parseInt(yearNumber), parseInt(monthNumber), parseInt(dayNumber)); + + const formatDate = (d) => { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + if (dates[0].includes('-')) { + return `${year}-${month}-${day}`; + } else { + return `${year}${month}${day}`; + } + }; + + const clickedDateStr = formatDate(cellDate); + + // Check if this date has data + if (!availableDatesSet.has(clickedDateStr)) return; + + const isSameAsPrevious = lastClickedDate && + cellDate.getTime() === lastClickedDate.getTime(); + + if (isSameAsPrevious) { + // Clicking same date twice - toggle individual date + if (manuallySelectedDates.has(clickedDateStr)) { + manuallySelectedDates.delete(clickedDateStr); + console.log("Removed date:", clickedDateStr); + if (hintElement) hintElement.textContent = `🗑️ Removed ${clickedDateStr}. Total: ${manuallySelectedDates.size} dates selected.`; + } else { + manuallySelectedDates.add(clickedDateStr); + console.log("Added single date:", clickedDateStr); + if (hintElement) hintElement.textContent = `✅ Added ${clickedDateStr}. Total: ${manuallySelectedDates.size} dates selected.`; + } + lastClickedDate = null; // Reset for next selection + } else if (lastClickedDate) { + // Two different dates clicked - create a range + const [startDate, endDate] = [lastClickedDate, cellDate].sort((a, b) => a - b); + + console.log("Creating range from", formatDate(startDate), "to", formatDate(endDate)); + + let currentDate = new Date(startDate); + const rangeEnd = new Date(endDate); + let rangeCount = 0; + + while (currentDate <= rangeEnd) { + const dateStr = formatDate(currentDate); + if (availableDatesSet.has(dateStr)) { + manuallySelectedDates.add(dateStr); + rangeCount++; + } + currentDate.setDate(currentDate.getDate() + 1); + } + + console.log("Range added, total dates selected:", manuallySelectedDates.size); + if (hintElement) hintElement.textContent = `📅 Added range: ${rangeCount} dates. Total: ${manuallySelectedDates.size} dates selected.`; + lastClickedDate = null; // Reset for next selection + } else { + // First click of a potential range + lastClickedDate = cellDate; + console.log("First date clicked for range:", clickedDateStr); + if (hintElement) hintElement.textContent = `🎯 First date selected: ${clickedDateStr}. Click another date to create a range, or click this date again to select it individually.`; + return; // Don't update selection yet, wait for second click + } + + // Update the visual selection in datepicker + const selectedDateObjects = Array.from(manuallySelectedDates).map(dateStr => { + if (dateStr.includes('-')) { + return new Date(dateStr); + } else { + const year = parseInt(dateStr.substring(0, 4)); + const month = parseInt(dateStr.substring(4, 6)) - 1; + const day = parseInt(dateStr.substring(6, 8)); + return new Date(year, month, day); + } + }); + + picker.selectDate(selectedDateObjects); + + // Update hidden input + const hiddenInput = document.getElementById(hiddenInputId); + hiddenInput.value = Array.from(manuallySelectedDates).join(','); + console.log("Total selected dates:", manuallySelectedDates.size); + console.log("Selected dates:", hiddenInput.value.split(',').slice(0, 10).join(', '), '...'); + }); + } + }, 100); + + console.log("Datepicker initialized"); } else if (variable.enum && variable.enum.length > 0) { + // Add filter input at the top if there are many options + if (variable.enum.length > 5) { + const filterWrapper = toHTML(` +
+ +
+ `); + itemDiv.appendChild(filterWrapper); + } + + // Add checkbox list const checkbox_list = renderCheckboxList(link); itemDiv.appendChild(checkbox_list); + // Add filter functionality if filter exists + if (variable.enum.length > 5) { + const filterInput = itemDiv.querySelector(`#filter-${link.title}`); + if (filterInput) { + filterInput.addEventListener('input', (e) => { + const filterText = e.target.value.toLowerCase(); + const checkboxRows = checkbox_list.querySelectorAll('.checkbox-row'); + + checkboxRows.forEach(row => { + const label = row.querySelector('label'); + const code = row.querySelector('label.code code'); + const labelText = label ? label.textContent.toLowerCase() : ''; + const codeText = code ? code.textContent.toLowerCase() : ''; + + if (labelText.includes(filterText) || codeText.includes(filterText)) { + row.style.display = ''; + } else { + row.style.display = 'none'; + } + }); + }); + } + } + itemDiv.querySelector("button.all").addEventListener("click", () => { let new_state; if (checkbox_list.hasAttribute("disabled")) { checkbox_list.removeAttribute("disabled"); - itemDiv.querySelectorAll("input").forEach((c) => { + itemDiv.querySelectorAll("input[type='checkbox']").forEach((c) => { c.removeAttribute("checked"); c.removeAttribute("disabled"); }); } else { checkbox_list.setAttribute("disabled", ""); - itemDiv.querySelectorAll("input").forEach((c) => { + itemDiv.querySelectorAll("input[type='checkbox']").forEach((c) => { c.setAttribute("checked", "true"); c.setAttribute("disabled", ""); }); @@ -277,59 +469,82 @@ function renderCatalogItems(links) { function renderRequestBreakdown(request, descriptions) { const container = document.getElementById("request-breakdown"); const format_value = (key, value) => { - return `"${value}"`; + return `"${value}"`; }; const format_values = (key, values) => { if (values.length === 1) { return format_value(key, values[0]); } - return `[${values.map((v) => format_value(key, v)).join(", ")}]`; + return `[${values.map((v) => format_value(key, v)).join(`, `)}]`; }; let html = - `{\n` + + `{\n` + request .map( ([key, values]) => - ` "${key}": ${format_values(key, values)},` + ` "${key}": ${format_values(key, values)},` ) .join("\n") + - `\n}`; + `\n}`; container.innerHTML = html; } function renderMARSRequest(request, descriptions) { const container = document.getElementById("final_req"); const format_value = (key, value) => { - return `"${value}"`; + return `"${value}"`; }; const format_values = (key, values) => { if (values.length === 1) { return format_value(key, values[0]); } - return `[${values.map((v) => format_value(key, v)).join(", ")}]`; + return `[${values.map((v) => format_value(key, v)).join(`, `)}]`; }; + // Add feature object to each request if polygon is selected + const requestsWithFeature = selectedPolygon ? request.map(obj => ({ + ...obj, + feature: { + type: "polygon", + shape: selectedPolygon + } + })) : request; + + // Store for copying + currentMARSRequests = requestsWithFeature; + let html = - `[\n` + - request + `[\n` + + requestsWithFeature .map( - obj => - ` {\n` + - Object.entries(obj) + obj => { + const entries = Object.entries(obj); + return ` {\n` + + entries .map( - ([key, values]) => - ` "${key}": ${format_values(key, values)},` + ([key, values], idx) => { + const isLast = idx === entries.length - 1; + if (key === "feature" && values && typeof values === "object" && values.type === "polygon") { + // Format the feature object specially + const shapeStr = JSON.stringify(values.shape, null, 0); + return ` "feature": {\n` + + ` "type": "${values.type}",\n` + + ` "shape": ${shapeStr}\n` + + ` }${isLast ? '' : ','}`; + } + return ` "${key}": ${format_values(key, values)}${isLast ? '' : ','}`; + } ) .join("\n") + - `\n }` + `\n }`; + } ) - .join(",\n") + - `\n]`; + .join(`,\n`) + + `\n]`; container.innerHTML = html; } @@ -353,9 +568,28 @@ async function fetchCatalog(request, stacUrl) { const response = await fetch(stacUrl); const catalog = await response.json(); - // Render the request breakdown in the sidebar - renderRequestBreakdown(request, catalog.debug.descriptions); - renderMARSRequest(catalog.final_object, catalog.debug.descriptions); + // Check if we've reached the end of the catalogue (final_object has data) + const hasReachedEnd = catalog.final_object && catalog.final_object.length > 0; + + // Get section elements + const currentSelectionSection = document.getElementById("current-selection-section"); + const marsRequestsSection = document.getElementById("mars-requests-section"); + const nextButton = document.getElementById("next-btn"); + + if (hasReachedEnd) { + // At the end: show MARS requests, hide current selection and next button + currentSelectionSection.style.display = "none"; + marsRequestsSection.style.display = "block"; + nextButton.style.display = "none"; + catalogCache = catalog; // Store catalog for re-rendering with features + renderMARSRequest(catalog.final_object, catalog.debug.descriptions); + } else { + // Not at the end: show current selection, hide MARS requests, show next button + currentSelectionSection.style.display = "block"; + marsRequestsSection.style.display = "none"; + nextButton.style.display = "flex"; + renderRequestBreakdown(request, catalog.debug.descriptions); + } // Show the raw STAC in the sidebar renderRawSTACResponse(catalog); @@ -366,6 +600,20 @@ async function fetchCatalog(request, stacUrl) { renderCatalogItems(catalog.links); } + // Show region selection at the end of catalogue + const regionSelection = document.getElementById("region-selection"); + const catalogList = document.getElementById("catalog-list"); + const polytopeSection = document.getElementById("polytope-section"); + if (hasReachedEnd) { + regionSelection.style.display = "block"; + catalogList.classList.add("region-active"); + if (polytopeSection) polytopeSection.style.display = "block"; + } else { + regionSelection.style.display = "none"; + catalogList.classList.remove("region-active"); + if (polytopeSection) polytopeSection.style.display = "none"; + } + // Highlight the request and raw STAC hljs.highlightElement(document.getElementById("raw-stac")); hljs.highlightElement(document.getElementById("debug")); @@ -399,5 +647,331 @@ function initializeViewer() { stacAnchor.href = getSTACUrlFromQuery(); } +// Copy MARS requests to clipboard +function copyMARSRequests() { + const copyBtn = document.getElementById("copy-mars-btn"); + const btnText = copyBtn.querySelector(".copy-btn-text"); + + // Use the stored MARS requests with feature if available + const jsonContent = JSON.stringify(currentMARSRequests, null, 2); + + navigator.clipboard.writeText(jsonContent).then(() => { + // Change button text temporarily + btnText.textContent = "Copied!"; + copyBtn.classList.add("copied"); + + // Reset after 2 seconds + setTimeout(() => { + btnText.textContent = "Copy"; + copyBtn.classList.remove("copied"); + }, 2000); + }).catch(err => { + console.error("Failed to copy:", err); + btnText.textContent = "Failed"; + setTimeout(() => { + btnText.textContent = "Copy"; + }, 2000); + }); +} + +// Download JSON data as a file +function downloadJSON(data, filename) { + const jsonString = JSON.stringify(data, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +// ============================================ +// Geographic Region Selection with Map +// ============================================ + +let regionMap = null; +let drawnItems = null; +let selectedPolygon = null; +let currentMARSRequests = []; // Store current MARS requests for copying +let catalogCache = null; // Store catalog for re-rendering when polygon changes + +function initializeRegionMap() { + const mapElement = document.getElementById('map'); + if (!mapElement || regionMap) return; + + // Initialize map centered on the world + regionMap = L.map('map').setView([20, 0], 2); + + // Add OpenStreetMap tile layer + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 18, + }).addTo(regionMap); + + // Initialize the FeatureGroup to store editable layers + drawnItems = new L.FeatureGroup(); + regionMap.addLayer(drawnItems); + + // Initialize the draw control + const drawControl = new L.Control.Draw({ + position: 'topright', + draw: { + polyline: false, + circle: false, + circlemarker: false, + marker: false, + rectangle: true, + polygon: { + allowIntersection: false, + showArea: true, + shapeOptions: { + color: '#0066cc', + weight: 2, + fillOpacity: 0.2 + } + } + }, + edit: { + featureGroup: drawnItems, + remove: true + } + }); + regionMap.addControl(drawControl); + + // Handle polygon creation + regionMap.on('draw:created', function (e) { + // Clear previous polygons + drawnItems.clearLayers(); + + const layer = e.layer; + drawnItems.addLayer(layer); + + // Get the coordinates + const coordinates = layer.getLatLngs()[0].map(latlng => [ + parseFloat(latlng.lat.toFixed(6)), + parseFloat(latlng.lng.toFixed(6)) + ]); + + // Close the polygon by adding the first point at the end + coordinates.push(coordinates[0]); + + selectedPolygon = coordinates; + displaySelectedRegion(coordinates); + }); + + // Handle polygon edit + regionMap.on('draw:edited', function (e) { + const layers = e.layers; + layers.eachLayer(function (layer) { + const coordinates = layer.getLatLngs()[0].map(latlng => [ + parseFloat(latlng.lat.toFixed(6)), + parseFloat(latlng.lng.toFixed(6)) + ]); + coordinates.push(coordinates[0]); + selectedPolygon = coordinates; + displaySelectedRegion(coordinates); + }); + }); + + // Handle polygon deletion + regionMap.on('draw:deleted', function (e) { + selectedPolygon = null; + document.getElementById('selected-region').style.display = 'none'; + }); + + // Force map to resize properly + setTimeout(() => { + regionMap.invalidateSize(); + }, 100); +} + +function displaySelectedRegion(coordinates) { + const selectedRegionDiv = document.getElementById('selected-region'); + const coordinatesDisplay = document.getElementById('region-coordinates'); + + const regionFeature = { + type: "polygon", + shape: coordinates + }; + + coordinatesDisplay.textContent = JSON.stringify({ feature: regionFeature }, null, 2); + selectedRegionDiv.style.display = 'block'; + + // Re-render MARS requests with the feature appended + if (catalogCache && catalogCache.final_object) { + renderMARSRequest(catalogCache.final_object, catalogCache.debug.descriptions); + } +} + +// Event listeners for region selection +document.addEventListener("DOMContentLoaded", () => { + const enableRegionBtn = document.getElementById('enable-region-btn'); + const skipRegionBtn = document.getElementById('skip-region-btn'); + const clearRegionBtn = document.getElementById('clear-region-btn'); + const mapContainer = document.getElementById('map-container'); + + if (enableRegionBtn) { + enableRegionBtn.addEventListener('click', () => { + mapContainer.style.display = 'block'; + enableRegionBtn.style.display = 'none'; + skipRegionBtn.textContent = 'Continue Without Region'; + initializeRegionMap(); + }); + } + + if (skipRegionBtn) { + skipRegionBtn.addEventListener('click', () => { + // User chose to skip region selection - could proceed to next step + console.log('User skipped region selection'); + // Here you could trigger the next action or inform the user + }); + } + + if (clearRegionBtn) { + clearRegionBtn.addEventListener('click', () => { + if (drawnItems) { + drawnItems.clearLayers(); + } + selectedPolygon = null; + document.getElementById('selected-region').style.display = 'none'; + + // Re-render MARS requests without the feature + if (catalogCache && catalogCache.final_object) { + renderMARSRequest(catalogCache.final_object, catalogCache.debug.descriptions); + } + }); + } +}); + +// ============================================ +// Polytope Query Handler +// ============================================ + +async function queryPolytope() { + const polytopeBtn = document.getElementById('polytope-btn'); + const polytopeBtnText = document.getElementById('polytope-btn-text'); + const polytopeStatus = document.getElementById('polytope-status'); + const polytopeResults = document.getElementById('polytope-results'); + const emailInput = document.getElementById('polytope-email'); + const keyInput = document.getElementById('polytope-key'); + + if (!currentMARSRequests || currentMARSRequests.length === 0) { + polytopeStatus.textContent = 'No MARS requests available to query.'; + polytopeStatus.className = 'polytope-status error'; + polytopeStatus.style.display = 'block'; + return; + } + + // Validate credentials + const email = emailInput.value.trim(); + const apiKey = keyInput.value.trim(); + + if (!email || !apiKey) { + polytopeStatus.textContent = 'Please provide both email and API key to query Polytope.'; + polytopeStatus.className = 'polytope-status error'; + polytopeStatus.style.display = 'block'; + return; + } + + // Disable button and show loading state + polytopeBtn.disabled = true; + polytopeBtnText.textContent = 'Querying...'; + polytopeStatus.textContent = `Submitting ${currentMARSRequests.length} request(s) to Polytope service...`; + polytopeStatus.className = 'polytope-status loading'; + polytopeStatus.style.display = 'block'; + polytopeResults.innerHTML = ''; + polytopeResults.style.display = 'none'; + + try { + const response = await fetch('/api/v2/polytope/query', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + requests: currentMARSRequests, + credentials: { + user_email: email, + user_key: apiKey + } + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.detail || 'Failed to query Polytope service'); + } + + // Show success message + polytopeStatus.textContent = `Successfully submitted ${result.total} request(s). ${result.successful} succeeded, ${result.failed} failed.`; + polytopeStatus.className = 'polytope-status success'; + + // Display detailed results + if (result.results && result.results.length > 0) { + polytopeResults.innerHTML = result.results.map((res, idx) => ` +
+
+ Request ${idx + 1}: ${res.success ? '✓ Success' : '✗ Failed'} +
+
+ ${res.success + ? `Data retrieved successfully${res.data_size ? ` (${res.data_size})` : ''}` + : `Error: ${res.error || 'Unknown error'}` + } +
+ ${res.message ? `
${res.message}
` : ''} + ${res.success && res.json_data ? ` + + ` : ''} +
+ `).join(''); + polytopeResults.style.display = 'block'; + + // Add event listeners to download buttons + document.querySelectorAll('.download-json-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const idx = parseInt(e.target.getAttribute('data-request-idx')); + const resultData = result.results[idx]; + if (resultData && resultData.json_data) { + downloadJSON(resultData.json_data, `polytope_request_${idx + 1}.json`); + } + }); + }); + } + + polytopeBtnText.textContent = 'Query Complete'; + } catch (error) { + console.error('Polytope query error:', error); + polytopeStatus.textContent = `Error: ${error.message}`; + polytopeStatus.className = 'polytope-status error'; + } finally { + // Re-enable button after a delay + setTimeout(() => { + polytopeBtn.disabled = false; + polytopeBtnText.textContent = 'Query Polytope Service'; + }, 2000); + } +} + // Call initializeViewer on page load initializeViewer(); + +// Add event listener for copy button +document.addEventListener("DOMContentLoaded", () => { + const copyBtn = document.getElementById("copy-mars-btn"); + if (copyBtn) { + copyBtn.addEventListener("click", copyMARSRequests); + } + + // Add event listener for Polytope button + const polytopeBtn = document.getElementById('polytope-btn'); + if (polytopeBtn) { + polytopeBtn.addEventListener('click', queryPolytope); + } +}); diff --git a/stac_server/static/logo.png b/stac_server/static/logo.png new file mode 100644 index 00000000..5c449526 Binary files /dev/null and b/stac_server/static/logo.png differ diff --git a/stac_server/static/logo_old.svg b/stac_server/static/logo_old.svg new file mode 100644 index 00000000..b8e4bc8c --- /dev/null +++ b/stac_server/static/logo_old.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/stac_server/static/styles.css b/stac_server/static/styles.css index 81202e3b..31f2c144 100644 --- a/stac_server/static/styles.css +++ b/stac_server/static/styles.css @@ -1,303 +1,1398 @@ +/* ============================================ + Modern Professional Catalogue Styles + ============================================ */ + +/* CSS Variables for consistent theming */ +:root { + --primary-color: #0073E6; + --primary-dark: #2F3842; + --primary-light: #009DEB; + --secondary-color: #009DEB; + --accent-color: #FF6B9D; + + --bg-primary: #ffffff; + --bg-secondary: #F6F9FC; + --bg-tertiary: #F2F7FD; + --bg-dark: #2F3842; + + --text-primary: #100F0F; + --text-secondary: #B1B5C3; + --text-light: #adb5bd; + --text-inverse: #ffffff; + + --border-color: #dee2e6; + --border-light: #e9ecef; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.12); + --shadow-hover: 0 8px 16px rgba(0, 0, 0, 0.15); + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + + --transition-fast: 0.15s ease; + --transition-base: 0.25s ease; + --transition-slow: 0.35s ease; + + --header-height: 70px; + --sidebar-width: 420px; +} + +/* Reset & Base Styles */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + html, body { min-height: 100vh; height: 100%; - - --accent-color: #003399; - --background-grey: #f4f4f4; + margin: 0; + padding: 0; } body { - font-family: Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.6; + color: var(--text-primary); + background-color: var(--bg-secondary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============================================ + Header Styles + ============================================ */ + +.main-header { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%); + color: var(--text-inverse); + box-shadow: var(--shadow-md); + position: sticky; + top: 0; + z-index: 1000; + height: var(--header-height); +} + +.header-content { + max-width: 1800px; + margin: 0 auto; + padding: 0 2rem; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; +} + +.header-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.logo-icon { + font-size: 2rem; + line-height: 1; +} + +.site-title { + font-size: 1.5rem; + font-weight: 600; margin: 0; - padding-left: 0.5em; - padding-right: 0.5em; + letter-spacing: -0.02em; +} + +.header-right { + display: flex; + gap: 1rem; +} + +.header-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: var(--radius-sm); + color: var(--text-inverse); + text-decoration: none; + font-weight: 500; + transition: background var(--transition-base); +} +.header-link:hover { + background: rgba(255, 255, 255, 0.2); } +/* ============================================ + Layout + ============================================ */ #viewer { display: flex; flex-direction: row; - height: fit-content; - min-height: 100vh; + min-height: calc(100vh - var(--header-height)); + max-width: 1800px; + margin: 0 auto; } +/* ============================================ + Sidebar / Catalog List + ============================================ */ + #catalog-list { - max-width: 400px; - padding: 10px; - overflow-y: scroll; - background-color: var(--background-grey); - border-right: 1px solid #ddd; + flex: 0 0 var(--sidebar-width); + background-color: var(--bg-primary); + border-right: 1px solid var(--border-color); + overflow-y: auto; + box-shadow: var(--shadow-sm); + transition: flex-basis var(--transition-base); } -#catalog-list h2 { - margin-top: 0; +#catalog-list.region-active { + flex: 0 0 650px; } -#details { - width: 70%; - padding: 10px; +.sidebar-sticky { + padding: 2rem 1.5rem; +} + +.sidebar-intro { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid var(--border-light); } -.sidebar-header { +.instruction-text { + margin: 0 0 0.75rem 0; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.5; +} + +.update-time { display: flex; + align-items: center; + gap: 0.5rem; + margin: 1rem 0 0 0; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + font-size: 0.85rem; + color: var(--text-secondary); +} + +/* ============================================ + Navigation Controls + ============================================ */ + +.sidebar-nav { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.nav-btn { + flex: 1; + display: flex; + align-items: center; justify-content: center; - margin-bottom: 10px; - flex-wrap: wrap; - gap: 0.5em; + gap: 0.5rem; + padding: 0.75rem 1rem; + border: none; + border-radius: var(--radius-md); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); } -.sidebar-header button { - width: 7em; - height: 2em; - padding: 0; +.nav-btn.primary { + background: var(--primary-color); + color: var(--text-inverse); } -canvas { - width: 100%; - height: 300px; - border: 1px solid #ccc; - margin-top: 20px; +.nav-btn.primary:hover { + background: var(--primary-dark); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.nav-btn.secondary { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.nav-btn.secondary:hover { + background: var(--border-color); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.nav-btn:active { + transform: translateY(0); +} + +/* ============================================ + Catalog Items / Cards + ============================================ */ + +.catalog-items { + display: flex; + flex-direction: column; + gap: 1rem; +} +.catalog-items { + display: flex; + flex-direction: column; + gap: 1rem; } -/* Updated CSS for the item elements in the catalog list */ .item { + position: relative; display: flex; flex-direction: column; + background: var(--bg-primary); + border: 2px solid var(--border-light); + border-radius: var(--radius-lg); + padding: 1.5rem; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); +} - background-color: white; - border: 1px solid #ddd; - padding: 10px; - margin-bottom: 10px; - border-radius: 5px; - transition: background-color 0.2s ease; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +.item:hover { + border-color: var(--primary-light); + box-shadow: var(--shadow-hover); + transform: translateY(-2px); +} + +.item.selected { + background: linear-gradient(135deg, #F2F7FD 0%, #F6F9FC 100%); + border-color: var(--primary-color); + box-shadow: var(--shadow-md); } .item-title { - font-size: 18px; - margin: 0; - color: #333; + font-size: 1.15rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: var(--text-primary); + letter-spacing: -0.01em; } .item-type { - font-size: 14px; - margin: 5px 0; - color: #666; + display: inline-block; + padding: 0.25rem 0.75rem; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 500; + border-radius: var(--radius-sm); + margin-bottom: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; } -.item-id, -.item-key-type { - font-size: 12px; - color: #999; +.item-description { + font-size: 0.9rem; + margin: 0.5rem 0 1rem 0; + color: var(--text-secondary); + line-height: 1.5; } -.item-description { - font-size: 13px; - margin: 5px 0; - color: #444; - font-style: italic; +/* Select All Button */ +.item button.all { + position: absolute; + right: 1.25rem; + top: 1.25rem; + width: 2rem; + height: 2rem; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + color: var(--text-primary); + border: 2px solid var(--border-color); + border-radius: 50%; + font-size: 1.2rem; + font-weight: bold; + cursor: pointer; + transition: all var(--transition-fast); } -.item.selected { - background-color: var(--background-grey); - border-color: var(--accent-color); +.item button.all:hover { + background: var(--primary-color); + color: var(--text-inverse); + border-color: var(--primary-color); + transform: scale(1.1); } +/* ============================================ + Checkbox Container + ============================================ */ -summary { - h2 { - display: inline; - } +.checkbox-container { + display: grid; + grid-template-columns: auto auto 1fr; + grid-auto-rows: auto; + grid-column-gap: 0.75rem; + grid-row-gap: 0.75rem; + max-height: 280px; + overflow-y: auto; + padding: 1rem; + margin-top: 1rem; + background: var(--bg-secondary); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + scrollbar-width: thin; + scrollbar-color: var(--border-color) transparent; +} + +.checkbox-container::-webkit-scrollbar { + width: 6px; } -details[open] summary:has(> h2) { - margin-bottom: 0.5em; + +.checkbox-container::-webkit-scrollbar-track { + background: transparent; } -.json-pre { - white-space: pre-wrap; - /* background-color: #f9f9f9; */ - border: 1px solid #ccc; - border-radius: 5px; - padding: 10px; +.checkbox-container::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; } +.checkbox-container::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} -/* Button styles */ -button { - height: 3em; - padding: 10px 20px; - /* Padding around button text */ - margin: 0 5px; - /* Margin between buttons */ - background-color: var(--accent-color); - /* ECMWF blue */ - color: white; - /* White text color */ - border: none; - /* Remove default button border */ +.checkbox-container[disabled] { + background-color: #e9ecef; + opacity: 0.7; +} + +.checkbox-row { + display: contents; +} + +.checkbox-row:hover > * { + color: var(--primary-color); +} + +.checkbox-row:hover > input[type='checkbox'] { + box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.15); +} + +.checkbox-row input[type='checkbox'] { + grid-column-start: 1; + width: 1.25rem; + height: 1.25rem; + align-self: center; cursor: pointer; - /* Pointer cursor on hover */ - border-radius: 5px; - /* Rounded corners */ - transition: background-color 0.3s ease; - /* Smooth background color transition */ + accent-color: var(--primary-color); + transition: all var(--transition-fast); } -button:hover { - background-color: #001f66; +.checkbox-row a.more-info { + cursor: help; + text-decoration: none; + font-size: 0.7rem; + font-weight: 600; + display: inline-flex; + width: 1.2rem; + height: 1.2rem; + padding: 0; + align-items: center; + justify-content: center; + aspect-ratio: 1 / 1; + border-radius: 50%; + border: 2px solid var(--text-secondary); + color: var(--text-secondary); + transition: all var(--transition-fast); } +.checkbox-row a.more-info:hover { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--text-inverse); + transform: scale(1.15); +} -.item { - position: relative; +.checkbox-row label { + grid-column-start: 2; + align-self: center; + font-size: 0.9rem; + color: var(--text-primary); + cursor: pointer; + transition: color var(--transition-fast); +} - button.all { - position: absolute; - right: 10px; - top: 10px; - text-align: right; - padding: 0; - width: 1em; - height: 1em; - color: black; - background-color: transparent; - border-radius: 50%; - font-size: 2em; - } +.checkbox-row label.code { + grid-column-start: 3; + text-align: right; + align-self: center; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 0.8rem; + color: var(--text-secondary); +} - .checkbox-container[disabled] { - background-color: #d3d3d3; - } +/* ============================================ + Filter Input for Checkbox Lists + ============================================ */ - .checkbox-container { - display: grid; - grid-template-columns: auto auto 1fr; - grid-auto-rows: auto; - grid-column-gap: 10px; - grid-row-gap: 10px; - - margin-top: 20px; - max-height: 200px; - overflow-y: auto; - padding: 10px; - border: 1px solid #ccc; - border-radius: 4px; - background-color: #fff; - - div.checkbox-row:hover > * { - color: var(--accent-color); - &[type='checkbox'] {box-shadow: 0px 0px 5px var(--accent-color);} - } - - div.checkbox-row { - display: contents; - - /* Set the checkbox checked colour */ - input[type=checkbox] { - accent-color: var(--accent-color); - } - - input[type='checkbox'] { - grid-column-start: 1; - height: 1.5em; - width: 1.5em; - align-self: center; - cursor: pointer; - } - - a.more-info { - cursor: help; - text-decoration: none; - font-size: 0.7em; - display: inline-flex; - width: fit-content; - min-width: 1em; - padding: 0.1em; - align-items: center; - justify-content: center; - aspect-ratio: 1 / 1; - border-radius: 50%; - border: 2px solid #666; - } - - label { - grid-column-start: 2; - align-self: center; - font-size: 16px; - color: #333; - } - - label.code { - grid-column-start: 3; - text-align: right; - align-self: center; - } - - } - } +.filter-wrapper { + margin-top: 1rem; + margin-bottom: 0.75rem; +} - .checkbox-container:hover .key-value { - color: var(--accent-color); - } +.filter-input { + width: 100%; + padding: 0.625rem 0.875rem 0.625rem 2.25rem; + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 0.9rem; + background: var(--bg-primary); + color: var(--text-primary); + transition: all var(--transition-base); + background-image: url('data:image/svg+xml,'); + background-repeat: no-repeat; + background-position: 0.75rem center; + background-size: 16px 16px; } -.list-label { - font-weight: bold; - margin-bottom: 0.5em; - display: block; - color: var(--accent-color); +.filter-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.1); } -span.key, -span.value { - color: #ba2121; - ; +.filter-input::placeholder { + color: var(--text-light); +} + +/* Sidebar Footer */ +.sidebar-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 2px solid var(--border-light); +} + +.text-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border-radius: var(--radius-md); + color: var(--primary-color); + text-decoration: none; + font-weight: 500; + font-size: 0.9rem; + transition: all var(--transition-base); +} + +.text-link:hover { + background: var(--primary-color); + color: var(--text-inverse); + box-shadow: var(--shadow-md); +} + +/* ============================================ + Main Content Area + ============================================ */ + +#details { + flex: 1; + padding: 2rem; + overflow-y: auto; + background: var(--bg-secondary); +} + +.detail-section { + background: var(--bg-primary); + border-radius: var(--radius-lg); + padding: 2rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-light); +} + +.detail-section.collapsible { + padding: 0; +} + +.detail-section.collapsible summary { + padding: 1.5rem 2rem; + cursor: pointer; + transition: background var(--transition-base); + border-radius: var(--radius-lg); +} + +.detail-section.collapsible summary:hover { + background: var(--bg-secondary); +} + +.detail-section.collapsible[open] summary { + border-bottom: 1px solid var(--border-light); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +.detail-section.collapsible > *:not(summary) { + padding: 0 2rem 2rem 2rem; +} + +.section-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 1rem 0; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.section-title svg { + color: var(--primary-color); +} + +.section-description { + margin: 0 0 1.25rem 0; + color: var(--text-secondary); + font-size: 0.95rem; + line-height: 1.6; +} + +.section-description a { + color: var(--primary-color); + text-decoration: none; + font-weight: 500; + transition: color var(--transition-fast); +} + +.section-description a:hover { + color: var(--primary-dark); + text-decoration: underline; +} + +/* ============================================ + Code Blocks + ============================================ */ + +.code-block { + background: var(--bg-dark); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.code-block pre { + margin: 0; + padding: 1.5rem; + overflow-x: auto; + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.85rem; + line-height: 1.6; + scrollbar-width: thin; + scrollbar-color: var(--text-secondary) transparent; } +.code-block pre::-webkit-scrollbar { + height: 8px; +} + +.code-block pre::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); +} + +.code-block pre::-webkit-scrollbar-thumb { + background: var(--text-secondary); + border-radius: 4px; +} + +.code-block code { + font-family: inherit; + font-size: inherit; +} + +#final_req, +#qube { + margin: 0; + padding: 1.5rem; + background: var(--bg-dark); + color: #e9ecef; + border-radius: var(--radius-md); + overflow-x: auto; + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.85rem; + line-height: 1.6; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); +} + +/* Syntax Highlighting Enhancements */ span.key { - font-weight: bold; + color: #4fc3f7; + font-weight: 600; +} + +span.value { + color: #81c784; } span.key:hover, span.value:hover { - color: #ff2a2a; + color: #ffeb3b; + cursor: help; +} + +span.punct { + color: #e0e0e0; + font-weight: 500; +} + +/* ============================================ + Form Elements + ============================================ */ + +input[type="text"] { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 0.9rem; + font-family: inherit; + transition: all var(--transition-base); + background: var(--bg-primary); +} + +input[type="text"]:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.1); +} + +/* ============================================ + Utility Classes + ============================================ */ + +.has-data { + background-color: rgba(129, 199, 132, 0.2); + border: 2px solid #81c784 !important; +} + +.list-label { + font-weight: 600; + margin-bottom: 0.75rem; + display: block; + color: var(--primary-color); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Date Picker Styles */ +.date-picker-input { + width: 100%; + margin-top: 1rem; + border: none; + background: transparent; +} + +.date-picker-hint { + margin: 0.75rem 0 1rem 0; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, #F2F7FD 0%, #F6F9FC 100%); + border-left: 4px solid var(--primary-color); + border-radius: var(--radius-sm); + font-size: 0.85rem; + line-height: 1.5; + color: var(--text-secondary); + font-weight: 500; + transition: all var(--transition-base); +} + +.date-picker-hint:empty { + display: none; +} + +div.air-datepicker { + width: 100% !important; + background: var(--bg-primary); + border: 2px solid var(--border-light); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + font-family: inherit; +} + +div.air-datepicker .air-datepicker-nav { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-light); + padding: 0.75rem; +} + +div.air-datepicker .air-datepicker-nav--title { + color: var(--text-primary); + font-weight: 600; +} + +div.air-datepicker .air-datepicker-nav--action { + color: var(--primary-color); +} + +div.air-datepicker .air-datepicker-nav--action:hover { + background: var(--primary-light); +} + +div.air-datepicker .air-datepicker-body--day-name { + color: var(--text-secondary); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; +} + +div.air-datepicker .air-datepicker-cell { + color: var(--text-primary); + border-radius: var(--radius-sm); +} + +div.air-datepicker .air-datepicker-cell.-disabled- { + color: var(--text-light); + opacity: 0.25; + cursor: not-allowed; + background: transparent; + text-decoration: line-through; +} + +div.air-datepicker .air-datepicker-cell:not(.-disabled-):hover { + background: var(--primary-light); + color: var(--text-inverse); cursor: pointer; + transform: scale(1.05); + transition: all var(--transition-fast); +} + +div.air-datepicker .air-datepicker-cell.-selected- { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%) !important; + color: var(--text-inverse) !important; + font-weight: 700; + box-shadow: 0 2px 8px rgba(0, 115, 230, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2); + border: none !important; +} + +div.air-datepicker .air-datepicker-cell.-in-range- { + background: linear-gradient(135deg, rgba(0, 115, 230, 0.2) 0%, rgba(0, 115, 230, 0.15) 100%) !important; + color: var(--primary-dark); + font-weight: 600; + border-top: 1px solid rgba(0, 115, 230, 0.3); + border-bottom: 1px solid rgba(0, 115, 230, 0.3); +} + +div.air-datepicker .air-datepicker-cell.-range-from-, +div.air-datepicker .air-datepicker-cell.-range-to- { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%) !important; + color: var(--text-inverse) !important; + font-weight: 700; + box-shadow: 0 2px 8px rgba(0, 115, 230, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2); + border: none !important; +} + +/* Subtle indicator for dates with available data */ +div.air-datepicker .air-datepicker-cell.has-data { + font-weight: 600; + position: relative; + background: rgba(0, 115, 230, 0.06); + border-bottom: 2px solid rgba(0, 115, 230, 0.4); +} + +div.air-datepicker .air-datepicker-cell.has-data:not(.-disabled-) { + color: var(--primary-dark); +} + +/* When dates are selected, make them darker and more prominent */ +div.air-datepicker .air-datepicker-cell.-selected-.has-data, +div.air-datepicker .air-datepicker-cell.-range-from-.has-data, +div.air-datepicker .air-datepicker-cell.-range-to-.has-data { + background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-color) 100%) !important; + color: var(--text-inverse) !important; + border-bottom: 2px solid var(--primary-dark); + font-weight: 700; + box-shadow: 0 3px 10px rgba(47, 56, 66, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.15); +} + +div.air-datepicker .air-datepicker-cell.-in-range-.has-data { + background: linear-gradient(135deg, rgba(0, 115, 230, 0.25) 0%, rgba(0, 115, 230, 0.2) 100%) !important; + color: var(--primary-dark); + border-bottom: 2px solid rgba(0, 115, 230, 0.5); + font-weight: 600; + border-top: 1px solid rgba(0, 115, 230, 0.4); +} + +/* ============================================ + Responsive Design + ============================================ */ + +@media (max-width: 1200px) { + :root { + --sidebar-width: 360px; + } + + .header-content { + padding: 0 1.5rem; + } + + .site-title { + font-size: 1.25rem; + } } -/* Change layout for narrow viewport */ -@media (max-width: 800px) { +@media (max-width: 968px) { #viewer { flex-direction: column; } #catalog-list { + flex: 0 0 auto; width: 100%; border-right: none; + border-bottom: 1px solid var(--border-color); + max-height: 60vh; } #details { width: 100%; } + + .header-content { + padding: 0 1rem; + } + + .site-title { + font-size: 1.1rem; + } + + .sidebar-nav { + flex-direction: column; + } } -details h2 { - font-size: medium; +@media (max-width: 640px) { + :root { + --header-height: 60px; + } + + .header-content { + padding: 0 1rem; + } + + .site-title { + font-size: 1rem; + } + + .logo-icon { + font-size: 1.5rem; + } + + .sidebar-sticky { + padding: 1.5rem 1rem; + } + + #details { + padding: 1.5rem 1rem; + } + + .detail-section { + padding: 1.5rem; + } + + .detail-section.collapsible summary { + padding: 1rem 1.5rem; + } + + .detail-section.collapsible > *:not(summary) { + padding: 0 1.5rem 1.5rem 1.5rem; + } + + .item { + padding: 1.25rem; + } + + .item button.all { + right: 1rem; + top: 1rem; + width: 1.75rem; + height: 1.75rem; + font-size: 1rem; + } } -.has-data { - background-color: rgba(160, 255, 160, 0.346); +/* ============================================ + Print Styles + ============================================ */ + +@media print { + .main-header, + .sidebar-nav, + .sidebar-footer, + button { + display: none; + } + + #viewer { + flex-direction: column; + } + + #catalog-list, + #details { + width: 100%; + border: none; + box-shadow: none; + } } +/* ============================================ + Geographic Region Selection + ============================================ */ -#date-picker { - align-self: center; - width: calc(50%); - margin-top: 0.5em; - margin-bottom: 0.5em; +.region-selection { + margin-top: 2rem; + padding: 1.5rem; + background: var(--bg-secondary); + border: 2px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); } -div.air-datepicker { - align-self: center;; +.region-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.region-description { + margin: 0 0 1rem 0; + color: var(--text-secondary); + font-size: 0.85rem; + line-height: 1.5; +} + +.region-controls { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.region-btn { + flex: 1; + min-width: 120px; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border: none; + border-radius: var(--radius-md); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); +} + +.region-btn.primary { + background: var(--primary-color); + color: var(--text-inverse); +} + +.region-btn.primary:hover { + background: var(--primary-dark); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.region-btn.secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 2px solid var(--border-color); +} + +.region-btn.secondary:hover { + background: var(--bg-tertiary); + border-color: var(--text-secondary); +} + +#map-container { + margin-top: 1.5rem; +} + +.map-instructions { + margin-bottom: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-primary); + border-left: 4px solid var(--primary-color); + border-radius: var(--radius-sm); + font-size: 0.8rem; + color: var(--text-secondary); +} + +.map-instructions p { + margin: 0; + line-height: 1.5; +} + +.map-instructions strong { + color: var(--text-primary); +} + +#map { + border: 2px solid var(--border-light); + box-shadow: var(--shadow-md); +} + +.selected-region { + margin-top: 1.5rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: var(--radius-md); +} + +.selected-region h4 { + margin: 0 0 1rem 0; + color: var(--text-primary); + font-size: 0.95rem; + font-weight: 600; +} + +.selected-region .code-block { + margin-bottom: 1rem; +} + +.selected-region pre { + margin: 0; + padding: 1rem; + background: var(--bg-dark); + color: #e9ecef; + border-radius: var(--radius-sm); + font-size: 0.85rem; + line-height: 1.6; + overflow-x: auto; +} + +/* ============================================ + Animations + ============================================ */ + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.item { + animation: fadeIn var(--transition-base) ease-out; +} + +/* Loading State */ +.item.loading { + background: var(--bg-secondary); + color: var(--text-secondary); + pointer-events: none; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Focus Visible for Accessibility */ +:focus-visible { + outline: 3px solid var(--primary-color); + outline-offset: 2px; +} + +button:focus-visible, +a:focus-visible { + outline: 3px solid var(--accent-color); +} + +/* Canvas Elements */ +canvas { + width: 100%; + height: 300px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + margin-top: 1.5rem; +} + +/* ============================================ + Copy Button for Code Blocks + ============================================ */ + +.code-block-with-copy { + position: relative; +} + +.copy-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.9); + color: var(--text-primary); + border: 2px solid var(--border-color); + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-base); + z-index: 10; + box-shadow: var(--shadow-sm); +} + +.copy-btn:hover { + background: var(--primary-color); + color: var(--text-inverse); + border-color: var(--primary-color); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.copy-btn.copied { + background: #28a745; + color: white; + border-color: #28a745; +} + +.copy-btn svg { + flex-shrink: 0; +} + +@media (max-width: 640px) { + .copy-btn { + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + } + + .copy-btn-text { + display: none; + } +} + +/* ============================================ + Polytope Query Section + ============================================ */ + +.polytope-section { + margin-top: 2rem; + padding: 1.5rem; + background: linear-gradient(135deg, #F6F9FC 0%, #F2F7FD 100%); + border: 2px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.polytope-title { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 0.75rem 0; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.polytope-description { + margin: 0 0 1.25rem 0; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.5; +} + +.polytope-auth-form { + margin-bottom: 1.5rem; + padding: 1.25rem; + background: var(--bg-primary); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-label { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 600; +} + +.form-input { + width: 100%; + padding: 0.75rem; + border: 2px solid var(--border-light); + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-family: inherit; + color: var(--text-primary); + background: var(--bg-primary); + transition: all var(--transition-base); +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.1); +} + +.form-input::placeholder { + color: var(--text-light); +} + +.form-hint { + margin: 0.5rem 0 0 0; + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.4; +} + +.form-hint a { + color: var(--primary-color); + text-decoration: none; +} + +.form-hint a:hover { + text-decoration: underline; +} + +.polytope-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%); + color: var(--text-inverse); + border: none; + border-radius: var(--radius-md); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-base); + box-shadow: var(--shadow-md); +} + +.polytope-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.polytope-btn:active { + transform: translateY(0); +} + +.polytope-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.polytope-status { + margin-top: 1.25rem; + padding: 1rem; + background: var(--bg-primary); + border-left: 4px solid var(--primary-color); + border-radius: var(--radius-sm); + font-size: 0.9rem; + color: var(--text-primary); +} + +.polytope-status.loading { + border-left-color: var(--secondary-color); +} + +.polytope-status.success { + border-left-color: #28a745; +} + +.polytope-status.error { + border-left-color: var(--accent-color); + color: #dc3545; +} + +.polytope-results { + margin-top: 1.25rem; +} + +.polytope-result-item { + padding: 1rem; + margin-bottom: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); +} + +.polytope-result-item.success { + border-left: 4px solid #28a745; +} + +.polytope-result-item.error { + border-left: 4px solid #dc3545; +} + +.polytope-result-header { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary); + font-size: 0.9rem; +} + +.polytope-result-detail { + font-size: 0.85rem; + color: var(--text-secondary); + font-family: 'Monaco', 'Courier New', monospace; +} +.download-json-btn { + transition: all 0.2s ease; +} + +.download-json-btn:hover { + background: var(--secondary-color) !important; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 115, 230, 0.3); +} + +.download-json-btn:active { + transform: translateY(0); } diff --git a/stac_server/templates/index.html b/stac_server/templates/index.html index c14e927c..8aeb9a86 100644 --- a/stac_server/templates/index.html +++ b/stac_server/templates/index.html @@ -7,7 +7,7 @@ {{title}} - + @@ -16,6 +16,15 @@ + + + + + + + + + + + +