diff --git a/README.md b/README.md new file mode 100644 index 0000000..3098b7c --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Earth Data Search (EDS) + +A modern, user-friendly web application for searching and visualizing Earth observation data using STAC APIs. This application provides an intuitive interface for discovering satellite imagery and related Earth observation datasets. + +## Features + +- πŸ—ΊοΈ Interactive Map Interface + - Visual area selection using drawing tools + - Real-time bounding box coordinate display + - Dynamic map updates based on search results + +- πŸ” Advanced Search Capabilities + - Full-text search across titles and descriptions + - Date range filtering with calendar integration + - Cloud cover percentage filtering with slider control + - Bounding box spatial filtering + - Collection-specific searches + +- πŸ“Š Results Visualization + - Thumbnail previews of datasets + - Cloud cover indicators with intuitive icons + - Dataset metadata display + - Quick-view information modal + +- πŸ’« Modern User Experience + - Responsive design + - Material Design styling + - Real-time search updates + - URL state management for shareable searches + +## Getting Started + +### Prerequisites + +- A modern web browser (Chrome, Firefox, Safari, or Edge) +- Python 3.x (for running the local development server) + +### Installation + +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/eds.git + cd eds + ``` + +2. No additional dependencies are required as the application runs entirely in the browser. + +### Running the Application + +1. Start the local development server: + ```bash + python -m http.server 8000 + ``` + +2. Open your browser and navigate to: + ``` + http://localhost:8000 + ``` + +## Usage + +### Basic Search + +1. Enter search terms in the search box to find datasets by title or description +2. Use the date picker to filter results by time range +3. Adjust the cloud cover slider to filter images based on cloud percentage + +### Spatial Search + +1. Use the drawing tools on the map to define a search area +2. The bounding box coordinates will automatically update +3. You can also manually enter coordinates in the format: `west,south,east,north` + +### Managing Results + +- Click the information icon (ℹ️) on any dataset to view detailed metadata +- Cloud cover percentage is displayed with intuitive icons: + - β˜€οΈ 0-10% clouds + - 🌀️ 11-30% clouds + - β›… 31-60% clouds + - πŸŒ₯️ 61-90% clouds + - ☁️ 91-100% clouds + +### URL Parameters + +The application supports the following URL parameters for sharing searches: + +- `cloudCover`: Maximum cloud cover percentage +- `collections`: Comma-separated list of collection IDs +- `bbox`: Bounding box coordinates (west,south,east,north) +- `datetime`: Date range in ISO format + +## Project Structure + +``` +eds/ +β”œβ”€β”€ index.html # Main application entry point +β”œβ”€β”€ control-panel.html # Search controls and filters +β”œβ”€β”€ css/ # Stylesheets +β”‚ └── styles.css # Main stylesheet +β”œβ”€β”€ js/ # JavaScript modules +β”‚ β”œβ”€β”€ components/ # UI components +β”‚ └── api/ # API integration +└── README.md # This file +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/control-panel.html b/control-panel.html new file mode 100644 index 0000000..e69de29 diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 0000000..bb7334e --- /dev/null +++ b/css/styles.css @@ -0,0 +1,1315 @@ +/* + * STAC Catalog Explorer - Material Design with Tabbed Interface + */ +/* + * Additional CSS to fix tab functionality + * Add these styles to your styles.css file + */ + +.tab-pane { + display: none; + padding: 8px 0; +} + +.tab-pane.active { + display: block; +} + +.search-tabs { + display: flex; + border-bottom: 1px solid var(--md-border-color); + margin-bottom: 12px; + overflow-x: auto; /* Allow horizontal scroll on small screens */ + position: relative; + z-index: 1; +} + +.tab { + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: border-color 0.3s ease, color 0.3s ease; + color: var(--md-text-secondary); + white-space: nowrap; /* Prevent text wrapping */ +} + +.tab:hover { + color: var(--md-primary); +} + +.tab.active { + color: var(--md-primary); + border-bottom-color: var(--md-primary); +} + +.tab-content { + position: relative; + min-height: 150px; +} + +/* Fix for expanding tab content */ +.md-card-body { + padding: 12px; + overflow: hidden; + transition: max-height var(--transition-speed) ease, padding var(--transition-speed) ease; + max-height: none; /* Changed from 600px to none for automatic height */ + overflow-y: auto; + display: flex; + flex-direction: column; +} + +:root { + /* Material Design Colors */ + --md-primary: #2196F3; /* Blue 500 */ + --md-primary-light: #BBDEFB; /* Blue 100 */ + --md-primary-dark: #1976D2; /* Blue 700 */ + --md-secondary: #FF9800; /* Orange 500 */ + --md-success: #4CAF50; /* Green 500 */ + --md-danger: #F44336; /* Red 500 */ + --md-warning: #FFC107; /* Amber 500 */ + --md-info: #03A9F4; /* Light Blue 500 */ + + /* UI Elements */ + --sidebar-width: 360px; + --sidebar-collapsed-width: 60px; + --elevation-1: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12); + --elevation-2: 0 3px 1px -2px rgba(0,0,0,0.2), 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12); + --elevation-4: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12); + --transition-speed: 0.3s; +} + +/* Dark Theme (Default) */ +html.dark-theme { + --md-bg-default: #121212; + --md-surface: #1e1e1e; + --md-surface-overlay: #2d2d2d; + --md-text-primary: rgba(255, 255, 255, 0.87); + --md-text-secondary: rgba(255, 255, 255, 0.6); + --md-border-color: rgba(255, 255, 255, 0.12); + --md-hover-overlay: rgba(255, 255, 255, 0.05); + --md-selected-overlay: rgba(255, 255, 255, 0.08); +} + +/* Light Theme */ +html.light-theme { + --md-bg-default: #fafafa; + --md-surface: #ffffff; + --md-surface-overlay: #f5f5f5; + --md-text-primary: rgba(0, 0, 0, 0.87); + --md-text-secondary: rgba(0, 0, 0, 0.6); + --md-border-color: rgba(0, 0, 0, 0.12); + --md-hover-overlay: rgba(0, 0, 0, 0.04); + --md-selected-overlay: rgba(0, 0, 0, 0.08); +} + +/* Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body, html { + font-family: 'Roboto', sans-serif; + height: 100%; + width: 100%; + color: var(--md-text-primary); + background-color: var(--md-bg-default); + line-height: 1.5; +} + +/* Main Layout */ +.app-container { + display: flex; + height: 100vh; + width: 100%; + overflow: hidden; +} + +/* Sidebar Styles */ +.sidebar { + width: var(--sidebar-width); + background-color: var(--md-surface); + box-shadow: var(--elevation-2); + z-index: 1500; + display: flex; + flex-direction: column; + transition: width var(--transition-speed) ease; + overflow: hidden; + position: relative; + resize: horizontal; /* Make the sidebar resizable */ + min-width: 250px; /* Minimum width */ + max-width: 600px; /* Maximum width */ +} + +/* Ensure the sidebar can be resized properly */ +.sidebar:not(.collapsed) { + overflow-x: auto; /* Allow horizontal overflow for resize handle */ +} + +/* Add a visual indicator for the resize handle */ +.sidebar:not(.collapsed)::after { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 8px; + height: 100%; + cursor: ew-resize; + background: linear-gradient(to right, transparent, rgba(33, 150, 243, 0.2)); + opacity: 0; + transition: opacity 0.2s ease; +} + +.sidebar:not(.collapsed):hover::after { + opacity: 1; +} + +.sidebar.collapsed { + width: var(--sidebar-collapsed-width); + min-width: var(--sidebar-collapsed-width); + resize: none; /* Disable resizing when collapsed */ +} + +.sidebar-header { + padding: 12px 16px; + background-color: var(--md-primary); + color: white; + position: relative; + height: 56px; + display: flex; + align-items: center; +} + +.sidebar-header h1 { + font-size: 1.25rem; + font-weight: 500; + margin: 0; + display: flex; + align-items: center; + transition: opacity var(--transition-speed) ease; +} + +.sidebar-header h1 i { + margin-right: 12px; + font-size: 24px; +} + +.sidebar.collapsed .sidebar-header h1 span { + opacity: 0; + visibility: hidden; +} + +.sidebar.collapsed .sidebar-header h1 i { + margin-right: 0; +} + +.sidebar-toggle { + position: fixed; + top: 26px; + left: calc(var(--sidebar-width) - 16px); + transform: translateY(-50%); + width: 32px; + height: 32px; + background-color: var(--md-primary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + cursor: pointer; + box-shadow: var(--elevation-2); + z-index: 3000; + transition: left var(--transition-speed) ease; +} + +.sidebar.collapsed .sidebar-toggle { + left: calc(var(--sidebar-collapsed-width) - 16px); +} + +.sidebar-content { + flex-grow: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + transition: opacity var(--transition-speed) ease, visibility var(--transition-speed) ease; +} + +.sidebar.collapsed .sidebar-content { + opacity: 0; + visibility: hidden; +} + +/* Theme Toggle */ +.theme-toggle { + position: absolute; + top: 12px; + right: 48px; + background: transparent; + border: none; + color: white; + font-size: 20px; + cursor: pointer; + z-index: 1002; + transition: transform 0.3s ease; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.theme-toggle:hover { + transform: rotate(30deg); +} + +/* Material Card Styles */ +.md-card { + background-color: var(--md-surface); + border-radius: 4px; + box-shadow: var(--elevation-1); + overflow: hidden; + transition: box-shadow var(--transition-speed) ease, height var(--transition-speed) ease, margin-bottom var(--transition-speed) ease; + margin-bottom: 8px; +} + +.md-card:hover { + box-shadow: var(--elevation-2); +} + +.md-card-header { + padding: 8px 12px; + background-color: var(--md-surface); + color: var(--md-text-primary); + font-weight: 500; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--md-border-color); + cursor: pointer; + user-select: none; + min-height: 48px; +} + +.md-card-header h2 { + font-size: 0.95rem; + margin: 0; + display: flex; + align-items: center; +} + +.md-card-header h2 i { + margin-right: 8px; + color: var(--md-primary); + font-size: 20px; +} + +.md-card-header .toggle-icon { + transition: transform var(--transition-speed) ease; + font-size: 20px; +} + +.md-card.collapsed .md-card-header .toggle-icon { + transform: rotate(-90deg); +} + +.md-card-body { + padding: 6px; + overflow: hidden; + transition: max-height var(--transition-speed) ease, padding var(--transition-speed) ease; + max-height: none; /* Changed from 600px to none for automatic height */ + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.md-card.collapsed .md-card-body { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + overflow: hidden; +} + +.results-card { + flex-grow: 1; + display: flex; + flex-direction: column; + min-height: 150px; + overflow: hidden; +} + +.results-card .md-card-body { + padding: 0; + flex-grow: 1; + overflow-y: auto; + max-height: calc(100vh - 100px); /* Adjust based on other elements */ +} + +.results-card.collapsed .md-card-body { + max-height: 0; +} + +.header-content { + display: flex; + align-items: center; + gap: 8px; +} + +/* Tab Navigation */ +.search-tabs { + display: flex; + border-bottom: 1px solid var(--md-border-color); + margin-bottom: 6px; +} + +.tab { + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: border-color 0.3s ease, color 0.3s ease; + color: var(--md-text-secondary); +} + +.tab:hover { + color: var(--md-primary); +} + +.tab.active { + color: var(--md-primary); + border-bottom-color: var(--md-primary); +} + +.tab-content { + position: relative; + min-height: 150px; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} + +/* Tools Panel */ +.tools-panel { + position: absolute; + top: 16px; + right: 16px; + width: 320px; + background-color: var(--md-surface); + border-radius: 4px; + box-shadow: var(--elevation-4); + z-index: 1000; + transition: all var(--transition-speed) ease; + overflow: hidden; +} + +.tools-panel.collapsed { + width: 48px; + overflow: hidden; +} + +.tools-header { + padding: 8px 12px; + background-color: var(--md-primary); + color: white; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + min-height: 48px; +} + +.tools-header h2 { + font-size: 1rem; + margin: 0; + display: flex; + align-items: center; + font-weight: 500; +} + +.tools-header h2 i { + margin-right: 8px; + font-size: 20px; +} + +.tools-panel.collapsed .tools-header h2 span { + display: none; +} + +.tools-panel.collapsed .tools-header h2 i { + margin-right: 0; +} + +.tools-toggle { + color: white; + font-size: 14px; + transition: transform var(--transition-speed) ease; +} + +.tools-panel.collapsed .tools-toggle { + transform: rotate(180deg); +} + +.tools-content { + padding: 12px; + transition: opacity var(--transition-speed) ease; +} + +.tools-panel.collapsed .tools-content { + opacity: 0; + visibility: hidden; + height: 0; + padding: 0; +} + +.tools-section { + margin-bottom: 16px; +} + +.tools-section:last-child { + margin-bottom: 0; +} + +.tools-section-title { + font-weight: 500; + font-size: 14px; + margin-bottom: 12px; + color: var(--md-text-primary); + display: flex; + align-items: center; + border-bottom: 1px solid var(--md-border-color); + padding-bottom: 6px; +} + +.tools-section-title i { + margin-right: 8px; + color: var(--md-primary); + font-size: 18px; +} + +/* Form Elements */ +.form-group { + margin-bottom: 6px; + position: relative; +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-control { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--md-border-color); + border-radius: 4px; + font-family: 'Roboto', sans-serif; + font-size: 14px; + background-color: var(--md-surface); + color: var(--md-text-primary); + transition: border-color var(--transition-speed) ease, box-shadow var(--transition-speed) ease; +} + +.form-control:focus { + border-color: var(--md-primary); + box-shadow: 0 0 0 1px var(--md-primary-light); + outline: none; +} + +select.form-control { + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 16px; + padding-right: 36px; +} + +.form-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 13px; + color: var(--md-text-secondary); +} + +/* Cloud Cover Slider */ +.form-range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: var(--md-border-color); + border-radius: 2px; + outline: none; + margin: 10px 0; +} + +.form-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: var(--md-primary); + border-radius: 50%; + cursor: pointer; + transition: transform 0.2s ease; +} + +.form-range::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--md-primary); + border: none; + border-radius: 50%; + cursor: pointer; + transition: transform 0.2s ease; +} + +.form-range::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.form-range::-moz-range-thumb:hover { + transform: scale(1.2); +} + +.form-range:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-range:disabled::-webkit-slider-thumb { + background: var(--md-text-secondary); + cursor: not-allowed; +} + +.form-range:disabled::-moz-range-thumb { + background: var(--md-text-secondary); + cursor: not-allowed; +} + +/* Material Design Buttons */ +.md-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 16px; + border-radius: 4px; + font-family: 'Roboto', sans-serif; + font-weight: 500; + font-size: 14px; + text-transform: uppercase; + border: none; + cursor: pointer; + transition: background-color var(--transition-speed) ease, + box-shadow var(--transition-speed) ease; + letter-spacing: 0.5px; + min-width: 64px; + height: 36px; +} + +.md-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.md-btn:active { + box-shadow: var(--elevation-1); +} + +.md-btn i { + margin-right: 8px; + font-size: 16px; +} + +.md-btn-primary { + background-color: var(--md-primary); + color: white; + box-shadow: var(--elevation-2); +} + +.md-btn-primary:hover:not(:disabled) { + background-color: var(--md-primary-dark); + box-shadow: var(--elevation-4); +} + +.md-btn-secondary { + background-color: transparent; + color: var(--md-primary); + border: 1px solid var(--md-primary); +} + +.md-btn-secondary:hover:not(:disabled) { + background-color: rgba(33, 150, 243, 0.08); +} + +.md-btn-danger { + background-color: var(--md-danger); + color: white; + box-shadow: var(--elevation-2); +} + +.md-btn-danger:hover:not(:disabled) { + background-color: #d32f2f; + box-shadow: var(--elevation-4); +} + +.md-btn-info { + background-color: var(--md-info); + color: white; + box-shadow: var(--elevation-2); +} + +.md-btn-info:hover:not(:disabled) { + background-color: #0288d1; + box-shadow: var(--elevation-4); +} + +.button-group { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + position: sticky; + bottom: 0; + background-color: var(--md-surface); + padding-top: 8px; + z-index: 10; +} + +/* Dataset List */ +.dataset-list { + list-style: none; + padding: 0; + margin: 0; +} + +.dataset-item { + border-bottom: 1px solid var(--md-border-color); + transition: background-color var(--transition-speed) ease; +} + +.dataset-item:hover { + background-color: var(--md-hover-overlay); +} + +.dataset-item.active { + background-color: var(--md-selected-overlay); + border-left: 4px solid var(--md-primary); +} + +.dataset-content { + padding: 6px; +} + +.dataset-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0px; +} + +.dataset-info { + flex-grow: 1; +} + +.dataset-thumbnail { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 4px; + margin-bottom: 0px; + background-color: var(--md-surface-overlay); +} + +.dataset-title { + font-weight: 500; + font-size: 16px; + margin-bottom: 4px; + color: var(--md-text-primary); +} + +.dataset-date { + font-size: 13px; + color: var(--md-text-secondary); + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.dataset-date i { + margin-right: 6px; + font-size: 14px; +} + +.dataset-footer { + margin-top: 12px; + display: flex; + justify-content: space-between; +} + +.dataset-details { + margin-top: 10px; + padding: 10px; + background-color: var(--md-surface-overlay); + border-radius: 4px; + font-size: 13px; + display: none; +} + +.metadata-field { + margin-bottom: 6px; +} + +.metadata-field:last-child { + margin-bottom: 0; +} + +.metadata-label { + font-weight: 500; + color: var(--md-text-secondary); +} + +/* Pagination */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border-top: 1px solid var(--md-border-color); + gap: 12px; +} + +.pagination-info { + font-size: 14px; + color: var(--md-text-secondary); +} + +.pagination .md-btn { + min-width: 36px; + width: 36px; + padding: 0; +} + +.pagination .md-btn i { + margin-right: 0; +} + +/* Map Container */ +.map-container { + flex-grow: 1; + position: relative; + z-index: 500; +} + +#map { + height: 100%; + width: 100%; + z-index: 500; +} + +/* Loading Indicator */ +.loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--md-surface); + padding: 24px 32px; + border-radius: 4px; + box-shadow: var(--elevation-4); + display: none; + z-index: 2000; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--md-text-primary); +} + +.loading.active { + display: flex; +} + +.spinner { + width: 36px; + height: 36px; + border: 3px solid rgba(33, 150, 243, 0.2); + border-left-color: var(--md-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading span { + font-weight: 500; +} + +/* Badge */ +.md-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + height: 20px; + border-radius: 10px; + font-size: 12px; + font-weight: 500; + background-color: var(--md-primary); + color: white; + min-width: 20px; +} + +/* Info Box */ +.info-box { + display: flex; + align-items: flex-start; + padding: 10px; + border-radius: 4px; + background-color: rgba(3, 169, 244, 0.1); + font-size: 13px; + color: var(--md-text-primary); +} + +.info-box i { + color: var(--md-info); + margin-right: 8px; + margin-top: 2px; + font-size: 16px; +} + +/* Notification */ +.notification-container { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; +} + +.notification { + padding: 12px 16px; + border-radius: 4px; + background-color: var(--md-surface); + box-shadow: var(--elevation-4); + font-size: 14px; + display: flex; + align-items: center; + animation: slide-in 0.3s ease forwards; + color: var(--md-text-primary); +} + +.notification i { + margin-right: 12px; + font-size: 16px; +} + +.notification.success i { + color: var(--md-success); +} + +.notification.error i { + color: var(--md-danger); +} + +.notification.warning i { + color: var(--md-warning); +} + +.notification.info i { + color: var(--md-info); +} + +@keyframes slide-in { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes fade-out { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(100%); opacity: 0; } +} + +/* Utility Classes */ +.mt-1 { margin-top: 4px; } +.mt-2 { margin-top: 8px; } +.mt-3 { margin-top: 12px; } +.mt-4 { margin-top: 16px; } +.mb-1 { margin-bottom: 4px; } +.mb-2 { margin-bottom: 8px; } +.mb-3 { margin-bottom: 12px; } +.mb-4 { margin-bottom: 16px; } +.p-0 { padding: 0; } +.hidden { display: none; } +.w-100 { width: 100%; } + +/* Responsive Styles */ +@media (max-width: 768px) { + .sidebar { + position: absolute; + height: 100%; + transform: translateX(0); + z-index: 2000; + } + + .sidebar.collapsed { + transform: translateX(calc(-1 * var(--sidebar-width) + var(--sidebar-collapsed-width))); + width: var(--sidebar-width); + } + + .sidebar.collapsed .sidebar-content { + opacity: 1; + visibility: visible; + } + + .tools-panel { + width: 100%; + max-width: 320px; + right: 0; + top: 0; + height: auto; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + .tools-panel.collapsed { + width: 48px; + } + + /* Adjust toggle button for mobile */ + .sidebar-toggle { + right: -12px; + } + + .sidebar-toggle i { + transform: rotate(0); + } + + .sidebar.collapsed .sidebar-toggle i { + transform: rotate(180deg); + } +} + +/* JSON View Styles */ +.json-view { + margin-top: 20px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--card-bg); +} + +.json-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--header-bg); + border-bottom: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.json-content { + padding: 12px; + margin: 0; + overflow-x: auto; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 13px; + line-height: 1.5; + background: var(--code-bg); + color: var(--text-color); + border-radius: 0 0 4px 4px; + max-height: 400px; + overflow-y: auto; +} + +.copy-json-btn { + padding: 4px 8px !important; + font-size: 12px !important; + height: auto !important; + min-width: auto !important; +} + +.copy-json-btn i { + font-size: 16px !important; + margin-right: 4px !important; +} + +/* Dark theme adjustments */ +.dark-theme .json-view { + background: var(--dark-card-bg); +} + +.dark-theme .json-header { + background: var(--dark-header-bg); +} + +.dark-theme .json-content { + background: var(--dark-code-bg); + color: var(--dark-text-color); +} + +/* Modal Dialog */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: none; + justify-content: center; + align-items: center; + z-index: 9999; + backdrop-filter: blur(2px); +} + +.modal-overlay.active { + display: flex; +} + +.modal-dialog { + background-color: var(--md-surface); + border-radius: 8px; + box-shadow: var(--elevation-4); + width: 90%; + max-width: 800px; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: modal-slide-in 0.3s ease; +} + +.modal-header { + padding: 16px; + border-bottom: 1px solid var(--md-border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-title { + font-size: 1.25rem; + font-weight: 500; + color: var(--md-text-primary); + margin: 0; +} + +.modal-close { + background: none; + border: none; + color: var(--md-text-secondary); + cursor: pointer; + padding: 8px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.modal-close:hover { + background-color: var(--md-hover-overlay); +} + +.modal-body { + padding: 16px; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + padding: 16px; + border-top: 1px solid var(--md-border-color); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +@keyframes modal-slide-in { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* JSON content in modal */ +.modal-body .json-content { + max-height: none; + background-color: var(--md-surface-overlay); + border-radius: 4px; + font-size: 14px; +} + +.dark-theme .modal-body .json-content { + background-color: var(--md-bg-default); +} + +/* Add styles for images with $value */ +.special-image { + image-rendering: auto; + image-rendering: crisp-edges; + image-rendering: pixelated; +} + +/* Fix for Leaflet image overlays with CORS issues */ +.leaflet-image-layer { + /* Ensure images are rendered properly */ + image-rendering: auto; +} + +/* Direct URL input styles */ +.url-input-container { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.url-input-container input { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.url-input-container button { + align-self: flex-end; +} + +.dataset-metadata { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; + position: absolute; + top: 8px; + left: 8px; + z-index: 10; + width: calc(100% - 48px); /* Leave space for the info button */ +} + +.dataset-date { + font-size: 13px; + color: white; + display: flex; + align-items: center; + flex-wrap: wrap; + background-color: rgba(0, 0, 0, 0.6); + padding: 4px 8px; + border-radius: 16px; + backdrop-filter: blur(2px); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +} + +.dataset-date i { + margin-right: 6px; + font-size: 14px; +} + +.thumbnail-container { + position: relative; + width: 100%; + margin-bottom: 0px; +} + +.dataset-thumbnail { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 4px; + background-color: var(--md-surface-overlay); +} + +.thumbnail-overlay { + position: absolute; + top: 0; + right: 0; + padding: 4px; +} + +.info-btn { + background-color: rgba(0, 0, 0, 0.5); + color: white; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.info-btn:hover { + background-color: rgba(0, 0, 0, 0.7); +} + +.info-btn i { + font-size: 18px; +} + +.dataset-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0px; +} + +.dataset-info { + flex-grow: 1; +} + +.dataset-title { + font-weight: 500; + font-size: 16px; + color: var(--md-text-primary); +} + +/* Cloud Cover Controls */ +.cloud-cover-toggle { + display: flex; + align-items: center; + gap: 8px; +} + +.cloud-cover-toggle .form-check-input { + margin: 0; + cursor: pointer; +} + +.cloud-cover-toggle .form-check-label { + cursor: pointer; + user-select: none; +} + +#cloud-cover-controls { + opacity: 0.5; + transition: opacity 0.3s ease; +} + +#cloud-cover-controls.enabled { + opacity: 1; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..12b5742 --- /dev/null +++ b/index.html @@ -0,0 +1,242 @@ + + + + + + STAC Catalog Explorer + + + + + + + + + + + + + + + + +
+ + + + +
+
+ + +
+
+ Loading data... +
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..4f8123b --- /dev/null +++ b/js/app.js @@ -0,0 +1,102 @@ +/** + * Main application module for STAC Catalog Explorer + * Ties together all components and initializes the application + */ + +// Import core modules +import { UIManager } from './components/common/UIManager.js'; +import { NotificationService } from './components/common/NotificationService.js'; +import { MapManager } from './components/map/MapManager.js'; +import { STACApiClient } from './components/api/StacApiClient.js'; +import { StateManager } from './utils/StateManager.js'; +import { ShareManager } from './utils/ShareManager.js'; + +// Import UI components +import { SearchPanel } from './components/search/SearchPanel.js'; +import { CatalogSelector } from './components/search/CatalogSelector.js'; +import { CollectionManager } from './components/search/CollectionManager.js'; +import { SearchForm } from './components/search/SearchForm.js'; +import { ResultsPanel } from './components/results/ResultsPanel.js'; + +// Import configuration +import { CONFIG } from './config.js'; + +/** + * Initialize the application when the DOM is fully loaded + */ +document.addEventListener('DOMContentLoaded', function() { + console.log('STAC Catalog Explorer - Initializing application...'); + + try { + // Set initial theme + document.documentElement.classList.add('dark-theme'); + + // Initialize core services + const notificationService = new NotificationService(); + const mapManager = new MapManager('map', CONFIG); + const apiClient = new STACApiClient(CONFIG.stacEndpoints.copernicus); + + // Initialize UI manager + const uiManager = new UIManager(); + + // Initialize catalog selector first to handle default catalog load + const catalogSelector = new CatalogSelector(apiClient, notificationService); + + // Initialize collection manager + const collectionManager = new CollectionManager(apiClient, notificationService); + + // Initialize results panel and search form + const resultsPanel = new ResultsPanel(apiClient, mapManager, notificationService); + const searchForm = new SearchForm(mapManager); + + // Initialize search panel with all required components + const searchPanel = new SearchPanel( + apiClient, + resultsPanel, + catalogSelector, + collectionManager, + searchForm, + notificationService + ); + + // Initialize state manager after all components are ready + const stateManager = new StateManager(catalogSelector, mapManager, searchPanel); + + // Initialize share manager + const shareManager = new ShareManager(stateManager, notificationService); + + // Set up initial date range if configured + if (CONFIG.appSettings.defaultDateRange > 0) { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - CONFIG.appSettings.defaultDateRange); + + // Format dates as YYYY-MM-DD for the input fields + const formatDateForInput = (date) => { + return date.toISOString().split('T')[0]; + }; + + document.getElementById('date-start').value = formatDateForInput(startDate); + document.getElementById('date-end').value = formatDateForInput(endDate); + } + + // Show welcome notification + notificationService.showNotification('Welcome to the STAC Catalog Explorer', 'info'); + + console.log('STAC Catalog Explorer - Initialization complete'); + + // Expose key objects to the global scope for developer console access + window.stacExplorer = { + mapManager, + apiClient, + searchPanel, + resultsPanel, + stateManager, + shareManager, + config: CONFIG + }; + } catch (error) { + console.error('Error initializing application:', error); + alert(`Error initializing application: ${error.message}`); + } +}); \ No newline at end of file diff --git a/js/components/api/StacApiClient.js b/js/components/api/StacApiClient.js new file mode 100644 index 0000000..fd71eeb --- /dev/null +++ b/js/components/api/StacApiClient.js @@ -0,0 +1,216 @@ +/** + * STACApiClient.js - Client for interacting with STAC APIs + */ + +export class STACApiClient { + /** + * Create a new STAC API client + * @param {Object} endpoints - Object containing API endpoints + */ + constructor(endpoints) { + this.setEndpoints(endpoints); + } + + /** + * Set API endpoints + * @param {Object} endpoints - Object containing API endpoints + */ + setEndpoints(endpoints) { + this.endpoints = { + root: endpoints.root || '', + collections: endpoints.collections || '', + search: endpoints.search || '' + }; + } + + /** + * Connect to a custom STAC catalog + * @param {string} url - URL of the STAC catalog + */ + async connectToCustomCatalog(url) { + try { + // Normalize URL: remove trailing slash if present + const normalizedUrl = url.endsWith('/') ? url.slice(0, -1) : url; + + // Check if URL is valid by fetching the root catalog + const response = await fetch(normalizedUrl); + + if (!response.ok) { + throw new Error(`HTTP error ${response.status}: ${response.statusText}`); + } + + const rootCatalog = await response.json(); + + // Check if this is a valid STAC catalog + if (!rootCatalog.links || !rootCatalog.stac_version) { + throw new Error('Invalid STAC catalog: missing required fields'); + } + + // Find collections and search endpoints + let collectionsUrl = `${normalizedUrl}/collections`; + let searchUrl = `${normalizedUrl}/search`; + + // Try to find links in the root catalog + const collectionsLink = rootCatalog.links.find(link => + link.rel === 'data' || link.rel === 'collections'); + const searchLink = rootCatalog.links.find(link => + link.rel === 'search'); + + if (collectionsLink && collectionsLink.href) { + collectionsUrl = new URL(collectionsLink.href, normalizedUrl).href; + } + + if (searchLink && searchLink.href) { + searchUrl = new URL(searchLink.href, normalizedUrl).href; + } + + // Set endpoints + this.setEndpoints({ + root: normalizedUrl, + collections: collectionsUrl, + search: searchUrl + }); + + return rootCatalog; + } catch (error) { + console.error('Error connecting to custom catalog:', error); + throw new Error(`Failed to connect to STAC catalog: ${error.message}`); + } + } + + /** + * Fetch collections from the STAC API + * @returns {Promise} - Promise resolving to an array of collections + */ + async fetchCollections() { + try { + if (!this.endpoints.collections) { + throw new Error('Collections endpoint not defined'); + } + + const response = await fetch(this.endpoints.collections); + + if (!response.ok) { + throw new Error(`HTTP error ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + // Check if the response has a collections property (STAC API spec) + if (data.collections) { + return data.collections; + } else if (Array.isArray(data)) { + // Some implementations return an array directly + return data; + } else { + // If it's not an array or doesn't have collections, it's likely a single collection + return [data]; + } + } catch (error) { + console.error('Error fetching collections:', error); + throw new Error(`Failed to fetch collections: ${error.message}`); + } + } + + /** + * Fetch a specific collection + * @param {string} collectionId - ID of the collection to fetch + * @returns {Promise} - Promise resolving to the collection + */ + async fetchCollection(collectionId) { + try { + if (!this.endpoints.collections) { + throw new Error('Collections endpoint not defined'); + } + + const url = `${this.endpoints.collections}/${collectionId}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching collection ${collectionId}:`, error); + throw new Error(`Failed to fetch collection: ${error.message}`); + } + } + + /** + * Search for STAC items + * @param {Object} params - Search parameters + * @returns {Promise} - Promise resolving to an array of items + */ + async searchItems(params = {}) { + try { + if (!this.endpoints.search) { + throw new Error('Search endpoint not defined'); + } + + console.log('Making search request to:', this.endpoints.search); + console.log('Request params:', JSON.stringify(params, null, 2)); + + const response = await fetch(this.endpoints.search, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Search request failed:', { + status: response.status, + statusText: response.statusText, + responseText: errorText + }); + throw new Error(`HTTP error ${response.status}: ${response.statusText}\n${errorText}`); + } + + const data = await response.json(); + console.log('Search response:', JSON.stringify(data, null, 2)); + + // Check if the response has a features property (GeoJSON/STAC API spec) + if (data.features) { + return data.features; + } else if (Array.isArray(data)) { + // Some implementations might return an array directly + return data; + } else { + // If it's not an array or doesn't have features, return empty array + return []; + } + } catch (error) { + console.error('Error searching items:', error); + throw new Error(`Failed to search items: ${error.message}`); + } + } + + /** + * Fetch a specific item + * @param {string} collectionId - ID of the collection + * @param {string} itemId - ID of the item to fetch + * @returns {Promise} - Promise resolving to the item + */ + async fetchItem(collectionId, itemId) { + try { + if (!this.endpoints.collections) { + throw new Error('Collections endpoint not defined'); + } + + const url = `${this.endpoints.collections}/${collectionId}/items/${itemId}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching item ${itemId}:`, error); + throw new Error(`Failed to fetch item: ${error.message}`); + } + } +} \ No newline at end of file diff --git a/js/components/common/NotificationService.js b/js/components/common/NotificationService.js new file mode 100644 index 0000000..f1e58b2 --- /dev/null +++ b/js/components/common/NotificationService.js @@ -0,0 +1,79 @@ +/** + * NotificationService.js - Handles system notifications + */ + +export class NotificationService { + constructor() { + // Create notification container if it doesn't exist + this.createNotificationContainer(); + + // Listen for custom events + document.addEventListener('showNotification', (event) => { + if (event.detail) { + this.showNotification( + event.detail.message, + event.detail.type || 'info' + ); + } + }); + } + + /** + * Create notification container + */ + createNotificationContainer() { + let notificationContainer = document.querySelector('.notification-container'); + + if (!notificationContainer) { + notificationContainer = document.createElement('div'); + notificationContainer.className = 'notification-container'; + document.body.appendChild(notificationContainer); + } + } + + /** + * Show a notification to the user + * @param {string} message - Notification message + * @param {string} type - Notification type (success, error, warning, info) + */ + showNotification(message, type = 'info') { + // Make sure container exists + this.createNotificationContainer(); + const notificationContainer = document.querySelector('.notification-container'); + + // Create the notification + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + + // Set icon based on notification type + let icon; + switch (type) { + case 'success': + icon = 'check_circle'; + break; + case 'error': + icon = 'error'; + break; + case 'warning': + icon = 'warning'; + break; + case 'info': + default: + icon = 'info'; + break; + } + + notification.innerHTML = `${icon} ${message}`; + notificationContainer.appendChild(notification); + + // Remove the notification after a delay + setTimeout(() => { + notification.style.animation = 'fade-out 0.3s ease forwards'; + + // Remove the element after animation completes + setTimeout(() => { + notification.remove(); + }, 300); + }, 4000); + } +} \ No newline at end of file diff --git a/js/components/common/UIManager.js b/js/components/common/UIManager.js new file mode 100644 index 0000000..62c5ca1 --- /dev/null +++ b/js/components/common/UIManager.js @@ -0,0 +1,167 @@ +/** + * UIManager.js - Core UI functionality handling global UI interactions + */ + +export class UIManager { + constructor() { + this.sidebarCollapsed = false; + this.toolsPanelCollapsed = true; + this.cardStates = { + 'search-container': false, + 'results-card': false + }; + + // Initialize UI event listeners + this.initializeUI(); + } + + /** + * Initialize UI components and event listeners + */ + initializeUI() { + // Theme toggle + document.getElementById('theme-toggle').addEventListener('click', () => { + this.toggleTheme(); + }); + + // Sidebar toggle + document.getElementById('sidebar-toggle').addEventListener('click', () => { + this.toggleSidebar(); + }); + + // Tools panel toggle + const toolsHeader = document.getElementById('tools-header'); + if (toolsHeader) { + toolsHeader.addEventListener('click', () => { + this.toggleToolsPanel(); + }); + } + + // Initialize collapsible cards + this.initializeCollapsibleCards(); + + // Listen for custom events + document.addEventListener('toggleCard', (event) => { + if (event.detail && event.detail.cardId) { + this.toggleCard(event.detail.cardId); + } + }); + } + + /** + * Initialize collapsible cards + */ + initializeCollapsibleCards() { + // Set up click handlers for all card headers + const cardIds = ['search-container', 'results-card']; + + cardIds.forEach(cardId => { + const headerId = cardId === 'search-container' ? 'search-container-header' : 'results-header'; + const headerEl = document.getElementById(headerId); + const cardEl = document.getElementById(cardId); + + headerEl.addEventListener('click', () => { + this.toggleCard(cardId); + }); + }); + } + + /** + * Toggle card collapse state + * @param {string} cardId - ID of the card to toggle + */ + toggleCard(cardId) { + const cardEl = document.getElementById(cardId); + const isCollapsed = cardEl.classList.contains('collapsed'); + + // Update state + this.cardStates[cardId] = !isCollapsed; + + if (isCollapsed) { + // Expand the card + cardEl.classList.remove('collapsed'); + + // Collapse all other cards + const cardIds = Object.keys(this.cardStates); + cardIds.forEach(otherId => { + if (otherId !== cardId) { + const otherCard = document.getElementById(otherId); + if (otherCard && !otherCard.classList.contains('collapsed')) { + otherCard.classList.add('collapsed'); + this.cardStates[otherId] = false; + } + } + }); + } else { + // Collapse the card + cardEl.classList.add('collapsed'); + } + } + + /** + * Toggle sidebar collapse state + */ + toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + const toggleIcon = document.querySelector('#sidebar-toggle i'); + this.sidebarCollapsed = !this.sidebarCollapsed; + + if (this.sidebarCollapsed) { + sidebar.classList.add('collapsed'); + // Update icon for collapsed state + toggleIcon.textContent = 'chevron_right'; + } else { + sidebar.classList.remove('collapsed'); + // Update icon for expanded state + toggleIcon.textContent = 'chevron_left'; + } + + // Trigger a window resize event to update map + window.dispatchEvent(new Event('resize')); + } + + /** + * Toggle tools panel collapse state + */ + toggleToolsPanel() { + const toolsPanel = document.getElementById('tools-panel'); + if (!toolsPanel) { + console.warn('Tools panel not found'); + return; + } + + const toggleIcon = document.querySelector('#tools-toggle i'); + this.toolsPanelCollapsed = !this.toolsPanelCollapsed; + + if (this.toolsPanelCollapsed) { + toolsPanel.classList.add('collapsed'); + } else { + toolsPanel.classList.remove('collapsed'); + } + } + + /** + * Toggle between light and dark themes + */ + toggleTheme() { + const html = document.documentElement; + const themeToggleIcon = document.querySelector('#theme-toggle i'); + + if (html.classList.contains('light-theme')) { + // Switch to dark mode + html.classList.remove('light-theme'); + html.classList.add('dark-theme'); + themeToggleIcon.textContent = 'light_mode'; + } else { + // Switch to light mode + html.classList.remove('dark-theme'); + html.classList.add('light-theme'); + themeToggleIcon.textContent = 'dark_mode'; + } + + // Dispatch theme change event for map to update + document.dispatchEvent(new CustomEvent('themeChange', { + detail: { theme: html.classList.contains('light-theme') ? 'Light' : 'Dark' } + })); + } +} \ No newline at end of file diff --git a/js/components/map/MapManager.js b/js/components/map/MapManager.js new file mode 100644 index 0000000..d20205b --- /dev/null +++ b/js/components/map/MapManager.js @@ -0,0 +1,1274 @@ +/** + * MapManager.js - Handles map display and interactions + */ + +export class MapManager { + /** + * Create a new MapManager + * @param {string} mapElementId - ID of the map container element + * @param {Object} config - Configuration settings + */ + constructor(mapElementId, config) { + this.mapElementId = mapElementId; + this.config = config; + this.map = null; + this.drawControl = null; + this.drawnItems = null; + this.currentLayer = null; + this.currentLayerOverlay = null; + this.currentAssetKey = null; + + // Initialize the map + this.initMap(); + + // Initialize event listeners + this.initEventListeners(); + } + + /** + * Initialize the map + */ + initMap() { + // Create map with default settings + this.map = L.map(this.mapElementId).setView( + this.config.mapSettings.defaultCenter, + this.config.mapSettings.defaultZoom + ); + + // Add default basemap + this.setBasemap(this.config.mapSettings.defaultBasemap); + + // Initialize draw control + this.initDrawControl(); + } + + /** + * Initialize draw control for bounding box selection + */ + initDrawControl() { + // Create feature group for drawn items + this.drawnItems = new L.FeatureGroup(); + this.map.addLayer(this.drawnItems); + + // Initialize draw control with rectangle only + const drawOptions = { + draw: { + polyline: false, + circle: false, + polygon: false, + marker: false, + circlemarker: false, + rectangle: { + shapeOptions: { + color: '#2196F3', + weight: 2 + } + } + }, + edit: { + featureGroup: this.drawnItems, + remove: true + } + }; + + // Create draw control + this.drawControl = new L.Control.Draw(drawOptions); + + // Don't add it yet, will be added when user clicks draw button + } + + /** + * Initialize event listeners + */ + initEventListeners() { + // Theme change event + document.addEventListener('themeChange', (event) => { + if (event.detail && event.detail.theme) { + this.setBasemap(event.detail.theme); + } + }); + + // Map draw events + this.map.on(L.Draw.Event.CREATED, (event) => { + const layer = event.layer; + + // Add drawn layer to feature group + this.drawnItems.addLayer(layer); + + // Get bounds and convert to STAC bbox format [west, south, east, north] + const bounds = layer.getBounds(); + const bbox = [ + bounds.getWest(), + bounds.getSouth(), + bounds.getEast(), + bounds.getNorth() + ]; + + // Dispatch event with bbox + document.dispatchEvent(new CustomEvent('bboxDrawn', { detail: { bbox } })); + + // Remove draw control and revert to normal cursor + this.map.removeControl(this.drawControl); + this.map._container.style.cursor = ''; + }); + + // Clear map drawings event + document.addEventListener('clearMapDrawings', () => { + this.clearDrawings(); + }); + + // Expand tools panel event + document.addEventListener('expandToolsPanel', () => { + const toolsPanel = document.getElementById('tools-panel'); + if (toolsPanel.classList.contains('collapsed')) { + document.getElementById('tools-header').click(); + } + }); + + // Direct image URL loading + const loadImageBtn = document.getElementById('load-image-btn'); + if (loadImageBtn) { + loadImageBtn.addEventListener('click', () => { + this.loadImageFromDirectUrl(); + }); + + // Also allow pressing Enter in the input field + const directUrlInput = document.getElementById('direct-image-url'); + if (directUrlInput) { + directUrlInput.addEventListener('keypress', (event) => { + if (event.key === 'Enter') { + this.loadImageFromDirectUrl(); + } + }); + } + } + } + + /** + * Set the basemap + * @param {string} basemapName - Name of the basemap to use + */ + setBasemap(basemapName) { + // Remove current basemap if exists + if (this.baseLayer) { + this.map.removeLayer(this.baseLayer); + } + + // Get basemap config or use default if not found + const basemapConfig = this.config.mapSettings.basemapOptions[basemapName] || + this.config.mapSettings.basemapOptions.Dark; + + // Create new basemap layer + this.baseLayer = L.tileLayer(basemapConfig.url, { + attribution: basemapConfig.attribution, + maxZoom: 19 + }); + + // Add to map + this.baseLayer.addTo(this.map); + } + + /** + * Start the bounding box drawing mode + */ + startDrawingBbox() { + // Clear any existing drawings + this.clearDrawings(); + + // Add draw control to map + this.map.addControl(this.drawControl); + + // Set cursor to crosshair + this.map._container.style.cursor = 'crosshair'; + + // Activate rectangle drawing + new L.Draw.Rectangle(this.map, this.drawControl.options.draw.rectangle).enable(); + } + + /** + * Clear all drawings from the map + */ + clearDrawings() { + // Clear feature group + this.drawnItems.clearLayers(); + + // Clear bbox input field + document.getElementById('bbox-input').value = ''; + + // Remove draw control if it's on the map + try { + this.map.removeControl(this.drawControl); + } catch (e) { + // Ignore if control is not on map + } + + // Reset cursor + this.map._container.style.cursor = ''; + } + + /** + * Update the map view from a bounding box input + * @param {string} bboxString - Comma-separated bounding box string (west,south,east,north) + */ + updateBBoxFromInput(bboxString) { + try { + // Parse the bbox string + const bbox = bboxString.split(',').map(Number); + + // Validate + if (bbox.length !== 4 || bbox.some(isNaN)) { + throw new Error('Invalid bbox format'); + } + + // Clear existing drawings + this.drawnItems.clearLayers(); + + // Create a rectangle for the bbox + const bounds = L.latLngBounds( + [bbox[1], bbox[0]], // Southwest corner [lat, lng] + [bbox[3], bbox[2]] // Northeast corner [lat, lng] + ); + + // Create rectangle layer + const rectangle = L.rectangle(bounds, { + color: '#2196F3', + weight: 2 + }); + + // Add to drawn items + this.drawnItems.addLayer(rectangle); + + // Fit map to the bbox + this.map.fitBounds(bounds); + } catch (error) { + console.error('Error updating bbox from input:', error); + } + } + + /** + * Find assets with 'visual' roles in a STAC item + * @param {Object} item - STAC item + * @returns {Array} - Array of asset objects with links + */ + findVisualAssets(item) { + const visualAssets = []; + + if (!item.assets) { + console.warn('Item has no assets:', item); + return visualAssets; + } + + // PRIORITY 1: First check if the thumbnail is already loaded in the results panel + // This is the most reliable approach since it's already displayed in the browser + try { + const resultItem = document.querySelector(`.dataset-item[data-id="${item.id}"]`); + if (resultItem) { + const thumbnail = resultItem.querySelector('.dataset-thumbnail'); + if (thumbnail && thumbnail.complete && thumbnail.naturalHeight !== 0 && thumbnail.src && + !thumbnail.src.includes('placeholder')) { + console.log('Using already loaded thumbnail from results panel:', thumbnail.src); + // Return immediately with just this asset to ensure it's used + return [{ + key: 'loaded_thumbnail', + href: thumbnail.src, + type: 'image/jpeg', + roles: ['thumbnail'] + }]; + } + } + } catch (error) { + console.error('Error checking for loaded thumbnail:', error); + } + + // Check if any assets are from CREODIAS - if so, don't even try to use them + // as they will cause CORS errors + const hasCreodiasAssets = Object.values(item.assets).some(asset => + asset.href && ( + asset.href.includes('datahub.creodias.eu') || + asset.href.endsWith('$value') + ) + ); + + if (hasCreodiasAssets) { + console.log('Item has CREODIAS assets that would cause CORS issues - using only the results thumbnail'); + // Return an empty array so the code will fall back to using the results thumbnail + return []; + } + + // PRIORITY 2: Then check specifically for thumbnail asset + if (item.assets.thumbnail && item.assets.thumbnail.href) { + // Skip CREODIAS URLs that will cause CORS issues + if (item.assets.thumbnail.href.includes('datahub.creodias.eu') || + item.assets.thumbnail.href.endsWith('$value')) { + console.log('Skipping CREODIAS thumbnail that would cause CORS issues'); + } else { + console.log('Found thumbnail asset:', item.assets.thumbnail); + visualAssets.push({ + key: 'thumbnail', + ...item.assets.thumbnail + }); + // Return immediately to prioritize thumbnail + return visualAssets; + } + } + + // Check each asset + for (const [key, asset] of Object.entries(item.assets)) { + // Skip assets that will cause CORS issues + if (asset.href && ( + asset.href.includes('datahub.creodias.eu') || + asset.href.endsWith('$value') + )) { + console.log('Skipping CORS-problematic asset:', key, asset.href); + continue; + } + + console.log('Checking asset:', key, asset); + + // Check for visual role + if (asset.roles && asset.roles.includes('visual')) { + console.log('Found visual role asset:', key, asset); + visualAssets.push({ + key, + ...asset + }); + } + // Also check for common visual asset keys + else if (['visual', 'rgb', 'preview', 'browse'].includes(key)) { + console.log('Found visual key asset:', key, asset); + visualAssets.push({ + key, + ...asset + }); + } + } + + console.log('Found visual assets:', visualAssets); + return visualAssets; + } + + /** + * Display a STAC item on the map + * @param {Object} item - STAC item to display + * @param {string} [preferredAssetKey] - Optional preferred asset key to display + * @returns {Promise} - Promise that resolves when the item is displayed + */ + async displayItemOnMap(item, preferredAssetKey = null) { + try { + console.log('displayItemOnMap called with item:', item.id, 'and preferredAssetKey:', preferredAssetKey); + + // Remove any existing layer + this.removeCurrentLayer(); + + // Check if item has a geometry + if (!item.geometry) { + console.error('Item has no geometry:', item); + throw new Error('Item has no geometry'); + } + + // Create GeoJSON layer + this.currentLayer = L.geoJSON(item.geometry, { + style: { + color: '#4CAF50', + weight: 2, + fillOpacity: 0.2 + } + }); + + // Add to map + this.currentLayer.addTo(this.map); + console.log('Added GeoJSON layer to map for item:', item.id); + + // Fit map to layer bounds + this.fitToLayerBounds(); + + // If we have a preferred asset key, try to use it first + if (preferredAssetKey && item.assets && item.assets[preferredAssetKey]) { + console.log(`Using preferred asset: ${preferredAssetKey}`, item.assets[preferredAssetKey]); + const asset = item.assets[preferredAssetKey]; + await this.addAssetOverlay(asset, item, preferredAssetKey); + return true; + } + + // SIMPLIFIED APPROACH: Try using the results thumbnail first + console.log('Trying to use results thumbnail first'); + const thumbnailSuccess = this.useResultsThumbnail(item); + console.log('useResultsThumbnail result:', thumbnailSuccess); + + // Only if that fails, try other methods + if (!thumbnailSuccess) { + console.log('Thumbnail not available, falling back to visual assets'); + // Find assets with 'visual' roles for preview + const visualAssets = this.findVisualAssets(item); + console.log('Found visual assets:', visualAssets); + + // If we have visual assets, try to add them as overlay + if (visualAssets.length > 0) { + const asset = visualAssets[0]; + const assetKey = Object.keys(item.assets).find(key => item.assets[key] === asset); + console.log('Using visual asset:', assetKey, asset); + await this.addAssetOverlay(asset, item, assetKey); + } else { + console.warn('No visual assets found for item:', item); + } + } + + return true; + } catch (error) { + console.error('Error displaying item on map:', error); + return false; + } + } + + /** + * Use the thumbnail from the results panel as a quick preview + * @param {Object} item - STAC item + * @returns {boolean} - Whether the thumbnail was successfully used + */ + useResultsThumbnail(item) { + try { + console.log('Using simplified thumbnail approach for item:', item.id); + + // Find the thumbnail in the results panel - try both data-id and dataset.id + let resultItem = document.querySelector(`.dataset-item[data-id="${item.id}"]`); + + // If not found, try with the id attribute directly + if (!resultItem) { + console.log('Trying to find result item by iterating through all dataset items'); + const allItems = document.querySelectorAll('.dataset-item'); + for (const element of allItems) { + if (element.dataset.id === item.id) { + resultItem = element; + break; + } + } + } + + if (!resultItem) { + console.warn('Could not find result item in the panel for:', item.id); + return false; + } + + const thumbnail = resultItem.querySelector('.dataset-thumbnail'); + if (!thumbnail || !thumbnail.src || thumbnail.src.includes('placeholder')) { + console.warn('No valid thumbnail found for item:', item.id); + return false; + } + + console.log('Found thumbnail:', thumbnail.src); + + // Set the current asset key to 'thumbnail' + this.currentAssetKey = 'thumbnail'; + + // Get bounds from item + let bounds; + if (item.bbox && item.bbox.length === 4) { + bounds = L.latLngBounds( + [item.bbox[1], item.bbox[0]], // Southwest corner [lat, lng] + [item.bbox[3], item.bbox[2]] // Northeast corner [lat, lng] + ); + } else if (item.geometry && item.geometry.type === 'Polygon') { + // Extract bounds from polygon coordinates + const coords = item.geometry.coordinates[0]; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + coords.forEach(coord => { + minX = Math.min(minX, coord[0]); + minY = Math.min(minY, coord[1]); + maxX = Math.max(maxX, coord[0]); + maxY = Math.max(maxY, coord[1]); + }); + + bounds = L.latLngBounds( + [minY, minX], // Southwest corner [lat, lng] + [maxY, maxX] // Northeast corner [lat, lng] + ); + } else { + console.warn('Item has no valid bbox or geometry for overlay'); + return false; + } + + // Create image overlay with the thumbnail's src directly + const overlay = L.imageOverlay(thumbnail.src, bounds, { + opacity: 1.0, + interactive: false // Prevent interaction issues + }); + + // Add to map + overlay.addTo(this.map); + + // Store as part of current layer + this.currentLayerOverlay = overlay; + + console.log('Results thumbnail overlay created successfully'); + return true; + } catch (error) { + console.error('Error using results thumbnail:', error); + return false; + } + } + + /** + * Add an asset as an overlay on the map + * @param {Object} asset - STAC asset to display + * @param {Object} item - Parent STAC item + * @param {string} assetKey - The key of the asset in the item's assets object + * @returns {Promise} - Promise that resolves when the overlay is added + */ + async addAssetOverlay(asset, item, assetKey) { + try { + // Store the asset key for state management + this.currentAssetKey = assetKey; + + // Check if asset has a href + if (!asset.href) { + throw new Error('Asset has no href'); + } + + // Determine if this is a COG or other type of visual asset + const isCOG = this.isCOGAsset(asset); + + if (isCOG) { + // Handle COG assets with special rendering + await this.addCOGOverlay(asset, item); + } else { + // Handle regular image assets + await this.addImageOverlay(asset, item); + } + + // Dispatch event that an item has been displayed with a specific asset + document.dispatchEvent(new CustomEvent('assetDisplayed', { + detail: { + itemId: item.id, + assetKey: assetKey + } + })); + + return true; + } catch (error) { + console.error('Error adding asset overlay:', error); + return false; + } + } + + /** + * Add a visual overlay to the map + * @param {Object} asset - Asset object with href + * @param {Object} item - Parent STAC item + */ + async addVisualOverlay(asset, item) { + try { + console.log('Adding visual overlay for asset:', asset); + console.log('Item:', item); + + // Skip if no href + if (!asset.href) { + console.warn('Asset has no href:', asset); + // Try using the thumbnail from results as fallback + return this.useResultsThumbnail(item); + } + + // Process the href - keep as is even if it ends with $value (it's still a valid JPG) + let imageUrl = asset.href; + console.log('Using image URL:', imageUrl); + + // Get bounds from item + let bounds; + if (item.bbox && item.bbox.length === 4) { + console.log('Using bbox for bounds:', item.bbox); + bounds = L.latLngBounds( + [item.bbox[1], item.bbox[0]], // Southwest corner [lat, lng] + [item.bbox[3], item.bbox[2]] // Northeast corner [lat, lng] + ); + } else if (item.geometry && item.geometry.type === 'Polygon') { + console.log('Using geometry for bounds:', item.geometry); + // Extract bounds from polygon coordinates + const coords = item.geometry.coordinates[0]; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + coords.forEach(coord => { + minX = Math.min(minX, coord[0]); + minY = Math.min(minY, coord[1]); + maxX = Math.max(maxX, coord[0]); + maxY = Math.max(maxY, coord[1]); + }); + + bounds = L.latLngBounds( + [minY, minX], // Southwest corner [lat, lng] + [maxY, maxX] // Northeast corner [lat, lng] + ); + console.log('Calculated bounds:', bounds); + } else { + console.warn('Item has no valid bbox or geometry for overlay'); + return this.useResultsThumbnail(item); + } + + // Log the URL we're trying to load + console.log('Attempting to load image from URL:', imageUrl); + + // Check for CREODIAS URLs or URLs with $value - these will likely have CORS issues + if (imageUrl.includes('datahub.creodias.eu') || imageUrl.endsWith('$value')) { + console.log('Detected CREODIAS or $value URL, using results thumbnail to avoid CORS issues'); + // For CREODIAS URLs, go straight to using the results thumbnail + // as we know direct fetch will fail due to CORS + return this.useResultsThumbnail(item); + } + + // For URLs with $value but not from CREODIAS, try direct fetch + if (imageUrl.includes('/$value')) { + console.log('URL contains $value, trying direct fetch first'); + const fetchResult = await this.fetchImageDirectly(imageUrl, bounds, item); + if (fetchResult) { + return true; + } + // If direct fetch fails, fall back to results thumbnail + return this.useResultsThumbnail(item); + } + + // For other URLs, try standard image overlay first + // Create image overlay with appropriate options + const overlayOptions = { + opacity: 1.0, + crossOrigin: true + }; + + // Create image overlay + const overlay = L.imageOverlay(imageUrl, bounds, overlayOptions); + + // Add load event handler + overlay.on('load', () => { + console.log('Image overlay loaded successfully'); + }); + + // Add error handling for the image load + overlay.on('error', (error) => { + console.error('Error loading image overlay:', error); + console.error('Failed URL:', imageUrl); + this.notificationService?.showNotification('Error loading image overlay. Trying alternative method...', 'warning'); + + // Try using the results thumbnail first as it's most reliable + if (!this.useResultsThumbnail(item)) { + // If that fails, try direct fetch + this.fetchImageDirectly(imageUrl, bounds, item); + } + }); + + // Add to map + overlay.addTo(this.map); + + // Store as part of current layer + this.currentLayerOverlay = overlay; + + return true; + } catch (error) { + console.error('Error adding visual overlay:', error); + console.error('Asset:', asset); + console.error('Item:', item); + this.notificationService?.showNotification('Error adding visual overlay to map', 'error'); + + // Try using the results thumbnail as a last resort + return this.useResultsThumbnail(item); + } + } + + /** + * Add an image overlay to the map + * @param {Object} asset - Asset object with href + * @param {Object} item - Parent STAC item + */ + async addImageOverlay(asset, item) { + try { + console.log('Adding image overlay for asset:', asset); + console.log('Item:', item); + + // Skip if no href + if (!asset.href) { + console.warn('Asset has no href:', asset); + return false; + } + + // Process the href + let imageUrl = asset.href; + console.log('Using image URL:', imageUrl); + + // Get bounds from item + let bounds; + if (item.bbox && item.bbox.length === 4) { + console.log('Using bbox for bounds:', item.bbox); + bounds = L.latLngBounds( + [item.bbox[1], item.bbox[0]], // Southwest corner [lat, lng] + [item.bbox[3], item.bbox[2]] // Northeast corner [lat, lng] + ); + } else if (item.geometry && item.geometry.type === 'Polygon') { + console.log('Using geometry for bounds:', item.geometry); + // Extract bounds from polygon coordinates + const coords = item.geometry.coordinates[0]; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + coords.forEach(coord => { + minX = Math.min(minX, coord[0]); + minY = Math.min(minY, coord[1]); + maxX = Math.max(maxX, coord[0]); + maxY = Math.max(maxY, coord[1]); + }); + + bounds = L.latLngBounds( + [minY, minX], // Southwest corner [lat, lng] + [maxY, maxX] // Northeast corner [lat, lng] + ); + console.log('Calculated bounds:', bounds); + } else { + console.warn('Item has no valid bbox or geometry for overlay'); + return false; + } + + // Create image overlay with appropriate options + const overlayOptions = { + opacity: 1.0, + crossOrigin: true + }; + + // Create image overlay + const overlay = L.imageOverlay(imageUrl, bounds, overlayOptions); + + // Add load event handler + overlay.on('load', () => { + console.log('Image overlay loaded successfully'); + }); + + // Add error handling for the image load + overlay.on('error', (error) => { + console.error('Error loading image overlay:', error); + console.error('Failed URL:', imageUrl); + this.notificationService?.showNotification('Error loading image overlay. Trying alternative method...', 'warning'); + + // Try using the results thumbnail first as it's most reliable + if (!this.useResultsThumbnail(item)) { + // If that fails, try direct fetch + this.fetchImageDirectly(imageUrl, bounds, item); + } + }); + + // Add to map + overlay.addTo(this.map); + + // Store as part of current layer + this.currentLayerOverlay = overlay; + + return true; + } catch (error) { + console.error('Error adding image overlay:', error); + console.error('Asset:', asset); + console.error('Item:', item); + this.notificationService?.showNotification('Error adding image overlay to map', 'error'); + + // Try using the results thumbnail as a last resort + return this.useResultsThumbnail(item); + } + } + + /** + * Create a canvas-based overlay as a fallback for CORS issues + * @param {string} imageUrl - URL of the image + * @param {L.LatLngBounds} bounds - Bounds for the overlay + * @param {Object} item - STAC item + */ + createCanvasOverlay(imageUrl, bounds, item) { + try { + console.log('Attempting canvas-based overlay as fallback for:', imageUrl); + + // For regular STAC items, try using the results thumbnail first as it's most reliable + if (item.id !== 'direct-url-image' && this.useResultsThumbnail(item)) { + console.log('Successfully used results thumbnail instead of canvas overlay'); + return true; + } + + // For direct URL inputs, we need to try harder with the canvas approach + // Create an image element to load the image + const img = new Image(); + img.crossOrigin = 'Anonymous'; + + // Use the image URL directly + let srcUrl = imageUrl; + + // For direct URL inputs, try to use a CORS proxy + if (item.id === 'direct-url-image') { + // Try using a different CORS proxy as a last resort + srcUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(imageUrl)}`; + console.log('Using AllOrigins proxy for canvas approach:', srcUrl); + } + + img.onload = () => { + // Create a canvas element + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + + // Draw the image on the canvas + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + + // Convert canvas to data URL + const dataUrl = canvas.toDataURL('image/jpeg'); + + // Create image overlay with data URL + const overlay = L.imageOverlay(dataUrl, bounds, { + opacity: 1.0 + }); + + // Add to map + overlay.addTo(this.map); + + // Store as part of current layer + if (this.currentLayerOverlay) { + this.map.removeLayer(this.currentLayerOverlay); + } + this.currentLayerOverlay = overlay; + + console.log('Canvas overlay created successfully'); + + // For direct URL inputs, show a success notification + if (item.id === 'direct-url-image') { + this.notificationService?.showNotification('Image loaded successfully using canvas method', 'success'); + } + }; + + img.onerror = () => { + console.error('Failed to load image for canvas overlay'); + + if (item.id === 'direct-url-image') { + // For direct URL inputs, show an error notification + this.notificationService?.showNotification('Could not load image. The URL may be invalid or blocked by CORS policy.', 'error'); + + // Try one more approach - using an iframe proxy + this.tryIframeProxy(imageUrl, bounds); + } else { + // For regular STAC items, try to use the thumbnail from the results panel as a fallback + this.useResultsThumbnail(item); + } + }; + + // Set the source to start loading + img.src = srcUrl; + return true; + } catch (error) { + console.error('Error creating canvas overlay:', error); + + // For direct URL inputs, show an error notification + if (item.id === 'direct-url-image') { + this.notificationService?.showNotification('Could not load image. The URL may be invalid or blocked by CORS policy.', 'error'); + return false; + } + + // Try using the results thumbnail as a last resort + return this.useResultsThumbnail(item); + } + } + + /** + * Try using an iframe proxy as a last resort for direct URLs + * @param {string} imageUrl - URL of the image + * @param {L.LatLngBounds} bounds - Bounds for the overlay + */ + tryIframeProxy(imageUrl, bounds) { + try { + console.log('Attempting iframe proxy as last resort for:', imageUrl); + + // Create a notification to inform the user + this.notificationService?.showNotification('Trying alternative method to load image...', 'info'); + + // Create a hidden iframe to load the image + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + document.body.appendChild(iframe); + + // Create a simple HTML document with the image + const html = ` + + + + Image Proxy + + + + + + + + `; + + // Listen for messages from the iframe + const messageHandler = (event) => { + if (event.data && event.data.type === 'imageLoaded') { + console.log('Image loaded in iframe proxy'); + + // Create a message to inform the user + this.notificationService?.showNotification('Image loaded in proxy frame. Using screenshot method.', 'success'); + + // Clean up + window.removeEventListener('message', messageHandler); + document.body.removeChild(iframe); + } else if (event.data && event.data.type === 'imageError') { + console.error('Image failed to load in iframe proxy'); + + // Create an error notification + this.notificationService?.showNotification('All methods failed to load the image. Please check the URL.', 'error'); + + // Clean up + window.removeEventListener('message', messageHandler); + document.body.removeChild(iframe); + } + }; + + window.addEventListener('message', messageHandler); + + // Write the HTML to the iframe + iframe.contentWindow.document.open(); + iframe.contentWindow.document.write(html); + iframe.contentWindow.document.close(); + + return true; + } catch (error) { + console.error('Error with iframe proxy:', error); + this.notificationService?.showNotification('All methods failed to load the image. Please check the URL.', 'error'); + return false; + } + } + + /** + * Remove the current layer from the map + */ + removeCurrentLayer() { + if (this.currentLayer) { + this.map.removeLayer(this.currentLayer); + this.currentLayer = null; + } + + if (this.currentLayerOverlay) { + this.map.removeLayer(this.currentLayerOverlay); + this.currentLayerOverlay = null; + } + + // Reset the current asset key + this.currentAssetKey = null; + + // Clean up any blob URLs + if (this.currentBlobUrl) { + URL.revokeObjectURL(this.currentBlobUrl); + this.currentBlobUrl = null; + } + } + + /** + * Fit the map view to the current layer bounds + */ + fitToLayerBounds() { + if (this.currentLayer) { + const bounds = this.currentLayer.getBounds(); + if (bounds.isValid()) { + this.map.fitBounds(bounds, { + padding: [50, 50] + }); + } + } + } + + /** + * Set the opacity of the current layer + * @param {number} opacity - Opacity value (0-1) + */ + setLayerOpacity(opacity) { + if (this.currentLayerOverlay) { + this.currentLayerOverlay.setOpacity(opacity); + } + } + + /** + * Fetch an image directly using fetch API + * @param {string} url - URL of the image + * @param {L.LatLngBounds} bounds - Bounds for the overlay + * @param {Object} item - STAC item + */ + async fetchImageDirectly(url, bounds, item) { + try { + console.log('Fetching image directly:', url); + + // Check if this is a CREODIAS URL + const isCreodiasUrl = url.includes('datahub.creodias.eu'); + + // For direct URL inputs, try a simpler approach first + if (item.id === 'direct-url-image') { + try { + console.log('Using direct image overlay for pasted URL'); + + // Create image overlay with direct URL + const overlay = L.imageOverlay(url, bounds, { + opacity: 1.0, + crossOrigin: 'anonymous' + }); + + // Add load event handler + overlay.on('load', () => { + console.log('Direct URL image overlay loaded successfully'); + }); + + // Add error handling for the image load + overlay.on('error', (error) => { + console.error('Error loading direct URL image overlay:', error); + throw new Error('Failed to load image directly'); + }); + + // Add to map + overlay.addTo(this.map); + + // Store as part of current layer + if (this.currentLayerOverlay) { + this.map.removeLayer(this.currentLayerOverlay); + } + this.currentLayerOverlay = overlay; + + return true; + } catch (directError) { + console.error('Error with direct overlay approach:', directError); + // Continue to the fetch approach + } + } + + // First try with regular CORS mode + try { + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + headers: { + 'Accept': 'image/jpeg,image/png,*/*' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Get the blob + const blob = await response.blob(); + + // Create a blob URL + const blobUrl = URL.createObjectURL(blob); + console.log('Created blob URL:', blobUrl); + + // Create image overlay with blob URL + const overlay = L.imageOverlay(blobUrl, bounds, { + opacity: 1.0 + }); + + // Add to map + overlay.addTo(this.map); + + // Store as part of current layer + if (this.currentLayerOverlay) { + this.map.removeLayer(this.currentLayerOverlay); + } + this.currentLayerOverlay = overlay; + + // Add cleanup for blob URL + this.currentBlobUrl = blobUrl; + + console.log('Image overlay created successfully'); + return true; + } catch (corsError) { + console.error('CORS error fetching image:', corsError); + + // If this is a direct URL input, try no-cors mode + if (item.id === 'direct-url-image') { + try { + console.log('Trying no-cors mode for direct URL'); + + // Try using a proxy service if available + const proxyUrl = `https://cors-anywhere.herokuapp.com/${url}`; + console.log('Using proxy URL:', proxyUrl); + + const response = await fetch(proxyUrl, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + headers: { + 'Accept': 'image/jpeg,image/png,*/*', + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error with proxy! status: ${response.status}`); + } + + // Get the blob + const blob = await response.blob(); + + // Create a blob URL + const blobUrl = URL.createObjectURL(blob); + console.log('Created blob URL from proxy:', blobUrl); + + // Create image overlay with blob URL + const overlay = L.imageOverlay(blobUrl, bounds, { + opacity: 1.0 + }); + + // Add to map + overlay.addTo(this.map); + + // Store as part of current layer + if (this.currentLayerOverlay) { + this.map.removeLayer(this.currentLayerOverlay); + } + this.currentLayerOverlay = overlay; + + // Add cleanup for blob URL + this.currentBlobUrl = blobUrl; + + console.log('Image overlay created successfully with proxy'); + return true; + } catch (proxyError) { + console.error('Error with proxy approach:', proxyError); + // Fall back to canvas method + } + } + + // If this is a CREODIAS URL, try using the results thumbnail as it's most reliable + if (isCreodiasUrl) { + console.log('CREODIAS URL detected, using results thumbnail instead'); + return this.useResultsThumbnail(item); + } + + // For other URLs, try canvas method as a last resort + return this.createCanvasOverlay(url, bounds, item); + } + } catch (error) { + console.error('Error in fetchImageDirectly:', error); + // Try using the results thumbnail as a fallback + if (this.useResultsThumbnail(item)) { + return true; + } + // If that fails, try canvas method as a last resort + return this.createCanvasOverlay(url, bounds, item); + } + } + + /** + * Load an image from a direct URL input + */ + async loadImageFromDirectUrl() { + try { + // Get the URL from the input field + const urlInput = document.getElementById('direct-image-url'); + const imageUrl = urlInput.value.trim(); + + if (!imageUrl) { + console.warn('No URL provided'); + this.notificationService?.showNotification('Please enter a valid image URL', 'warning'); + return; + } + + console.log('Loading image from direct URL:', imageUrl); + + // Show loading indicator + document.getElementById('loading').style.display = 'flex'; + + // Remove any existing layer + this.removeCurrentLayer(); + + // Get the current map bounds + const mapBounds = this.map.getBounds(); + + // Create a dummy item with the current map bounds + const dummyItem = { + id: 'direct-url-image', + geometry: { + type: 'Polygon', + coordinates: [[ + [mapBounds.getWest(), mapBounds.getSouth()], + [mapBounds.getEast(), mapBounds.getSouth()], + [mapBounds.getEast(), mapBounds.getNorth()], + [mapBounds.getWest(), mapBounds.getNorth()], + [mapBounds.getWest(), mapBounds.getSouth()] + ]] + }, + bbox: [ + mapBounds.getWest(), + mapBounds.getSouth(), + mapBounds.getEast(), + mapBounds.getNorth() + ] + }; + + // Create GeoJSON layer for the bounds + this.currentLayer = L.geoJSON(dummyItem.geometry, { + style: { + color: '#4CAF50', + weight: 2, + fillOpacity: 0.2 + } + }); + + // Add to map + this.currentLayer.addTo(this.map); + + // Try to fetch and display the image + const success = await this.fetchImageDirectly(imageUrl, mapBounds, dummyItem); + + if (success) { + console.log('Successfully loaded image from direct URL'); + this.notificationService?.showNotification('Image loaded successfully', 'success'); + + // Set the current asset key + this.currentAssetKey = 'direct-url'; + + // Dispatch event that an asset has been displayed + document.dispatchEvent(new CustomEvent('assetDisplayed', { + detail: { + itemId: 'direct-url-image', + assetKey: 'direct-url' + } + })); + } else { + console.error('Failed to load image from direct URL'); + this.notificationService?.showNotification('Failed to load image. Please check the URL and try again.', 'error'); + } + + // Hide loading indicator + document.getElementById('loading').style.display = 'none'; + } catch (error) { + console.error('Error loading image from direct URL:', error); + this.notificationService?.showNotification(`Error loading image: ${error.message}`, 'error'); + + // Hide loading indicator + document.getElementById('loading').style.display = 'none'; + } + } + + /** + * Check if an asset is a Cloud Optimized GeoTIFF (COG) + * @param {Object} asset - Asset object + * @returns {boolean} - True if the asset is a COG, false otherwise + */ + isCOGAsset(asset) { + // Check if the asset type is 'image/tiff; application=geotiff; profile=cloud-optimized' + return asset.type === 'image/tiff; application=geotiff; profile=cloud-optimized'; + } +} \ No newline at end of file diff --git a/js/components/results/ResultsPanel.js b/js/components/results/ResultsPanel.js new file mode 100644 index 0000000..f951259 --- /dev/null +++ b/js/components/results/ResultsPanel.js @@ -0,0 +1,490 @@ +/** + * ResultsPanel.js - Handles displaying search results with pagination + */ + +export class ResultsPanel { + /** + * Create a new ResultsPanel + * @param {Object} apiClient - STAC API client + * @param {Object} mapManager - Map manager for displaying items on map + * @param {Object} notificationService - Notification service + */ + constructor(apiClient, mapManager, notificationService) { + this.apiClient = apiClient; + this.mapManager = mapManager; + this.notificationService = notificationService; + this.items = []; + this.currentPage = 1; + this.itemsPerPage = 10; + this.totalPages = 1; + this.modal = null; + this.currentAssetKey = null; + + // Initialize pagination controls + this.initPagination(); + + // Create modal element + this.createModal(); + + // Listen for asset displayed events + document.addEventListener('assetDisplayed', this.handleAssetDisplayed.bind(this)); + } + + /** + * Initialize pagination controls + */ + initPagination() { + document.querySelector('.pagination-prev').addEventListener('click', () => { + if (this.currentPage > 1) { + this.currentPage--; + this.renderPage(); + } + }); + + document.querySelector('.pagination-next').addEventListener('click', () => { + if (this.currentPage < this.totalPages) { + this.currentPage++; + this.renderPage(); + } + }); + } + + /** + * Create modal dialog element + */ + createModal() { + // Create modal overlay + const modalOverlay = document.createElement('div'); + modalOverlay.className = 'modal-overlay'; + modalOverlay.id = 'details-modal'; + + // Create modal dialog + modalOverlay.innerHTML = ` + + `; + + // Add modal to body + document.body.appendChild(modalOverlay); + + // Add event listeners + const closeBtn = modalOverlay.querySelector('.modal-close'); + const closeBtnFooter = modalOverlay.querySelector('#modal-close-btn'); + const copyBtn = modalOverlay.querySelector('#modal-copy-btn'); + + closeBtn.addEventListener('click', () => this.closeModal()); + closeBtnFooter.addEventListener('click', () => this.closeModal()); + modalOverlay.addEventListener('click', (e) => { + if (e.target === modalOverlay) { + this.closeModal(); + } + }); + + // Store modal elements + this.modal = { + overlay: modalOverlay, + content: modalOverlay.querySelector('#modal-content'), + copyBtn: copyBtn + }; + } + + /** + * Show modal with item details + * @param {Object} item - STAC item to display + */ + showModal(item) { + // Format the JSON for display + const formattedJson = JSON.stringify(item, null, 2); + + // Create content + const content = document.createElement('div'); + content.innerHTML = ` + + `; + + // Update modal content + this.modal.content.innerHTML = ''; + this.modal.content.appendChild(content); + + // Update copy button handler + this.modal.copyBtn.onclick = () => { + navigator.clipboard.writeText(formattedJson).then(() => { + this.notificationService.showNotification('JSON copied to clipboard', 'success'); + }).catch(err => { + console.error('Failed to copy JSON:', err); + this.notificationService.showNotification('Failed to copy JSON', 'error'); + }); + }; + + // Show modal + this.modal.overlay.classList.add('active'); + + // Add keyboard listener for Escape key + document.addEventListener('keydown', this.handleEscapeKey); + } + + /** + * Close the modal + */ + closeModal() { + this.modal.overlay.classList.remove('active'); + document.removeEventListener('keydown', this.handleEscapeKey); + } + + /** + * Handle Escape key press + * @param {KeyboardEvent} event + */ + handleEscapeKey = (event) => { + if (event.key === 'Escape') { + this.closeModal(); + } + } + + /** + * Set items and render first page + * @param {Array} items - Array of STAC items + */ + setItems(items) { + this.items = items; + this.currentPage = 1; + this.totalPages = Math.ceil(items.length / this.itemsPerPage) || 1; + + // Update results count + document.getElementById('results-count').textContent = items.length; + + // Update pagination display + document.getElementById('current-page').textContent = this.currentPage; + document.getElementById('total-pages').textContent = this.totalPages; + + // Render first page + this.renderPage(); + } + + /** + * Clear all results + */ + clearResults() { + this.items = []; + this.currentPage = 1; + this.totalPages = 1; + + // Update results count + document.getElementById('results-count').textContent = '0'; + + // Update pagination display + document.getElementById('current-page').textContent = this.currentPage; + document.getElementById('total-pages').textContent = this.totalPages; + + // Clear the dataset list + document.getElementById('dataset-list').innerHTML = ''; + + // Disable pagination buttons + document.querySelector('.pagination-prev').disabled = true; + document.querySelector('.pagination-next').disabled = true; + } + + /** + * Render current page of results + */ + renderPage() { + const datasetList = document.getElementById('dataset-list'); + datasetList.innerHTML = ''; + + // Calculate start and end indices for current page + const startIndex = (this.currentPage - 1) * this.itemsPerPage; + const endIndex = Math.min(startIndex + this.itemsPerPage, this.items.length); + + // Get items for current page + const pageItems = this.items.slice(startIndex, endIndex); + + if (pageItems.length === 0) { + datasetList.innerHTML = '
  • No datasets found
  • '; + return; + } + + // Render each item + pageItems.forEach(item => { + const li = this.createDatasetItem(item); + datasetList.appendChild(li); + }); + + // Update pagination info + document.getElementById('current-page').textContent = this.currentPage; + + // Enable/disable pagination buttons + const prevBtn = document.querySelector('.pagination-prev'); + const nextBtn = document.querySelector('.pagination-next'); + + prevBtn.disabled = this.currentPage === 1; + nextBtn.disabled = this.currentPage === this.totalPages; + } + + /** + * Create a dataset item element + * @param {Object} item - STAC item + * @returns {HTMLElement} List item element + */ + createDatasetItem(item) { + const li = document.createElement('li'); + li.className = 'dataset-item'; + li.dataset.id = item.id; + li.setAttribute('data-id', item.id); + + // Extract thumbnail URL + let thumbnailUrl = 'https://api.placeholder.com/300/200?text=No+Preview'; + + // Check various potential thumbnail locations in the STAC item + if (item.assets) { + if (item.assets.thumbnail) { + thumbnailUrl = item.assets.thumbnail.href; + } else if (item.assets.preview) { + thumbnailUrl = item.assets.preview.href; + } else if (item.assets.overview) { + thumbnailUrl = item.assets.overview.href; + } + } + + // Get the date from the item + let itemDate = 'Unknown date'; + if (item.properties && item.properties.datetime) { + itemDate = new Date(item.properties.datetime).toLocaleDateString(); + } else if (item.date) { + itemDate = item.date; + } + + // Get cloud cover icon if available + let cloudIcon = ''; + if (item.properties && item.properties['eo:cloud_cover'] !== undefined) { + const cloudCover = Math.round(item.properties['eo:cloud_cover'],0); + + if (cloudCover > 75) { + cloudIcon = ' β€’ ☁️ ' + cloudCover + '%'; // Very cloudy + } else if (cloudCover > 50) { + cloudIcon = ' β€’ πŸŒ₯️ ' + cloudCover + '%'; // Mostly cloudy + } else if (cloudCover > 25) { + cloudIcon = ' β€’ β›… ' + cloudCover + '%'; // Partly cloudy + } else if (cloudCover > 5) { + cloudIcon = ' β€’ 🌀️ ' + cloudCover + '%'; // Mostly sunny + } else { + cloudIcon = ' β€’ β˜€οΈ ' + cloudCover + '%'; // Sunny + } + } + + // Get the collection ID + let collectionId = 'Unknown'; + if (item.collection) { + collectionId = item.collection; + } else if (item.links) { + const collectionLink = item.links.find(link => link.rel === 'collection'); + if (collectionLink) { + collectionId = collectionLink.href.split('/').pop(); + } + } + + // Get the title + const title = item.properties && item.properties.title ? + item.properties.title : (item.title || item.id); + + // Get the description + const description = item.properties && item.properties.description ? + item.properties.description : (item.description || 'No description available'); + + // Prepare metadata fields + const metadataFields = []; + + metadataFields.push(` + + + `); + + // Add cloud cover if available + if (item.properties && (item.properties['eo:cloud_cover'] !== undefined)) { + metadataFields.push(` + + `); + } + + // Add ground resolution if available + if (item.properties && (item.properties['eo:gsd'] !== undefined)) { + metadataFields.push(` + + `); + } + + // Add provider if available + if (item.properties && item.properties.provider) { + metadataFields.push(` + + `); + } else if (item.properties && item.properties.constellation) { + metadataFields.push(` + + `); + } + + // Add formatted JSON data + const formattedJson = JSON.stringify(item, null, 2) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\n/g, '
    ') + .replace(/ /g, ' '); + + metadataFields.push(` + + `); + + // Construct html + li.innerHTML = ` +
    +
    + + Dataset thumbnail +
    + +
    +
    +
    + `; + + // Add event listeners after creating the element + this.attachItemEventListeners(li, item); + + return li; + } + + /** + * Attach event listeners to dataset item + * @param {HTMLElement} element - Dataset item element + * @param {Object} item - STAC item data + */ + attachItemEventListeners(element, item) { + // Add event listener to thumbnail + const thumbnail = element.querySelector('.dataset-thumbnail'); + const infoBtn = element.querySelector('.info-btn'); + + // Add click handler to thumbnail + thumbnail.addEventListener('click', () => { + // Show loading indicator + document.getElementById('loading').style.display = 'flex'; + + // Display item on map + setTimeout(() => { + // Always use the thumbnail asset key when clicking View + this.mapManager.displayItemOnMap(item, 'thumbnail') + .then(() => { + // Mark the item as active + document.querySelectorAll('.dataset-item').forEach(el => { + el.classList.remove('active'); + }); + element.classList.add('active'); + + // Dispatch item activated event with the thumbnail asset key + document.dispatchEvent(new CustomEvent('itemActivated', { + detail: { + itemId: item.id, + assetKey: 'thumbnail' + } + })); + + // Expand tools panel if collapsed + document.dispatchEvent(new CustomEvent('expandToolsPanel')); + + // Hide loading indicator + document.getElementById('loading').style.display = 'none'; + }) + .catch(error => { + this.notificationService.showNotification( + `Error displaying item on map: ${error.message}`, + 'error' + ); + document.getElementById('loading').style.display = 'none'; + }); + }, 100); // Small delay to allow loading indicator to appear + }); + + // Add event listener to info button + if (infoBtn) { + infoBtn.addEventListener('click', () => { + this.showModal(item); + }); + } + } + + /** + * Handle when an asset is displayed on the map + * @param {CustomEvent} event - The asset displayed event + */ + handleAssetDisplayed(event) { + if (event.detail && event.detail.assetKey) { + this.currentAssetKey = event.detail.assetKey; + } + } +} \ No newline at end of file diff --git a/js/components/search/CatalogSelector.js b/js/components/search/CatalogSelector.js new file mode 100644 index 0000000..5a03620 --- /dev/null +++ b/js/components/search/CatalogSelector.js @@ -0,0 +1,147 @@ +/** + * CatalogSelector.js - Component for selecting and connecting to STAC catalogs + */ + +export class CatalogSelector { + /** + * Create a new CatalogSelector + * @param {Object} apiClient - STAC API client + * @param {Object} notificationService - Notification service + */ + constructor(apiClient, notificationService) { + this.apiClient = apiClient; + this.notificationService = notificationService; + this.currentCatalog = 'copernicus'; + + // Initialize event listeners + this.initEventListeners(); + + // Trigger initial catalog load + setTimeout(() => { + this.handleCatalogChange('copernicus'); + }, 100); + } + + /** + * Initialize event listeners + */ + initEventListeners() { + // Catalog selection change + document.getElementById('catalog-select').addEventListener('change', (event) => { + this.handleCatalogChange(event.target.value); + }); + + // Custom catalog connection + document.getElementById('connect-catalog-btn').addEventListener('click', () => { + this.connectToCustomCatalog(); + }); + + // Show/hide custom catalog input based on selection + document.getElementById('catalog-select').addEventListener('change', (event) => { + const customCatalogContainer = document.getElementById('custom-catalog-container'); + customCatalogContainer.style.display = event.target.value === 'custom' ? 'block' : 'none'; + }); + } + + /** + * Handle changing the STAC catalog + * @param {string} catalogType - Type of catalog (local, copernicus, custom) + */ + async handleCatalogChange(catalogType) { + try { + // Show loading indicator + document.getElementById('loading').style.display = 'flex'; + this.currentCatalog = catalogType; + + // If it's custom catalog, don't try to connect yet + if (catalogType === 'custom') { + document.getElementById('loading').style.display = 'none'; + return; + } + + // Get endpoints from config + let endpoints = window.stacExplorer.config.stacEndpoints[catalogType]; + + if (!endpoints) { + throw new Error(`Unknown catalog type: ${catalogType}`); + } + + // Update API client endpoints + this.apiClient.setEndpoints(endpoints); + + // Fetch collections + const collections = await this.apiClient.fetchCollections(); + + // Trigger collection updated event + document.dispatchEvent(new CustomEvent('collectionsUpdated', { + detail: { collections } + })); + + // Show success notification + this.notificationService.showNotification(`Connected to ${catalogType} catalog`, 'success'); + + // Hide loading indicator + document.getElementById('loading').style.display = 'none'; + + // Dispatch catalog changed event to switch tabs + document.dispatchEvent(new CustomEvent('catalogChanged', { + detail: { catalogType } + })); + } catch (error) { + console.error('Error changing catalog:', error); + this.notificationService.showNotification(`Error connecting to ${catalogType} catalog: ${error.message}`, 'error'); + document.getElementById('loading').style.display = 'none'; + } + } + + /** + * Connect to a custom STAC catalog + */ + async connectToCustomCatalog() { + const customUrl = document.getElementById('custom-catalog-url').value.trim(); + + if (!customUrl) { + this.notificationService.showNotification('Please enter a valid STAC catalog URL', 'error'); + return; + } + + try { + // Show loading indicator + document.getElementById('loading').style.display = 'flex'; + + // Connect to custom catalog + await this.apiClient.connectToCustomCatalog(customUrl); + + // Fetch collections + const collections = await this.apiClient.fetchCollections(); + + // Trigger collection updated event + document.dispatchEvent(new CustomEvent('collectionsUpdated', { + detail: { collections } + })); + + // Show success notification + this.notificationService.showNotification(`Connected to custom STAC catalog: ${customUrl}`, 'success'); + + // Hide loading indicator + document.getElementById('loading').style.display = 'none'; + + // Dispatch catalog changed event to switch tabs + document.dispatchEvent(new CustomEvent('catalogChanged', { + detail: { catalogType: 'custom' } + })); + } catch (error) { + console.error('Error connecting to custom catalog:', error); + this.notificationService.showNotification(`Error connecting to custom catalog: ${error.message}`, 'error'); + document.getElementById('loading').style.display = 'none'; + } + } + + /** + * Get the current catalog type + * @returns {string} Current catalog type + */ + getCurrentCatalog() { + return this.currentCatalog; + } +} \ No newline at end of file diff --git a/js/components/search/CollectionManager.js b/js/components/search/CollectionManager.js new file mode 100644 index 0000000..4453d7e --- /dev/null +++ b/js/components/search/CollectionManager.js @@ -0,0 +1,90 @@ +/** + * CollectionManager.js - Component for managing STAC collections + */ + +export class CollectionManager { + /** + * Create a new CollectionManager + * @param {Object} apiClient - STAC API client + * @param {Object} notificationService - Notification service + */ + constructor(apiClient, notificationService) { + this.apiClient = apiClient; + this.notificationService = notificationService; + this.collections = []; + this.selectedCollection = ''; + + // Initialize event listeners + this.initEventListeners(); + } + + /** + * Initialize event listeners + */ + initEventListeners() { + // Collection selection change + document.getElementById('collection-select').addEventListener('change', (event) => { + this.selectedCollection = event.target.value; + }); + + // Listen for collections updated event + document.addEventListener('collectionsUpdated', (event) => { + if (event.detail && event.detail.collections) { + this.populateCollectionSelect(event.detail.collections); + } + }); + } + + /** + * Populate collection select dropdown + * @param {Array} collections - Array of collection objects + */ + populateCollectionSelect(collections) { + this.collections = collections; + const select = document.getElementById('collection-select'); + select.innerHTML = ''; + + if (collections.length === 0) { + select.innerHTML = ''; + return; + } + + select.innerHTML = ''; + + collections.forEach(collection => { + const option = document.createElement('option'); + option.value = collection.id; + option.textContent = collection.title || collection.id; + select.appendChild(option); + }); + + // Reset selection + this.selectedCollection = ''; + } + + /** + * Get the selected collection ID + * @returns {string} Selected collection ID or empty string if none selected + */ + getSelectedCollection() { + return this.selectedCollection; + } + + /** + * Get collection by ID + * @param {string} collectionId - Collection ID + * @returns {Object|null} Collection object or null if not found + */ + getCollectionById(collectionId) { + return this.collections.find(collection => collection.id === collectionId) || null; + } + + /** + * Reset collection selection + */ + resetSelection() { + this.selectedCollection = ''; + const select = document.getElementById('collection-select'); + select.value = ''; + } +} \ No newline at end of file diff --git a/js/components/search/SearchForm.js b/js/components/search/SearchForm.js new file mode 100644 index 0000000..c4318dc --- /dev/null +++ b/js/components/search/SearchForm.js @@ -0,0 +1,208 @@ +/** + * SearchForm.js - Component for search criteria inputs + */ + +export class SearchForm { + /** + * Create a new SearchForm + * @param {Object} mapManager - Map manager for coordinating with the map + */ + constructor(mapManager) { + this.mapManager = mapManager; + this.initFromUrl(); + this.initEventListeners(); + } + + /** + * Initialize form state from URL parameters + */ + initFromUrl() { + const urlParams = new URLSearchParams(window.location.search); + + // Restore cloud cover state if present in URL + const cloudCover = urlParams.get('cloudCover'); + const cloudCoverEnabled = document.getElementById('cloud-cover-enabled'); + const cloudCoverInput = document.getElementById('cloud-cover'); + const cloudCoverControls = document.getElementById('cloud-cover-controls'); + const cloudCoverValue = document.getElementById('cloud-cover-value'); + + if (cloudCoverEnabled && cloudCoverInput && cloudCoverControls && cloudCoverValue) { + if (cloudCover !== null) { + // Enable cloud cover filter + cloudCoverEnabled.checked = true; + cloudCoverInput.disabled = false; + cloudCoverControls.style.display = 'block'; + cloudCoverControls.classList.add('enabled'); + + // Set the value + cloudCoverInput.value = cloudCover; + cloudCoverValue.textContent = `${cloudCover}%`; + } else { + // Reset to default state + cloudCoverEnabled.checked = false; + cloudCoverInput.disabled = true; + cloudCoverControls.style.display = 'none'; + cloudCoverControls.classList.remove('enabled'); + cloudCoverInput.value = '100'; + cloudCoverValue.textContent = '100%'; + } + } + } + + /** + * Update URL with current form state + */ + updateUrl() { + const url = new URL(window.location.href); + const urlParams = new URLSearchParams(url.search); + + // Update cloud cover in URL if enabled + const cloudCoverEnabled = document.getElementById('cloud-cover-enabled'); + const cloudCoverInput = document.getElementById('cloud-cover'); + + if (cloudCoverEnabled?.checked && cloudCoverInput && !cloudCoverInput.disabled) { + urlParams.set('cloudCover', cloudCoverInput.value); + } else { + urlParams.delete('cloudCover'); + } + + // Update URL without reloading the page + url.search = urlParams.toString(); + window.history.replaceState({}, '', url); + } + + /** + * Initialize event listeners + */ + initEventListeners() { + // Manual bbox input handling + const bboxInput = document.getElementById('bbox-input'); + if (bboxInput) { + bboxInput.addEventListener('change', (event) => { + if (this.mapManager) { + this.mapManager.updateBBoxFromInput(event.target.value); + } + }); + } + + // Listen for map draw events + document.addEventListener('bboxDrawn', (event) => { + if (event.detail?.bbox) { + this.updateBBoxInput(event.detail.bbox); + } + }); + + // Cloud cover handling + const cloudCoverEnabled = document.getElementById('cloud-cover-enabled'); + const cloudCoverControls = document.getElementById('cloud-cover-controls'); + const cloudCoverInput = document.getElementById('cloud-cover'); + const cloudCoverValue = document.getElementById('cloud-cover-value'); + + if (cloudCoverEnabled && cloudCoverControls && cloudCoverInput && cloudCoverValue) { + // Toggle cloud cover controls visibility + cloudCoverEnabled.addEventListener('change', () => { + const isEnabled = cloudCoverEnabled.checked; + cloudCoverControls.style.display = isEnabled ? 'block' : 'none'; + cloudCoverControls.classList.toggle('enabled', isEnabled); + cloudCoverInput.disabled = !isEnabled; + + // Update URL when cloud cover is toggled + this.updateUrl(); + }); + + // Update cloud cover value display and URL + cloudCoverInput.addEventListener('input', () => { + cloudCoverValue.textContent = `${cloudCoverInput.value}%`; + // Update URL when cloud cover value changes + this.updateUrl(); + }); + } + } + + /** + * Update the bbox input field with coordinates + * @param {Array} bbox - Bounding box coordinates [west, south, east, north] + */ + updateBBoxInput(bbox) { + if (!bbox?.length === 4) return; + + const bboxInput = document.getElementById('bbox-input'); + if (bboxInput) { + bboxInput.value = bbox.map(coord => parseFloat(coord).toFixed(6)).join(','); + } + } + + /** + * Get search parameters from form + * @returns {Object} - Search parameters + */ + getSearchParams() { + const params = { limit: 50 }; + + try { + // Add text search if provided + const searchText = document.getElementById('search-input')?.value.trim(); + if (searchText) { + params.query = { + "or": [ + { "contains": ["title", searchText] }, + { "contains": ["description", searchText] } + ] + }; + } + + // Add date range if provided + const startDate = document.getElementById('date-start')?.value; + const endDate = document.getElementById('date-end')?.value; + if (startDate || endDate) { + params.datetime = `${startDate ? startDate + 'T00:00:00Z' : ''}/${endDate ? endDate + 'T23:59:59Z' : ''}`; + } + + // Add bbox if provided + const bboxValue = document.getElementById('bbox-input')?.value.trim(); + if (bboxValue) { + const bbox = bboxValue.split(',').map(Number); + if (bbox.length === 4 && !bbox.some(isNaN)) { + params.bbox = bbox; + } + } + + // Add cloud cover if enabled and provided + const cloudCoverEnabled = document.getElementById('cloud-cover-enabled'); + const cloudCoverInput = document.getElementById('cloud-cover'); + + if (cloudCoverEnabled?.checked && cloudCoverInput && !cloudCoverInput.disabled) { + const cloudCoverValue = parseInt(cloudCoverInput.value); + if (!isNaN(cloudCoverValue)) { + params["filter-lang"] = "cql2-json"; + params.filter = { + "op": "and", + "args": [ + { + "op": ">=", + "args": [{ "property": "eo:cloud_cover" }, 0] + }, + { + "op": "<", + "args": [{ "property": "eo:cloud_cover" }, cloudCoverValue] + } + ] + }; + } + } + + // Add collections if they exist in the form + const collections = document.getElementById('collections')?.value; + if (collections) { + params.collections = collections.split(',').map(c => c.trim()); + } + + // Update URL with current state + this.updateUrl(); + } catch (error) { + console.error('Error getting search params:', error); + } + + return params; + } +} \ No newline at end of file diff --git a/js/components/search/SearchPanel.js b/js/components/search/SearchPanel.js new file mode 100644 index 0000000..c84e912 --- /dev/null +++ b/js/components/search/SearchPanel.js @@ -0,0 +1,192 @@ +/** + * SearchPanel.js - Main search panel with tabbed interface + */ + +export class SearchPanel { + /** + * Create a new SearchPanel + * @param {Object} apiClient - STAC API client + * @param {Object} resultsPanel - Results panel + * @param {Object} catalogSelector - Catalog selector component + * @param {Object} collectionManager - Collection manager component + * @param {Object} searchForm - Search form component + * @param {Object} notificationService - Notification service + */ + constructor( + apiClient, + resultsPanel, + catalogSelector, + collectionManager, + searchForm, + notificationService + ) { + this.apiClient = apiClient; + this.resultsPanel = resultsPanel; + this.catalogSelector = catalogSelector; + this.collectionManager = collectionManager; + this.searchForm = searchForm; + this.notificationService = notificationService; + + // Initialize tabs and buttons + this.initTabs(); + + // Initialize search button + document.getElementById('search-btn').addEventListener('click', () => { + this.performSearch(); + }); + + // Initialize reset button + document.getElementById('reset-btn').addEventListener('click', () => { + this.resetSearch(); + }); + + // Listen for catalog change to switch to collections tab + document.addEventListener('catalogChanged', (event) => { + // Switch to the collections tab after a short delay to let collections load + setTimeout(() => { + this.switchToTab('collections-tab'); + }, 300); + }); + + // Add click handler for search container header to hide results card + const searchContainerHeader = document.getElementById('search-container-header'); + if (searchContainerHeader) { + searchContainerHeader.addEventListener('click', () => { + // Collapse results card when search container is clicked + const resultsCard = document.getElementById('results-card'); + if (!resultsCard.classList.contains('collapsed')) { + const event = new CustomEvent('toggleCard', { detail: { cardId: 'results-card' } }); + document.dispatchEvent(event); + } + }); + } + } + + /** + * Initialize tabs functionality + */ + initTabs() { + const tabs = document.querySelectorAll('.search-tabs .tab'); + + tabs.forEach(tab => { + tab.addEventListener('click', (event) => { + const targetId = tab.getAttribute('data-target'); + this.switchToTab(targetId); + }); + }); + } + + /** + * Switch to a specific tab + * @param {string} targetId - ID of the tab to switch to + */ + switchToTab(targetId) { + // Get the tab element that corresponds to this target + const tabs = document.querySelectorAll('.search-tabs .tab'); + const targetTab = Array.from(tabs).find(tab => tab.getAttribute('data-target') === targetId); + + if (!targetTab) return; + + // Remove active class from all tabs + tabs.forEach(t => t.classList.remove('active')); + + // Add active class to clicked tab + targetTab.classList.add('active'); + + // Hide all tab panes + document.querySelectorAll('.tab-pane').forEach(pane => { + pane.classList.remove('active'); + }); + + // Show selected tab pane + document.getElementById(targetId).classList.add('active'); + } + + /** + * Perform search with parameters from all components + */ + async performSearch() { + try { + // Show loading indicator + document.getElementById('loading').style.display = 'flex'; + + // Get search parameters from SearchForm + const searchParams = this.searchForm.getSearchParams(); + + // Add collection if specified + const selectedCollection = this.collectionManager.getSelectedCollection(); + if (selectedCollection) { + searchParams.collections = [selectedCollection]; + } + + console.log('Final search parameters:', JSON.stringify(searchParams, null, 2)); + + // Perform the search + const items = await this.apiClient.searchItems(searchParams); + + // Update results panel + this.resultsPanel.setItems(items); + + // Collapse search container after search is performed + const searchContainer = document.getElementById('search-container'); + if (!searchContainer.classList.contains('collapsed')) { + const collapseSearchEvent = new CustomEvent('toggleCard', { detail: { cardId: 'search-container' } }); + document.dispatchEvent(collapseSearchEvent); + } + + // Make sure the results card is expanded + const resultsCard = document.getElementById('results-card'); + if (resultsCard.classList.contains('collapsed')) { + // Toggle results card + const event = new CustomEvent('toggleCard', { detail: { cardId: 'results-card' } }); + document.dispatchEvent(event); + } + + // Dispatch event that search results have been loaded + document.dispatchEvent(new CustomEvent('searchResultsLoaded', { + detail: { results: items } + })); + + // Hide loading indicator + document.getElementById('loading').style.display = 'none'; + + // Show notification if no results + if (items.length === 0) { + this.notificationService.showNotification('No datasets found matching your search criteria.', 'info'); + } + } catch (error) { + console.error('Error searching items:', error); + this.notificationService.showNotification(`Error searching items: ${error.message}`, 'error'); + + // Hide loading indicator + document.getElementById('loading').style.display = 'none'; + } + } + + /** + * Reset search form and results + */ + resetSearch() { + // Reset search input + document.getElementById('search-input').value = ''; + + // Reset date range + document.getElementById('date-start').value = ''; + document.getElementById('date-end').value = ''; + + // Reset bounding box + document.getElementById('bbox-input').value = ''; + + // Clear map drawings if available + document.dispatchEvent(new CustomEvent('clearMapDrawings')); + + // Reset collection selection + this.collectionManager.resetSelection(); + + // Clear results + this.resultsPanel.clearResults(); + + // Show notification + this.notificationService.showNotification('Search has been reset.', 'info'); + } +} \ No newline at end of file diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..695f264 --- /dev/null +++ b/js/config.js @@ -0,0 +1,52 @@ +/** + * Configuration settings for STAC Catalog Explorer + */ + +export const CONFIG = { + // STAC API Endpoints + stacEndpoints: { + local: { + root: 'http://localhost:8080/stac', + collections: 'http://localhost:8080/stac/collections', + search: 'http://localhost:8080/stac/search' + }, + copernicus: { + root: 'https://stac.dataspace.copernicus.eu/v1', + collections: 'https://stac.dataspace.copernicus.eu/v1/collections', + search: 'https://stac.dataspace.copernicus.eu/v1/search' + }, + custom: { + root: '', + collections: '', + search: '' + } + }, + + // Map settings + mapSettings: { + defaultCenter: [51.505, -0.09], // London + defaultZoom: 5, + defaultBasemap: 'Dark', + basemapOptions: { + Dark: { + url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + attribution: '© OpenStreetMap contributors © CARTO' + }, + Light: { + url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', + attribution: '© OpenStreetMap contributors © CARTO' + } + } + }, + + // Default search parameters + defaultSearchParams: { + limit: 50 // Default number of items to return in a search + }, + + // Application settings + appSettings: { + defaultDateRange: 30, // Default date range in days + itemsPerPage: 10 // Number of items per page in results + } +}; \ No newline at end of file diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..e69de29 diff --git a/js/utils/ShareManager.js b/js/utils/ShareManager.js new file mode 100644 index 0000000..9657152 --- /dev/null +++ b/js/utils/ShareManager.js @@ -0,0 +1,70 @@ +/** + * ShareManager.js - Handles sharing functionality for the application + */ + +export class ShareManager { + /** + * Create a new ShareManager + * @param {Object} stateManager - Reference to the StateManager + * @param {Object} notificationService - Reference to the NotificationService + */ + constructor(stateManager, notificationService) { + this.stateManager = stateManager; + this.notificationService = notificationService; + + // Initialize event listeners + this.initEventListeners(); + } + + /** + * Initialize event listeners + */ + initEventListeners() { + // Copy URL button + const copyUrlBtn = document.getElementById('copy-url-btn'); + if (copyUrlBtn) { + copyUrlBtn.addEventListener('click', this.copyCurrentUrl.bind(this)); + } + } + + /** + * Copy the current URL to the clipboard + */ + async copyCurrentUrl() { + try { + // Make sure the URL is up to date with the current state + this.stateManager.updateUrl(); + + // Get the current URL + const currentUrl = window.location.href; + + // Copy to clipboard + await navigator.clipboard.writeText(currentUrl); + + // Show success message + const messageEl = document.getElementById('url-copied-message'); + if (messageEl) { + messageEl.style.display = 'block'; + + // Hide after 3 seconds + setTimeout(() => { + messageEl.style.display = 'none'; + }, 3000); + } + + // Also show notification + if (this.notificationService) { + this.notificationService.showNotification('View URL copied to clipboard!', 'success'); + } + + console.log('URL copied to clipboard:', currentUrl); + } catch (error) { + console.error('Failed to copy URL:', error); + + // Show error notification + if (this.notificationService) { + this.notificationService.showNotification('Failed to copy URL: ' + error.message, 'error'); + } + } + } +} \ No newline at end of file diff --git a/js/utils/StateManager.js b/js/utils/StateManager.js new file mode 100644 index 0000000..4eef862 --- /dev/null +++ b/js/utils/StateManager.js @@ -0,0 +1,275 @@ +/** + * StateManager.js - Handles application state management via URL parameters + */ + +export class StateManager { + constructor(catalogSelector, mapManager, searchPanel) { + this.catalogSelector = catalogSelector; + this.mapManager = mapManager; + this.searchPanel = searchPanel; + this.activeItemId = null; + this.activeAssetKey = null; + + // Wait for initial catalog setup before initializing state + // This ensures we don't interfere with the default Copernicus load + setTimeout(() => { + this.initFromUrl(); + this.setupStateListeners(); + }, 100); + } + + /** + * Initialize application state from URL parameters + */ + initFromUrl() { + const params = new URLSearchParams(window.location.search); + + // Handle non-default catalog selection + if (params.has('catalog') && params.get('catalog') !== 'copernicus') { + const catalog = params.get('catalog'); + if (catalog === 'custom') { + const customUrl = params.get('catalogUrl'); + if (customUrl) { + document.getElementById('custom-catalog-url').value = customUrl; + } + document.getElementById('catalog-select').value = catalog; + this.catalogSelector.handleCatalogChange(catalog); + } else { + document.getElementById('catalog-select').value = catalog; + this.catalogSelector.handleCatalogChange(catalog); + } + } + + // Handle other state parameters after collections are loaded + const handleCollectionsLoaded = () => { + // Restore collection selection if specified + if (params.has('collection')) { + const collection = params.get('collection'); + document.getElementById('collection-select').value = collection; + document.getElementById('collection-select').dispatchEvent(new Event('change')); + } + + // Restore search parameters + if (params.has('search')) { + document.getElementById('search-input').value = params.get('search'); + } + if (params.has('dateStart')) { + document.getElementById('date-start').value = params.get('dateStart'); + } + if (params.has('dateEnd')) { + document.getElementById('date-end').value = params.get('dateEnd'); + } + if (params.has('bbox')) { + document.getElementById('bbox-input').value = params.get('bbox'); + this.mapManager.updateBBoxFromInput(params.get('bbox')); + } + + // Restore map state + if (params.has('mapCenter') && params.has('mapZoom')) { + const [lat, lng] = params.get('mapCenter').split(',').map(Number); + const zoom = parseInt(params.get('mapZoom')); + this.mapManager.map.setView([lat, lng], zoom); + } + + // Restore active item if specified + if (params.has('activeItem')) { + this.activeItemId = params.get('activeItem'); + + // If we also have an active asset, store it + if (params.has('activeAsset')) { + this.activeAssetKey = params.get('activeAsset'); + } + + // We'll need to wait for search results to load before we can activate the item + document.addEventListener('searchResultsLoaded', this.handleSearchResultsLoaded.bind(this), { once: true }); + } + + // Trigger search if we have any search parameters + if (this.hasSearchParams(params) && this.searchPanel) { + this.searchPanel.performSearch(); + } + }; + + // Listen for collections to be loaded + document.addEventListener('collectionsUpdated', handleCollectionsLoaded, { once: true }); + } + + /** + * Handle when search results are loaded, to restore active item + * @param {CustomEvent} event - The search results loaded event + */ + handleSearchResultsLoaded(event) { + if (!this.activeItemId) return; + + const results = event.detail.results; + if (!results || !results.length) return; + + // Find the item in the results + const activeItem = results.find(item => item.id === this.activeItemId); + if (activeItem) { + console.log('Restoring active item from URL:', this.activeItemId); + + // If we have an active asset, pass it to the display method + if (this.activeAssetKey) { + console.log('Using specified asset key from URL:', this.activeAssetKey); + this.mapManager.displayItemOnMap(activeItem, this.activeAssetKey); + } else { + // Default to thumbnail if no asset key is specified + console.log('No asset key specified, defaulting to thumbnail'); + this.mapManager.displayItemOnMap(activeItem, 'thumbnail'); + } + + // Also update the UI to show this item as selected + setTimeout(() => { + document.querySelectorAll('.dataset-item').forEach(el => { + if (el.dataset.itemId === this.activeItemId) { + el.classList.add('active'); + + // Ensure the results card is expanded + if (document.getElementById('results-card').classList.contains('collapsed')) { + document.getElementById('results-header').click(); + } + + // Scroll the item into view + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }); + + // Ensure tools panel is expanded + if (document.getElementById('tools-panel').classList.contains('collapsed')) { + document.getElementById('tools-header').click(); + } + }, 500); // Small delay to ensure the UI is ready + } + } + + /** + * Setup listeners for state changes + */ + setupStateListeners() { + // Listen for catalog changes + document.getElementById('catalog-select').addEventListener('change', () => this.updateUrl()); + + // Listen for collection changes + document.getElementById('collection-select').addEventListener('change', () => this.updateUrl()); + + // Listen for search parameter changes + document.getElementById('search-input').addEventListener('change', () => this.updateUrl()); + document.getElementById('date-start').addEventListener('change', () => this.updateUrl()); + document.getElementById('date-end').addEventListener('change', () => this.updateUrl()); + document.getElementById('bbox-input').addEventListener('change', () => this.updateUrl()); + + // Listen for map changes + this.mapManager.map.on('moveend', () => this.updateUrl()); + + // Listen for active item changes + document.addEventListener('itemActivated', (event) => { + this.activeItemId = event.detail.itemId; + this.activeAssetKey = event.detail.assetKey || null; + this.updateUrl(); + }); + } + + /** + * Update URL with current application state + */ + updateUrl() { + const params = new URLSearchParams(); + + // Add catalog selection + const catalog = document.getElementById('catalog-select').value; + if (catalog) { + params.set('catalog', catalog); + if (catalog === 'custom') { + const customUrl = document.getElementById('custom-catalog-url').value; + if (customUrl) { + params.set('catalogUrl', customUrl); + } + } + } + + // Add collection selection + const collection = document.getElementById('collection-select').value; + if (collection) { + params.set('collection', collection); + } + + // Add search parameters + const searchText = document.getElementById('search-input').value; + if (searchText) { + params.set('search', searchText); + } + + const dateStart = document.getElementById('date-start').value; + if (dateStart) { + params.set('dateStart', dateStart); + } + + const dateEnd = document.getElementById('date-end').value; + if (dateEnd) { + params.set('dateEnd', dateEnd); + } + + const bbox = document.getElementById('bbox-input').value; + if (bbox) { + params.set('bbox', bbox); + } + + // Add map state + const center = this.mapManager.map.getCenter(); + params.set('mapCenter', `${center.lat.toFixed(6)},${center.lng.toFixed(6)}`); + params.set('mapZoom', this.mapManager.map.getZoom().toString()); + + // Add active item if specified + if (this.activeItemId) { + params.set('activeItem', this.activeItemId); + + // If we have an active asset, add it too + if (this.activeAssetKey) { + params.set('activeAsset', this.activeAssetKey); + } + } + + // Update URL without reloading the page + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.pushState({}, '', newUrl); + } + + /** + * Check if URL parameters contain any search criteria + * @param {URLSearchParams} params - URL parameters + * @returns {boolean} True if search parameters exist + */ + hasSearchParams(params) { + return params.has('search') || + params.has('dateStart') || + params.has('dateEnd') || + params.has('bbox') || + params.has('collection'); + } + + /** + * Wait for an element to be available in the DOM + * @param {string} selector - Element selector + * @returns {Promise} Promise that resolves when element is available + */ + waitForElement(selector) { + return new Promise(resolve => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver(mutations => { + if (document.querySelector(selector)) { + observer.disconnect(); + resolve(document.querySelector(selector)); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + }); + } +} \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..80636cc --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "jsx": "react", + "allowImportingTsExtensions": true, + "strictNullChecks": true, + "strictFunctionTypes": true + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file