diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml
index f3c602286e..aab312a652 100644
--- a/.github/workflows/publish-docker.yml
+++ b/.github/workflows/publish-docker.yml
@@ -40,6 +40,7 @@ jobs:
org.opencontainers.image.maintainer=$({github.repository_owner})
tags: |
type=semver,pattern={{version}},value=v${{ env.VERSION }}
+ type=raw,value=${{ env.VERSION }}
- name: Log in to the GitHub container registry
uses: docker/login-action@v3
diff --git a/config.js b/config.js
index abbcb49fe5..f2f8bfae9f 100644
--- a/config.js
+++ b/config.js
@@ -1,9 +1,10 @@
export default {
- catalogUrl: null,
- catalogTitle: "STAC Browser",
+ catalogUrl: 'http://10.7.0.13:23006',
+ catalogTitle: "ESM-Tools STAC Catalog",
catalogImage: null,
allowExternalAccess: true, // Must be true if catalogUrl is not given
allowedDomains: [],
+ allowedHosts: true,
detectLocaleFromBrowser: true,
storeLocale: true,
locale: "en",
@@ -28,7 +29,7 @@ export default {
"id",
"pl"
],
- apiCatalogPriority: null,
+ apiCatalogPriority: 'collections',
useTileLayerAsFallback: false,
displayGeoTiffByDefault: false,
displayPreview: true,
@@ -54,5 +55,6 @@ export default {
preprocessSTAC: null,
authConfig: null,
crs: {},
- footerLinks: null
+ footerLinks: null,
+ vizServer: null // URL of visualization server for data previews (e.g., 'http://localhost:8001')
};
diff --git a/config.schema.json b/config.schema.json
index b75a418f8c..415de6fec7 100644
--- a/config.schema.json
+++ b/config.schema.json
@@ -264,6 +264,14 @@
"url"
]
}
+ },
+ "vizServer": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "uri",
+ "description": "URL of visualization server for data previews"
}
}
}
diff --git a/config.test.js b/config.test.js
new file mode 100644
index 0000000000..97a24e3428
--- /dev/null
+++ b/config.test.js
@@ -0,0 +1,42 @@
+// Test configuration for Playwright e2e tests
+// Uses null catalogUrl so external paths work correctly
+export default {
+ catalogUrl: null, // Tests use /search/external/... paths which work without a default catalog
+ catalogTitle: "STAC Browser (Test)",
+ catalogImage: null,
+ allowExternalAccess: true,
+ allowedDomains: [],
+ detectLocaleFromBrowser: false,
+ storeLocale: false,
+ locale: "en",
+ fallbackLocale: "en",
+ supportedLocales: ["en"],
+ apiCatalogPriority: null,
+ useTileLayerAsFallback: false,
+ displayGeoTiffByDefault: false,
+ displayPreview: true,
+ displayOverview: true,
+ buildTileUrlTemplate: null,
+ getMapSourceOptions: null,
+ pathPrefix: "/",
+ historyMode: "history",
+ cardViewMode: "cards",
+ cardViewSort: "asc",
+ showKeywordsInItemCards: false,
+ showKeywordsInCatalogCards: false,
+ showThumbnailsAsAssets: false,
+ searchResultsPerPage: null,
+ itemsPerPage: null,
+ collectionsPerPage: null,
+ maxEntriesPerPage: 1000,
+ defaultThumbnailSize: null,
+ crossOriginMedia: null,
+ requestHeaders: {},
+ requestQueryParameters: {},
+ socialSharing: [],
+ preprocessSTAC: null,
+ authConfig: null,
+ crs: {},
+ footerLinks: null,
+ vizServer: null // No viz server for tests - preview endpoints can be mocked
+};
diff --git a/package-lock.json b/package-lock.json
index ef69bf1d93..30f8a83620 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,8 @@
"@radiantearth/stac-migrate": "~2.0.2",
"@turf/mask": "^7.2.0",
"@vuepic/vue-datepicker": "^12.1.0",
+ "ag-grid-community": "^35.3.1",
+ "ag-grid-vue3": "^35.3.1",
"ajv-i18n": "^4.2.0",
"axios": "^1.2.0",
"bootstrap": "^5.3.8",
@@ -1415,17 +1417,6 @@
"node": ">=18"
}
},
- "node_modules/@popperjs/core": {
- "version": "2.11.8",
- "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
- "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
- "license": "MIT",
- "peer": true,
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/popperjs"
- }
- },
"node_modules/@radiantearth/stac-fields": {
"version": "1.5.7",
"resolved": "https://registry.npmjs.org/@radiantearth/stac-fields/-/stac-fields-1.5.7.tgz",
@@ -2052,6 +2043,7 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@types/rbush": {
@@ -2325,6 +2317,33 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/ag-charts-types": {
+ "version": "13.3.1",
+ "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.3.1.tgz",
+ "integrity": "sha512-m37eUe4g//swvGh8PWMlYQH3gknjSsPeW4sQwBhM3/auGAHmN0wvxEWlrdAn98pBPg4HoKbtOy+yf7gPCYsnvQ==",
+ "license": "MIT"
+ },
+ "node_modules/ag-grid-community": {
+ "version": "35.3.1",
+ "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.3.1.tgz",
+ "integrity": "sha512-Wmkd/xYTCbAApcZZO74nBggZCnd/oX753NY3MJ4zYARVpfBHRWWhIBJ2sFTFKbDWwHZOWsx3YT9+kkyeuyLKlw==",
+ "license": "MIT",
+ "dependencies": {
+ "ag-charts-types": "13.3.1"
+ }
+ },
+ "node_modules/ag-grid-vue3": {
+ "version": "35.3.1",
+ "resolved": "https://registry.npmjs.org/ag-grid-vue3/-/ag-grid-vue3-35.3.1.tgz",
+ "integrity": "sha512-zbx6k2WIeIBhzgbiHjZbPaA2y7n1qOzK81nrdwlAhkRnIU3LL6nu1ZGpvShpyYyYKr6OQlwLl/q0IYuTmxJEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "ag-grid-community": "35.3.1"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.32"
+ }
+ },
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -7058,31 +7077,6 @@
"vue": "^3.0.0-0 || ^2.7.0"
}
},
- "node_modules/vue-eslint-parser": {
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",
- "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "debug": "^4.4.0",
- "eslint-scope": "^8.2.0",
- "eslint-visitor-keys": "^4.2.0",
- "espree": "^10.3.0",
- "esquery": "^1.6.0",
- "semver": "^7.6.3"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/mysticatea"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0"
- }
- },
"node_modules/vue-i18n": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
diff --git a/package.json b/package.json
index 106b1b2cad..b46c9ef074 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,9 @@
"main": "src/main.js",
"scripts": {
"start": "vite",
+ "start:test": "SB_CONFIG=./config.test.js vite",
"build": "vite build",
+ "build:test": "SB_CONFIG=./config.test.js vite build",
"build:preview": "vite build && vite preview",
"build:report": "vite build --mode report",
"build:minimal": "vite build --mode minimal",
@@ -50,6 +52,8 @@
"@radiantearth/stac-migrate": "~2.0.2",
"@turf/mask": "^7.2.0",
"@vuepic/vue-datepicker": "^12.1.0",
+ "ag-grid-community": "^35.3.1",
+ "ag-grid-vue3": "^35.3.1",
"ajv-i18n": "^4.2.0",
"axios": "^1.2.0",
"bootstrap": "^5.3.8",
diff --git a/playwright.config.js b/playwright.config.js
index e63dc473cd..928d5adb0c 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -66,18 +66,19 @@ export default defineConfig({
],
/* Run your local dev server before starting the tests */
- webServer: process.env.CI
+ webServer: process.env.CI
? {
- // In CI: Build and serve the production build
- command: 'npm run build && npx vite preview --port 4173 --strictPort',
+ // In CI: Build and serve the production build using test config
+ command: 'npm run build:test && npx vite preview --port 4173 --strictPort',
url: 'http://localhost:4173',
reuseExistingServer: false,
timeout: 120 * 1000,
}
: {
- command: 'npm start',
+ // Local dev: Use test config (no albedo1 references)
+ command: 'npm run start:test',
url: 'http://localhost:8080',
- reuseExistingServer: true,
+ reuseExistingServer: false, // Always start fresh with test config
timeout: 120 * 1000,
},
});
diff --git a/src/StacBrowser.vue b/src/StacBrowser.vue
index a48cf2db45..ccedfbfe96 100644
--- a/src/StacBrowser.vue
+++ b/src/StacBrowser.vue
@@ -15,6 +15,12 @@
{{ $t('search.title') }}
+
+ My Collections
+
+
+ Compute
+
@@ -80,6 +86,9 @@
+
+
+
import('bootstrap-vue-next').then(m => m.BPopover)),
+ CompareButton: defineAsyncComponent(() => import('./components/CompareButton.vue')),
+ CollectionComparison: defineAsyncComponent(() => import('./components/CollectionComparison.vue')),
ErrorAlert,
LanguageChooser: defineAsyncComponent(() => import('./components/LanguageChooser.vue')),
RootStats: defineAsyncComponent(() => import('./components/RootStats.vue')),
@@ -181,6 +196,12 @@ export default defineComponent({
isSearchPage() {
return this.$route.name === 'search';
},
+ isPersonalCollectionsPage() {
+ return this.$route.name === 'personal-collections';
+ },
+ isDaskDashboardPage() {
+ return this.$route.name === 'dask-dashboard';
+ },
isServerSelector() {
return this.$route.name === 'select';
},
diff --git a/src/components/AddToCollection.vue b/src/components/AddToCollection.vue
new file mode 100644
index 0000000000..17a08e2d0c
--- /dev/null
+++ b/src/components/AddToCollection.vue
@@ -0,0 +1,197 @@
+
+
+
+ ★
+ Add to Collection
+
+
+
+
+
+ Loading...
+
+
Loading collections...
+
+
+
+ Failed to load collections: {{ collectionsError }}
+
+
+
+ No personal collections found. Create one first.
+
+
+
+
+
+
+
+ Adding item: {{ itemId.startsWith('/') ? itemId.split('/').pop() : itemId }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Catalog.vue b/src/components/Catalog.vue
index f74a84fb84..2110e3c357 100644
--- a/src/components/Catalog.vue
+++ b/src/components/Catalog.vue
@@ -4,14 +4,55 @@
-
+
+
+
+
+
+ {{ comp }}
+
{{ $t('deprecated') }}
{{ format }}
{{ summarizeDescription }}
+
+
+
+ {{ paleoDisplay }}
+
+ CO2: {{ co2Display }}
+ {{ runLength }}
+
+ {{ resolution }}
+
+ {{ dataVolume }}
+
+
+
@@ -28,6 +69,7 @@ import FileFormatsMixin from './FileFormatsMixin';
import StacFieldsMixin from './StacFieldsMixin';
import CardMixin from './CardMixin';
import StacLink from './StacLink.vue';
+import GlossaryTooltip from './GlossaryTooltip.vue';
import { STAC } from 'stac-js';
import { formatTemporalExtent } from '@radiantearth/stac-fields/formatters';
import { BCard, BCardBody, BCardFooter, BCardImg, BCardText, BCardTitle } from 'bootstrap-vue-next';
@@ -42,7 +84,9 @@ export default {
BCardText,
BCardTitle,
StacLink,
- Keywords: defineAsyncComponent(() => import('./Keywords.vue'))
+ GlossaryTooltip,
+ Keywords: defineAsyncComponent(() => import('./Keywords.vue')),
+ AddToCollection: defineAsyncComponent(() => import('./AddToCollection.vue'))
},
mixins: [
FileFormatsMixin,
@@ -82,6 +126,182 @@ export default {
}
}
return null;
+ },
+ // Extract model components from collection metadata.
+ // Option A layout: collapsed collection has a "components" list field
+ // (e.g. ["echam","fesom","jsbach","oasis3mct"]).
+ // Legacy layout: single "model" string field per per-component collection.
+ modelComponents() {
+ if (!this.data?.isCollection()) {
+ return [];
+ }
+ // Option A: "components" list (preferred)
+ const components = this.data.components;
+ if (Array.isArray(components) && components.length > 0) {
+ return components;
+ }
+ // Legacy: single "model" string
+ const model = this.data.model || this.data.properties?.model;
+ if (model) {
+ return Array.isArray(model) ? model : [model];
+ }
+ // Fallback: parse known component names from the collection ID
+ const id = this.data.id || '';
+ const knownComponents = ['echam', 'fesom', 'jsbach', 'hdmodel', 'oasis', 'recom', 'pism'];
+ const found = knownComponents.filter(comp =>
+ id.toLowerCase().includes(comp) ||
+ (this.data.title || '').toLowerCase().includes(comp)
+ );
+ return found.map(c => c.toUpperCase());
+ },
+ // CO2 concentration from namelist parameters, displayed in ppm
+ co2Display() {
+ if (!this.data?.isCollection()) {
+ return null;
+ }
+ // Look for CO2 VMR in namelist properties (nml:radctl:co2vmr)
+ const props = this.data.properties || this.data;
+ for (const key of Object.keys(props)) {
+ if (key.toLowerCase().includes('co2vmr') || key.toLowerCase().includes('co2_vmr')) {
+ const val = props[key];
+ if (typeof val === 'number' && val > 0) {
+ // Convert from decimal (volume mixing ratio) to ppm
+ const ppm = val < 0.01 ? val * 1e6 : val;
+ return `${Math.round(ppm)} ppm`;
+ }
+ }
+ }
+ return null;
+ },
+ // Calculate run length from temporal extent
+ runLength() {
+ if (!this.data?.isCollection() || !this.data.extent?.temporal?.interval?.length) {
+ return null;
+ }
+ const interval = this.data.extent.temporal.interval[0];
+ if (!interval || !interval[0] || !interval[1]) {
+ return null;
+ }
+ try {
+ const start = new Date(interval[0]);
+ const end = new Date(interval[1]);
+ const years = Math.round((end - start) / (365.25 * 24 * 60 * 60 * 1000));
+ if (years > 0) {
+ return `${years} year${years !== 1 ? 's' : ''}`;
+ }
+ } catch {
+ // Invalid dates
+ }
+ return null;
+ },
+ // Resolution information from metadata
+ resolution() {
+ if (!this.data?.isCollection()) {
+ return null;
+ }
+ const props = this.data.properties || this.data;
+ // Look for resolution fields
+ const atmosRes = props['resolution:atmosphere'] || props['atmos_resolution'];
+ const oceanRes = props['resolution:ocean'] || props['ocean_resolution'] || props['fesom:mesh_path'];
+ if (atmosRes && oceanRes) {
+ return `${atmosRes}/${oceanRes}`;
+ }
+ return atmosRes || oceanRes || null;
+ },
+ // Paleo time display (if applicable)
+ paleoDisplay() {
+ if (!this.data?.isCollection()) {
+ return null;
+ }
+ const props = this.data.properties || this.data;
+ // Check for paleo:display field or similar
+ const paleoProp = props['paleo:display'] || props['paleo_time'] || props['paleodatetime'];
+ if (paleoProp) {
+ return paleoProp;
+ }
+ // Check if temporal extent is in deep time (before 1 CE)
+ const interval = this.data.extent?.temporal?.interval?.[0];
+ if (interval && interval[0]) {
+ try {
+ const startYear = new Date(interval[0]).getFullYear();
+ if (startYear < 0) {
+ // Display as BCE
+ return `${Math.abs(startYear)} BCE`;
+ }
+ } catch {
+ // Not a valid date
+ }
+ }
+ return null;
+ },
+ // Check if we have any quick facts to display
+ hasQuickFacts() {
+ return this.co2Display || this.runLength || this.resolution || this.paleoDisplay || this.dataVolume;
+ },
+ // Data volume display (total size of collection)
+ dataVolume() {
+ if (!this.data?.isCollection()) {
+ return null;
+ }
+ const props = this.data.properties || this.data;
+ // Look for file size properties
+ const totalBytes = props['catalog:total_bytes'] || props['file:size'] || props['total_size'];
+ if (!totalBytes || totalBytes <= 0) {
+ return null;
+ }
+ // Format as human-readable size
+ const gb = totalBytes / (1024 ** 3);
+ if (gb >= 1000) {
+ return `${(gb / 1024).toFixed(1)} TB`;
+ }
+ if (gb >= 1) {
+ return `${gb.toFixed(1)} GB`;
+ }
+ const mb = totalBytes / (1024 ** 2);
+ return `${mb.toFixed(0)} MB`;
+ },
+ // DOI from scientific citation extension
+ doi() {
+ if (!this.data?.isCollection()) {
+ return null;
+ }
+ const props = this.data.properties || this.data;
+ return props['sci:doi'] || props['doi'] || null;
+ },
+ // DOI URL for linking
+ doiUrl() {
+ if (!this.doi) return null;
+ // Handle both bare DOI and full URL
+ if (this.doi.startsWith('http')) {
+ return this.doi;
+ }
+ return `https://doi.org/${this.doi}`;
+ },
+ // Generate citation text
+ citationText() {
+ if (!this.data?.isCollection()) {
+ return '';
+ }
+ const title = this.data.title || this.data.id;
+ const year = this.extractYear();
+ const doi = this.doi ? ` DOI: ${this.doi}` : '';
+ return `${title} (${year}).${doi}`;
+ },
+ // Browser path for Add to Collection (e.g. /experiments/basic-001)
+ collectionId() {
+ return this.data?.getBrowserPath?.() || null;
+ },
+ // STAC ID used for comparison (plain ID like "basic-002", not a browser path)
+ stacId() {
+ return this.data?.isCollection() ? (this.data.id || null) : null;
+ },
+ // Can this collection be compared?
+ canCompare() {
+ return !!this.stacId;
+ },
+ // Is this collection selected for comparison?
+ isSelectedForComparison() {
+ return this.$store.getters['comparison/isSelected'](this.stacId);
}
},
methods: {
@@ -90,7 +310,125 @@ export default {
return;
}
this.$store.commit(visible ? 'queue' : 'unqueue', this.catalog.getAbsoluteUrl());
+ },
+ // Return Bootstrap variant for component badge color coding
+ componentVariant(component) {
+ const comp = component.toLowerCase();
+ // Color coding: blue=ocean, warning=atmosphere, success=land, secondary=other
+ if (['fesom', 'fesom2', 'mpiom', 'nemo'].includes(comp)) {
+ return 'primary'; // Blue for ocean
+ }
+ if (['echam', 'echam6', 'icon-a', 'ifs'].includes(comp)) {
+ return 'warning'; // Orange/yellow for atmosphere
+ }
+ if (['jsbach', 'clm', 'lpjguess'].includes(comp)) {
+ return 'success'; // Green for land/vegetation
+ }
+ if (['hdmodel', 'hd'].includes(comp)) {
+ return 'info'; // Cyan for hydrology
+ }
+ if (['oasis', 'oasis3mct'].includes(comp)) {
+ return 'dark'; // Dark for coupler
+ }
+ if (['recom', 'hamocc', 'pisces'].includes(comp)) {
+ return 'danger'; // Red for biogeochemistry
+ }
+ return 'secondary'; // Default gray
+ },
+ // Extract year from temporal extent for citation
+ extractYear() {
+ if (!this.data?.extent?.temporal?.interval?.length) {
+ return 'n.d.';
+ }
+ const interval = this.data.extent.temporal.interval[0];
+ if (interval && interval[0]) {
+ try {
+ return new Date(interval[0]).getFullYear();
+ } catch {
+ return 'n.d.';
+ }
+ }
+ return 'n.d.';
+ },
+ // Copy citation to clipboard
+ async copyCitation() {
+ try {
+ await navigator.clipboard.writeText(this.citationText);
+ // Could add toast notification here
+ } catch (err) {
+ console.warn('Failed to copy citation:', err);
+ }
+ },
+ // Toggle comparison selection
+ toggleComparison() {
+ if (this.stacId) {
+ this.$store.dispatch('comparison/toggleCollection', this.stacId);
+ }
}
}
};
+
+
diff --git a/src/components/CollectionComparison.vue b/src/components/CollectionComparison.vue
new file mode 100644
index 0000000000..416a9bbc04
--- /dev/null
+++ b/src/components/CollectionComparison.vue
@@ -0,0 +1,320 @@
+
+
+
+
+
+
+
+ No collections selected for comparison.
+
+
+
+
+
+
+
+
+
+
+ Show only differences
+
+
+
+
+
+
+
+
+
+
+
+ Comparing {{ collections[0].title || collections[0].id }}
+ vs {{ collections[1].title || collections[1].id }}
+ -- side-by-side spatial plots with difference view
+
+
+
+ Loading visual comparison...
+
+
+
+ Select at least 2 collections to use visual comparison.
+
+
+
+
+
+ Cancel
+ OK
+
+
+
+
+
+
+
+
+
diff --git a/src/components/CompareButton.vue b/src/components/CompareButton.vue
new file mode 100644
index 0000000000..8804cab9aa
--- /dev/null
+++ b/src/components/CompareButton.vue
@@ -0,0 +1,42 @@
+
+
+
+ Compare ({{ selectionCount }})
+
+
+ Clear
+
+
+
+
+
+
+
diff --git a/src/components/DaskDashboard.vue b/src/components/DaskDashboard.vue
new file mode 100644
index 0000000000..02cf6b8909
--- /dev/null
+++ b/src/components/DaskDashboard.vue
@@ -0,0 +1,246 @@
+
+
+
+
+ Dask Compute Clusters
+
+ Updated {{ lastRefreshLabel }}
+
+
+ Refresh
+
+ + New Cluster
+
+
+
+
+ No visualization server configured.
+
+
+
+
+ Failed to load clusters: {{ error }}
+
+
+ No active clusters. Create one to enable distributed compute.
+
+
+
+
+
+
+ Name / ID
+ Type
+ Workers
+ Status
+ Dashboard
+ Scale
+
+
+
+
+
+
+ {{ cluster.name || cluster.id }}
+ {{ cluster.id }}
+
+ {{ cluster.type || 'local' }}
+ {{ cluster.workers ?? '—' }}
+ {{ cluster.status || 'unknown' }}
+
+ Open
+ —
+
+
+
+
+ Scale
+
+ n/a
+
+
+ ✕
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ createError }}
+
+
+
+
+ Shut down {{ deleteTarget?.name || deleteTarget?.id }} ?
+
+
+
+
+
+
+
diff --git a/src/components/DataPreview.vue b/src/components/DataPreview.vue
new file mode 100644
index 0000000000..f7b0b243c4
--- /dev/null
+++ b/src/components/DataPreview.vue
@@ -0,0 +1,304 @@
+
+
+
+
+
+ Interactive
+
+
+ Static
+
+
+
+
+
+
+ Loading interactive preview...
+
+
+
+
+
+
+
+
+ Loading preview...
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+
+
+ {{ v.long_name || v.name }} {{ v.units ? `(${v.units})` : '' }}
+
+
+
+
+ t={{ selectedTime }}
+
+
+ Viridis
+ Plasma
+ Coolwarm
+ Red-Blue
+
+
+
+ Refresh
+
+
+
+
+
+
+
+
+
+
+ Dimensions:
+
+ {{ dim }}={{ size }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/GlossaryTooltip.vue b/src/components/GlossaryTooltip.vue
new file mode 100644
index 0000000000..613db0e35d
--- /dev/null
+++ b/src/components/GlossaryTooltip.vue
@@ -0,0 +1,71 @@
+
+
+ {{ term }}
+ ?
+
+
+ {{ term }}
+
+
+
+
+
+
diff --git a/src/components/HrefActions.vue b/src/components/HrefActions.vue
index 302cf15357..5087cc9a3f 100644
--- a/src/components/HrefActions.vue
+++ b/src/components/HrefActions.vue
@@ -273,8 +273,17 @@ export default {
return this.$t('protocol.ftp');
case 'oss':
return this.$t('protocol.oss');
- case 'file':
+ case 'file': {
+ // Prefer explicit hpc:system on the asset (set by scanner for new items)
+ if (this.data['hpc:system']) {
+ return this.data['hpc:system'];
+ }
+ // Fallback: derive from known HPC path conventions for existing catalog items
+ const href = this.href || '';
+ if (href.includes('/albedo/')) return 'albedo';
+ if (href.includes('/work/') || href.includes('/scratch/')) return 'levante';
return this.$t('protocol.file');
+ }
}
return '';
},
diff --git a/src/components/Item.vue b/src/components/Item.vue
index 3505d60ce0..237671432c 100644
--- a/src/components/Item.vue
+++ b/src/components/Item.vue
@@ -7,7 +7,8 @@
-
+
+ {{ collectionId }}
{{ $t('deprecated') }}
{{ format }}
{{ summarizeDescription }}
@@ -63,6 +64,9 @@ export default defineComponent({
description: this.hasDescription
};
},
+ collectionId() {
+ return this.data && this.data.collection ? this.data.collection : null;
+ },
extent() {
if (this.data && (this.data.properties.start_datetime || this.data.properties.end_datetime)) {
return [this.data.properties.start_datetime, this.data.properties.end_datetime];
diff --git a/src/components/MetadataGroups.vue b/src/components/MetadataGroups.vue
index 8e3082fefa..1435c7bf01 100644
--- a/src/components/MetadataGroups.vue
+++ b/src/components/MetadataGroups.vue
@@ -1,18 +1,50 @@
@@ -146,6 +299,40 @@ export default {
#stac-browser {
.metadata {
+ .metadata-header {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+
+ .field-count {
+ font-size: 0.7em;
+ font-weight: normal;
+ color: #6c757d;
+ }
+ }
+
+ .metadata-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+
+ .search-container {
+ flex: 1;
+ min-width: 200px;
+ max-width: 400px;
+
+ .metadata-search {
+ width: 100%;
+ }
+ }
+
+ .collapse-controls {
+ display: flex;
+ gap: 0.5rem;
+ }
+ }
+
.card {
border: 0;
margin-top: $block-margin;
diff --git a/src/components/PersonalCollections.vue b/src/components/PersonalCollections.vue
new file mode 100644
index 0000000000..51c4cf8dfe
--- /dev/null
+++ b/src/components/PersonalCollections.vue
@@ -0,0 +1,848 @@
+
+
+
+
+ Personal Collections
+
+
+ + New Collection
+
+
+ + New Folder
+
+
+ Labels
+
+
+
+
+
+ {{ notification.message }}
+
+
+
+
+
+ Failed to load collections: {{ error }}
+
+
+
+ No collections yet. Create one to get started.
+
+
+
+
+
+
+ Tip: drag collections into folders to organise them.
+
+
+
+
+
+
+ {{ collectionModalError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ folderModalError }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ editModalError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Are you sure you want to delete {{ deleteTarget?.name }} ?
+
+ Deleting a folder will also remove all collections inside it.
+
+
+
+
+
+
+ {{ shareModalError }}
+
+
+
+
+
+
+
+
+
Currently shared with:
+
+ {{ share.username }}
+ {{ share.role }}
+
+ ✕
+
+
+
+
+ How sharing works: access is granted immediately — no email or notification
+ is sent to the recipient. They can fetch the shared collection directly via the API:
+ {{ shareCollectionApiUrl }}
+ A "Shared with me" browser view is planned for a future release.
+
+
+
+
+
+
+ {{ addItemsModalError }}
+
+
+ Enter STAC item IDs (one per line) to add to {{ addItemsTarget?.name }} .
+
+
+
+
+
+
+
+
+
+ {{ viewError }}
+
+ This collection has no items yet. Use the + button to add items.
+
+
+
+ Click an item to open it in the catalog browser.
+
+
+
+ {{ itemId.startsWith('/') ? itemId.split('/').pop() : itemId }}
+
+
+
+
+
+
+
+
+ {{ labelsModalError }}
+
+
+ {{ label.name }}
+
+ ✕
+
+
+
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/QueryableInput.vue b/src/components/QueryableInput.vue
index 8e0df1ea80..37ac82b9d5 100644
--- a/src/components/QueryableInput.vue
+++ b/src/components/QueryableInput.vue
@@ -91,6 +91,23 @@
:model-value="value.value"
@update:model-value="updateValue($event)"
/>
+
+
+
+
+
+
this.castArrayItem(v)));
}
+ },
+ // Check if this queryable has GHG unit metadata
+ hasGhgUnits() {
+ return this.schema && this.schema['x-ghg-units'];
+ },
+ // Get available unit options for GHG fields
+ ghgUnitOptions() {
+ if (!this.hasGhgUnits) return [];
+ const units = this.schema['x-ghg-units'].supported || ['decimal'];
+ return units.map(u => ({
+ value: u,
+ text: u.toUpperCase()
+ }));
+ },
+ // Conversion factors for GHG units
+ ghgConversionFactors() {
+ return {
+ 'decimal': 1.0,
+ 'ppm': 1e-6,
+ 'ppb': 1e-9
+ };
+ },
+ // Display value converted to selected unit
+ displayValue() {
+ if (!this.hasGhgUnits || !this.selectedUnit) {
+ return this.value?.value;
+ }
+ // Internal value is in decimal, convert to display unit
+ const internalValue = this.value?.value;
+ if (internalValue == null || internalValue === '') return '';
+ const factor = this.ghgConversionFactors[this.selectedUnit] || 1.0;
+ // Display value = internal value / factor
+ return factor === 1.0 ? internalValue : (internalValue / factor);
+ }
+ },
+ watch: {
+ // When unit changes, we need to reconvert the value
+ selectedUnit(newUnit, oldUnit) {
+ if (!this.hasGhgUnits || !oldUnit || !newUnit || oldUnit === newUnit) return;
+ // The internal value stays the same (in decimal)
+ // The display just changes based on selected unit
+ // No update needed since displayValue is computed
}
},
methods: {
@@ -322,6 +389,22 @@ export default {
},
getEventValue(event) {
return isObject(event) && 'target' in event ? event.target.value : event;
+ },
+ // Update value for GHG fields, converting from display unit to decimal
+ updateGhgValue(evt) {
+ let displayVal = this.getEventValue(evt);
+ if (displayVal === '' || displayVal == null) {
+ this.emitValue(CqlValue.create(null));
+ return;
+ }
+ displayVal = parseFloat(displayVal);
+ if (isNaN(displayVal)) {
+ return;
+ }
+ // Convert from display unit to internal decimal value
+ const factor = this.ghgConversionFactors[this.selectedUnit] || 1.0;
+ const internalVal = displayVal * factor;
+ this.emitValue(CqlValue.create(internalVal));
}
}
};
@@ -348,6 +431,15 @@ export default {
display: flex;
gap: 0.2em;
}
+ .value.ghg-value {
+ flex-grow: 3;
+ width: 5rem !important;
+ }
+ .unit-selector {
+ flex-grow: 1;
+ width: 4rem !important;
+ min-width: 4rem;
+ }
.op {
min-width: 4rem;
width: auto;
diff --git a/src/components/SearchFilter.vue b/src/components/SearchFilter.vue
index fe9b2d69c9..22ab0f6516 100644
--- a/src/components/SearchFilter.vue
+++ b/src/components/SearchFilter.vue
@@ -43,9 +43,144 @@
/>
-
- {{ $t('search.filterBySpatialExtent') }}
-
+
+
+
+
+
+
+
+
Model Components
+
+
+
+
+
+
+
+
CO2 Level (ppm)
+
+
+
to
+
+
+ PI (284)
+ 2xCO2
+ 4xCO2
+
+
+
+
+
+
+
CH4 Level (ppb)
+
+
+ to
+
+
+
+
+
+
+
N2O Level (ppb)
+
+
+ to
+
+
+
+
+
+
+
Paleo Time Period
+
+ {{ preset.name }}
+ Clear
+
+
+
+
+
+
Experiment Type
+
+
+
+
+
+
+
+
Output Frequency
+
+
+
+
@@ -86,13 +221,55 @@
{{ $t('search.logical.not') }}
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ queryable.title }}
+ {{ queryable.shortId || queryable.id }}
+
+
+
+
+
+
+
{{ queryable.title }}
{{ queryable.id }}
+
+
+
+ {{ $t('search.noMatchingProperties') || 'No matching properties' }}
+
+
+
+
+
Active filters ({{ filtersAndOr.toUpperCase() }}):
+
+
+ {{ filtersAndOr.toUpperCase() }} {{ line }}
+
+
+
+
{{ $t('submit') }}
{{ $t('reset') }}
@@ -165,7 +353,10 @@ import { CollectionCollection, STAC } from 'stac-js';
import { createSTAC, Collection } from '../models/stac';
import Cql from '../models/cql2/cql';
import Queryable from '../models/cql2/queryable';
-import CqlLogicalOperator, { CqlNot } from '../models/cql2/operators/logical';
+import CqlLogicalOperator, { CqlNot, CqlAnd } from '../models/cql2/operators/logical';
+import { CqlGreaterThanEqual, CqlLessThanEqual, CqlLike } from '../models/cql2/operators/comparison';
+import { CqlIn } from '../models/cql2/operators/array';
+import CqlValue from '../models/cql2/value';
import { stacRequest } from '../store/utils';
function getQueryDefaults() {
@@ -192,7 +383,18 @@ function getDefaults() {
filtersAndOr: 'and',
filtersNegate: false,
filters: [],
- selectedCollections: []
+ selectedCollections: [],
+ // Quick filter defaults (for reset)
+ selectedComponents: [],
+ co2Min: null,
+ co2Max: null,
+ ch4Min: null,
+ ch4Max: null,
+ n2oMin: null,
+ n2oMax: null,
+ selectedExperimentTypes: [],
+ selectedFrequencies: [],
+ selectedPaleoPreset: null
};
}
@@ -244,7 +446,27 @@ export default defineComponent({
hasAllCollections: false,
collections: [],
collectionsLoadingTimer: null,
- additionalCollectionCount: 0
+ additionalCollectionCount: 0,
+ // Queryable search and grouping
+ queryableSearchTerm: '',
+ expandedQueryableGroups: {},
+ // Quick filters for climate scientists
+ selectedComponents: [],
+ co2Min: null,
+ co2Max: null,
+ ch4Min: null,
+ ch4Max: null,
+ n2oMin: null,
+ n2oMax: null,
+ selectedExperimentTypes: [],
+ selectedFrequencies: [],
+ paleoPresets: [
+ { id: 'lgm', name: 'LGM', display: '21.0 ka', years_bp: 21000, description: 'Last Glacial Maximum' },
+ { id: 'mid_holocene', name: 'Mid-Holocene', display: '6.0 ka', years_bp: 6000, description: 'Mid-Holocene warm period' },
+ { id: 'eemian', name: 'Eemian', display: '125.0 ka', years_bp: 125000, description: 'Last Interglacial' },
+ { id: 'preindustrial', name: 'PI', display: '1850 CE', years_bp: 100, description: 'Pre-industrial' }
+ ],
+ selectedPaleoPreset: null
}, getDefaults());
},
computed: {
@@ -321,6 +543,168 @@ export default defineComponent({
const collator = new Intl.Collator(this.uiLanguage);
return this.queryables.slice(0).sort((a, b) => collator.compare(a.title, b.title));
},
+ // Filter queryables by search term (fuzzy-ish substring matching)
+ filteredQueryables() {
+ if (!this.queryableSearchTerm) {
+ return this.sortedQueryables;
+ }
+ const term = this.queryableSearchTerm.toLowerCase();
+ return this.sortedQueryables.filter(q => {
+ // Match against title, id, or any part of the property name
+ return (
+ q.title.toLowerCase().includes(term) ||
+ q.id.toLowerCase().includes(term)
+ );
+ });
+ },
+ // Check if we have namelist groups to display
+ hasNamelistGroups() {
+ return this.filteredQueryables.some(q => q.id.startsWith('nml:'));
+ },
+ // Group namelist parameters by their group name (e.g., nml:runctl:*, nml:radctl:*)
+ queryableGroups() {
+ const groups = {};
+ const collator = new Intl.Collator(this.uiLanguage);
+
+ this.filteredQueryables.forEach(q => {
+ if (!q.supported) return;
+
+ // Parse namelist properties: nml:{group}:{param} or nml:{file}:{group}:{param}
+ if (q.id.startsWith('nml:')) {
+ const parts = q.id.split(':');
+ let groupName, shortId;
+
+ if (parts.length >= 3) {
+ // nml:radctl:co2vmr -> group=radctl, shortId=co2vmr
+ // nml:namelist.echam:radctl:co2vmr -> group=namelist.echam:radctl, shortId=co2vmr
+ groupName = parts.slice(1, -1).join(':');
+ shortId = parts[parts.length - 1];
+ } else {
+ // nml:groups or similar
+ groupName = 'general';
+ shortId = parts.slice(1).join(':');
+ }
+
+ if (!groups[groupName]) {
+ groups[groupName] = {
+ name: groupName,
+ title: this.formatGroupTitle(groupName),
+ queryables: []
+ };
+ }
+ // Add shortId for cleaner display in dropdown.
+ // Use Object.create(proto) to preserve the Queryable prototype chain so that
+ // methods like getOperators() remain callable when the chip is clicked.
+ const qWithShortId = Object.assign(Object.create(Object.getPrototypeOf(q)), q, { shortId });
+ groups[groupName].queryables.push(qWithShortId);
+ }
+ });
+
+ // Sort queryables within each group
+ Object.values(groups).forEach(group => {
+ group.queryables.sort((a, b) => collator.compare(a.title, b.title));
+ });
+
+ // Return sorted groups
+ return Object.values(groups).sort((a, b) => collator.compare(a.title, b.title));
+ },
+ // Queryables that are NOT in a group (non-namelist properties)
+ filteredUngroupedQueryables() {
+ return this.filteredQueryables.filter(q => {
+ if (!q.supported) return false;
+ // Exclude namelist properties (they're shown in groups)
+ if (q.id.startsWith('nml:') && this.hasNamelistGroups) return false;
+ return true;
+ });
+ },
+ // Check if filtered results are empty
+ filteredQueryablesEmpty() {
+ if (!this.queryableSearchTerm) return false;
+ return this.filteredUngroupedQueryables.length === 0 &&
+ this.queryableGroups.every(g => g.queryables.length === 0);
+ },
+ // Quick filters visibility (show when we have queryables or collections)
+ showQuickFilters() {
+ return this.type === 'Collections' || (this.cql && this.queryables && this.queryables.length > 0);
+ },
+ // Available model components for quick filter
+ availableComponents() {
+ // Common ESM model components
+ return [
+ { text: 'ECHAM', value: 'echam' },
+ { text: 'FESOM', value: 'fesom' },
+ { text: 'JSBACH', value: 'jsbach' },
+ { text: 'HDMODEL', value: 'hdmodel' },
+ { text: 'OASIS3MCT', value: 'oasis3mct' },
+ { text: 'RECOM', value: 'recom' }
+ ];
+ },
+ // Live preview of active filter conditions (one CQL2-text line per condition)
+ filterPreviewLines() {
+ const parts = [];
+
+ if (this.selectedComponents.length > 0) {
+ parts.push(`component IN (${this.selectedComponents.map(c => `'${c}'`).join(', ')})`);
+ }
+
+ if (this.co2Min !== null) parts.push(`CO2 ≥ ${this.co2Min} ppm`);
+ if (this.co2Max !== null) parts.push(`CO2 ≤ ${this.co2Max} ppm`);
+
+ if (this.ch4Min !== null) parts.push(`CH4 ≥ ${this.ch4Min} ppb`);
+ if (this.ch4Max !== null) parts.push(`CH4 ≤ ${this.ch4Max} ppb`);
+
+ if (this.n2oMin !== null) parts.push(`N2O ≥ ${this.n2oMin} ppb`);
+ if (this.n2oMax !== null) parts.push(`N2O ≤ ${this.n2oMax} ppb`);
+
+ if (this.selectedFrequencies.length > 0) {
+ parts.push(`frequency IN (${this.selectedFrequencies.map(f => `'${f}'`).join(', ')})`);
+ }
+
+ if (this.selectedExperimentTypes.length > 0) {
+ parts.push(`experiment_type IN (${this.selectedExperimentTypes.map(e => `'${e}'`).join(', ')})`);
+ }
+
+ if (this.selectedPaleoPreset) {
+ const preset = this.paleoPresets.find(p => p.id === this.selectedPaleoPreset);
+ if (preset) parts.push(`paleo:years_bp ≈ ${preset.years_bp} (${preset.name})`);
+ }
+
+ for (const f of this.filters) {
+ try {
+ let filter = new f.operator(f.queryable, f.value);
+ let text = filter.toText();
+ if (f.negate) text = `NOT (${text})`;
+ parts.push(text);
+ } catch (e) {
+ // skip filters that can't be serialised yet
+ }
+ }
+
+ return parts;
+ },
+
+ // Experiment type options
+ experimentTypeOptions() {
+ return [
+ { value: 'control', text: 'Control' },
+ { value: 'historical', text: 'Historical' },
+ { value: 'scenario', text: 'Scenario' },
+ { value: 'paleo', text: 'Paleo' },
+ { value: 'sensitivity', text: 'Sensitivity' },
+ { value: 'spinup', text: 'Spin-up' }
+ ];
+ },
+ // Output frequency options
+ frequencyOptions() {
+ return [
+ { value: 'mon', text: 'Monthly' },
+ { value: 'day', text: 'Daily' },
+ { value: '6hr', text: '6-hourly' },
+ { value: '3hr', text: '3-hourly' },
+ { value: '1hr', text: 'Hourly' },
+ { value: 'subhr', text: 'Sub-hourly' }
+ ];
+ },
maxItems() {
return this.maxEntriesPerPage || 1000;
},
@@ -418,6 +802,11 @@ export default defineComponent({
.catch(error => console.error(error))
);
}
+ // Load paleo presets for quick filters
+ promises.push(
+ this.loadPaleoPresets()
+ .catch(error => console.warn('Failed to load paleo presets:', error))
+ );
Promise.all(promises).finally(() => this.loaded = true);
},
methods: {
@@ -580,6 +969,37 @@ export default defineComponent({
this.query.sortby = this.formatSort();
}
let filters = this.buildFilter();
+
+ // Get quick filters and convert to CQL format
+ const quickFilters = this.buildQuickFilters();
+ if (quickFilters.length > 0) {
+ const quickCqlArgs = quickFilters.map(qf => {
+ // Create a queryable-like object with required methods
+ const queryable = new Queryable(qf.field, { type: 'number' });
+
+ if (qf.op === '>=') {
+ return new CqlGreaterThanEqual(queryable, CqlValue.create(qf.value));
+ } else if (qf.op === '<=') {
+ return new CqlLessThanEqual(queryable, CqlValue.create(qf.value));
+ } else if (qf.op === 'like') {
+ return new CqlLike(queryable, CqlValue.create(qf.value));
+ } else if (qf.op === 'in' && qf.values) {
+ return new CqlIn(queryable, CqlValue.create(qf.values));
+ }
+ return null;
+ }).filter(f => f !== null);
+
+ if (quickCqlArgs.length > 0) {
+ const quickCql = new CqlAnd(quickCqlArgs);
+ if (filters) {
+ // Combine manual filters with quick filters using AND
+ filters = new Cql(new CqlAnd([filters.filters, quickCql]));
+ } else {
+ filters = new Cql(quickCql);
+ }
+ }
+ }
+
this.query.filters = filters;
this.$emit('input', this.query, false);
},
@@ -624,6 +1044,192 @@ export default defineComponent({
else {
return null;
}
+ },
+ // Toggle a queryable group's expanded state
+ toggleQueryableGroup(groupName) {
+ this.expandedQueryableGroups[groupName] = !this.expandedQueryableGroups[groupName];
+ },
+ // Format a group name into a human-readable title
+ formatGroupTitle(groupName) {
+ // Handle namelist file:group format like "namelist.echam:radctl"
+ if (groupName.includes(':')) {
+ const parts = groupName.split(':');
+ const file = parts[0].replace('namelist.', '');
+ const group = parts.slice(1).join(':');
+ return `${file.toUpperCase()} / ${group}`;
+ }
+ // Simple group name like "radctl"
+ return groupName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
+ },
+ // Load paleo presets from API
+ async loadPaleoPresets() {
+ const fallbackPresets = [
+ { id: 'lgm', name: 'LGM', display: '21.0 ka', years_bp: 21000, description: 'Last Glacial Maximum' },
+ { id: 'mid_holocene', name: 'Mid-Holocene', display: '6.0 ka', years_bp: 6000, description: 'Mid-Holocene warm period' },
+ { id: 'eemian', name: 'Eemian', display: '125.0 ka', years_bp: 125000, description: 'Last Interglacial' },
+ { id: 'preindustrial', name: 'PI', display: '1850 CE', years_bp: 100, description: 'Pre-industrial' }
+ ];
+ try {
+ const apiUrl = this.stac?.getAbsoluteUrl?.() || '';
+ const baseUrl = apiUrl ? new URL(apiUrl).origin : '';
+ if (!baseUrl) {
+ this.paleoPresets = fallbackPresets;
+ return;
+ }
+
+ const response = await fetch(`${baseUrl}/paleo-presets`);
+ if (response.ok) {
+ const data = await response.json();
+ this.paleoPresets = data.presets || fallbackPresets;
+ } else {
+ this.paleoPresets = fallbackPresets;
+ }
+ } catch (error) {
+ console.warn('Could not load paleo presets:', error);
+ this.paleoPresets = fallbackPresets;
+ }
+ },
+ // Set CO2 preset values
+ setCO2Preset(preset) {
+ const presets = {
+ 'preindustrial': { min: 280, max: 290 },
+ '2xco2': { min: 550, max: 570 },
+ '4xco2': { min: 1100, max: 1140 }
+ };
+ const p = presets[preset];
+ if (p) {
+ this.co2Min = p.min;
+ this.co2Max = p.max;
+ }
+ },
+ // Select a paleo time preset
+ selectPaleoPreset(preset) {
+ this.selectedPaleoPreset = preset.id;
+ // Add filter for paleo time if we have the queryable
+ // This will be included in the buildFilter when submitting
+ },
+ // Clear paleo preset selection
+ clearPaleoPreset() {
+ this.selectedPaleoPreset = null;
+ },
+ // Build quick filters into CQL2 format
+ buildQuickFilters() {
+ const quickFilters = [];
+
+ // Model component filter (IN clause)
+ if (this.selectedComponents.length > 0) {
+ quickFilters.push({
+ field: 'component',
+ op: 'in',
+ values: this.selectedComponents
+ });
+ }
+
+ // CO2 range filter (converted from ppm to decimal)
+ if (this.co2Min !== null || this.co2Max !== null) {
+ // Look for CO2 VMR queryable
+ const co2Field = 'nml:echam:radctl:co2vmr';
+ if (this.co2Min !== null) {
+ // Convert ppm to decimal (volume mixing ratio)
+ const minDecimal = this.co2Min * 1e-6;
+ quickFilters.push({
+ field: co2Field,
+ op: '>=',
+ value: minDecimal
+ });
+ }
+ if (this.co2Max !== null) {
+ const maxDecimal = this.co2Max * 1e-6;
+ quickFilters.push({
+ field: co2Field,
+ op: '<=',
+ value: maxDecimal
+ });
+ }
+ }
+
+ // CH4 range filter (converted from ppb to decimal)
+ if (this.ch4Min !== null || this.ch4Max !== null) {
+ const ch4Field = 'nml:echam:radctl:ch4vmr';
+ if (this.ch4Min !== null) {
+ // Convert ppb to decimal (volume mixing ratio)
+ const minDecimal = this.ch4Min * 1e-9;
+ quickFilters.push({
+ field: ch4Field,
+ op: '>=',
+ value: minDecimal
+ });
+ }
+ if (this.ch4Max !== null) {
+ const maxDecimal = this.ch4Max * 1e-9;
+ quickFilters.push({
+ field: ch4Field,
+ op: '<=',
+ value: maxDecimal
+ });
+ }
+ }
+
+ // N2O range filter (converted from ppb to decimal)
+ if (this.n2oMin !== null || this.n2oMax !== null) {
+ const n2oField = 'nml:echam:radctl:n2ovmr';
+ if (this.n2oMin !== null) {
+ // Convert ppb to decimal (volume mixing ratio)
+ const minDecimal = this.n2oMin * 1e-9;
+ quickFilters.push({
+ field: n2oField,
+ op: '>=',
+ value: minDecimal
+ });
+ }
+ if (this.n2oMax !== null) {
+ const maxDecimal = this.n2oMax * 1e-9;
+ quickFilters.push({
+ field: n2oField,
+ op: '<=',
+ value: maxDecimal
+ });
+ }
+ }
+
+ // Output frequency filter
+ if (this.selectedFrequencies.length > 0) {
+ quickFilters.push({
+ field: 'output_frequency',
+ op: 'in',
+ values: this.selectedFrequencies
+ });
+ }
+
+ // Experiment type filter
+ if (this.selectedExperimentTypes.length > 0) {
+ quickFilters.push({
+ field: 'experiment_type',
+ op: 'in',
+ values: this.selectedExperimentTypes
+ });
+ }
+
+ // Paleo time filter (years before present)
+ if (this.selectedPaleoPreset) {
+ const preset = this.paleoPresets.find(p => p.id === this.selectedPaleoPreset);
+ if (preset && preset.years_bp !== undefined) {
+ // Filter by paleo time - allow some range around the target year
+ const tolerance = preset.years_bp * 0.1; // 10% tolerance
+ quickFilters.push({
+ field: 'paleo:years_bp',
+ op: '>=',
+ value: preset.years_bp - tolerance
+ });
+ quickFilters.push({
+ field: 'paleo:years_bp',
+ op: '<=',
+ value: preset.years_bp + tolerance
+ });
+ }
+ }
+
+ return quickFilters;
}
}
});
@@ -632,11 +1238,99 @@ export default defineComponent({
diff --git a/src/components/metadata/MetadataEntry.vue b/src/components/metadata/MetadataEntry.vue
index 28a5224ce6..816cee97c1 100644
--- a/src/components/metadata/MetadataEntry.vue
+++ b/src/components/metadata/MetadataEntry.vue
@@ -1,16 +1,19 @@
-
-
+
+
-
+
+
+
+
-
+
@@ -31,19 +34,33 @@ const CODE_SNIPPET_FIELDS = [
'xarray_snippet'
];
+const TREE_VIEW_FIELDS = [
+ 'nml:parameters'
+];
+
export default {
name: "MetadataEntry",
components: {
MetadataTable: defineAsyncComponent(() => import('./MetadataTable.vue')),
- CodeSnippet: defineAsyncComponent(() => import('./CodeSnippet.vue'))
+ CodeSnippet: defineAsyncComponent(() => import('./CodeSnippet.vue')),
+ NamelistTree: defineAsyncComponent(() => import('./NamelistTree.vue'))
},
mixins: [
EntryMixin
],
+ props: {
+ highlight: {
+ type: String,
+ default: ''
+ }
+ },
computed: {
showTable() {
return FORCE_TABLE.includes(this.field) || this.itemOrder.length > 0 && size(this.value) >= 3;
},
+ showTree() {
+ return TREE_VIEW_FIELDS.includes(this.field) && typeof this.value === 'object' && this.value !== null;
+ },
isCodeSnippet() {
return CODE_SNIPPET_FIELDS.includes(this.field) && typeof this.value === 'string';
},
@@ -52,7 +69,49 @@ export default {
return 'python';
}
return 'text';
+ },
+ highlightedLabel() {
+ return this.applyHighlight(this.label);
+ },
+ highlightedFormatted() {
+ return this.applyHighlight(this.formatted);
+ }
+ },
+ methods: {
+ applyHighlight(text) {
+ if (!this.highlight || !text) {
+ return text;
+ }
+
+ const query = this.highlight.toLowerCase();
+ const lowerText = text.toLowerCase();
+
+ // Find all occurrences and highlight them
+ let result = '';
+ let lastIndex = 0;
+ let searchIndex = 0;
+
+ while ((searchIndex = lowerText.indexOf(query, lastIndex)) !== -1) {
+ // Add text before the match
+ result += text.substring(lastIndex, searchIndex);
+ // Add the highlighted match (preserving original case)
+ result += '' + text.substring(searchIndex, searchIndex + this.highlight.length) + ' ';
+ lastIndex = searchIndex + this.highlight.length;
+ }
+
+ // Add remaining text
+ result += text.substring(lastIndex);
+
+ return result;
}
}
};
+
+
diff --git a/src/components/metadata/MetadataGroup.vue b/src/components/metadata/MetadataGroup.vue
index c35aebca7a..f164ef5853 100644
--- a/src/components/metadata/MetadataGroup.vue
+++ b/src/components/metadata/MetadataGroup.vue
@@ -1,39 +1,92 @@
-
+
+ {{ collapsed ? '+' : '-' }}
{{ $t('metadata.general') }}
+ ({{ propertyCount }})
-
+
+
+
+
+
diff --git a/src/components/metadata/NamelistTree.vue b/src/components/metadata/NamelistTree.vue
new file mode 100644
index 0000000000..d589db4195
--- /dev/null
+++ b/src/components/metadata/NamelistTree.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
diff --git a/src/data/glossary.json b/src/data/glossary.json
new file mode 100644
index 0000000000..7f27d27440
--- /dev/null
+++ b/src/data/glossary.json
@@ -0,0 +1,227 @@
+{
+ "T31": {
+ "term": "T31",
+ "category": "resolution",
+ "description": "Atmosphere spectral resolution: ~3.75 degrees (~400km)"
+ },
+ "T63": {
+ "term": "T63",
+ "category": "resolution",
+ "description": "Atmosphere spectral resolution: ~1.875 degrees (~200km)"
+ },
+ "T127": {
+ "term": "T127",
+ "category": "resolution",
+ "description": "Atmosphere spectral resolution: ~0.9 degrees (~100km)"
+ },
+ "T255": {
+ "term": "T255",
+ "category": "resolution",
+ "description": "Atmosphere spectral resolution: ~0.5 degrees (~50km)"
+ },
+ "CORE2": {
+ "term": "CORE2",
+ "category": "mesh",
+ "description": "Ocean mesh: ~1 degree global resolution (Coordinated Ocean-ice Reference Experiments)"
+ },
+ "GLOB": {
+ "term": "GLOB",
+ "category": "mesh",
+ "description": "Ocean mesh: variable resolution, global coverage"
+ },
+ "pi": {
+ "term": "PI-Mesh",
+ "category": "mesh",
+ "description": "Ocean mesh: high resolution around the poles"
+ },
+ "vmr": {
+ "term": "VMR",
+ "category": "units",
+ "description": "Volume Mixing Ratio - concentration as a decimal fraction"
+ },
+ "ppm": {
+ "term": "ppm",
+ "category": "units",
+ "description": "Parts per million (10^-6)"
+ },
+ "ppb": {
+ "term": "ppb",
+ "category": "units",
+ "description": "Parts per billion (10^-9)"
+ },
+ "ECHAM": {
+ "term": "ECHAM",
+ "category": "model",
+ "description": "Atmosphere general circulation model (MPI-M Hamburg)"
+ },
+ "ECHAM6": {
+ "term": "ECHAM6",
+ "category": "model",
+ "description": "ECHAM version 6 atmosphere model"
+ },
+ "FESOM": {
+ "term": "FESOM",
+ "category": "model",
+ "description": "Finite Element Sea ice-Ocean Model (AWI) - unstructured mesh ocean model"
+ },
+ "FESOM2": {
+ "term": "FESOM2",
+ "category": "model",
+ "description": "FESOM version 2 - improved performance and physics"
+ },
+ "JSBACH": {
+ "term": "JSBACH",
+ "category": "model",
+ "description": "Jena Scheme for Biosphere-Atmosphere Coupling in Hamburg - land surface model"
+ },
+ "HDMODEL": {
+ "term": "HDMODEL",
+ "category": "model",
+ "description": "Hydrological Discharge Model - river routing scheme"
+ },
+ "OASIS": {
+ "term": "OASIS",
+ "category": "model",
+ "description": "Ocean Atmosphere Sea Ice Soil coupler framework"
+ },
+ "OASIS3MCT": {
+ "term": "OASIS3-MCT",
+ "category": "model",
+ "description": "OASIS coupler with Model Coupling Toolkit"
+ },
+ "RECOM": {
+ "term": "REcoM",
+ "category": "model",
+ "description": "Regulated Ecosystem Model - marine biogeochemistry"
+ },
+ "HAMOCC": {
+ "term": "HAMOCC",
+ "category": "model",
+ "description": "Hamburg Ocean Carbon Cycle Model - marine biogeochemistry"
+ },
+ "PISM": {
+ "term": "PISM",
+ "category": "model",
+ "description": "Parallel Ice Sheet Model"
+ },
+ "AWI-CM": {
+ "term": "AWI-CM",
+ "category": "model",
+ "description": "AWI Climate Model - coupled ECHAM+FESOM"
+ },
+ "AWI-ESM": {
+ "term": "AWI-ESM",
+ "category": "model",
+ "description": "AWI Earth System Model - includes biogeochemistry"
+ },
+ "MPI-ESM": {
+ "term": "MPI-ESM",
+ "category": "model",
+ "description": "MPI Earth System Model (Hamburg)"
+ },
+ "PI": {
+ "term": "PI-Control",
+ "category": "experiment",
+ "description": "Pre-industrial control run - constant 1850 conditions"
+ },
+ "piControl": {
+ "term": "piControl",
+ "category": "experiment",
+ "description": "Pre-industrial control simulation (CMIP convention)"
+ },
+ "LGM": {
+ "term": "LGM",
+ "category": "experiment",
+ "description": "Last Glacial Maximum - peak ice extent ~21,000 years ago"
+ },
+ "MH": {
+ "term": "Mid-Holocene",
+ "category": "experiment",
+ "description": "Mid-Holocene warm period ~6,000 years ago"
+ },
+ "historical": {
+ "term": "Historical",
+ "category": "experiment",
+ "description": "Historical simulation with observed forcing (typically 1850-2014)"
+ },
+ "spinup": {
+ "term": "Spin-up",
+ "category": "experiment",
+ "description": "Initial equilibration run before main experiment"
+ },
+ "ka": {
+ "term": "ka",
+ "category": "time",
+ "description": "Thousands of years ago (kilo-annum)"
+ },
+ "Ma": {
+ "term": "Ma",
+ "category": "time",
+ "description": "Millions of years ago (mega-annum)"
+ },
+ "BP": {
+ "term": "BP",
+ "category": "time",
+ "description": "Before Present (reference year 1950)"
+ },
+ "BCE": {
+ "term": "BCE",
+ "category": "time",
+ "description": "Before Common Era (negative years)"
+ },
+ "CE": {
+ "term": "CE",
+ "category": "time",
+ "description": "Common Era (positive years)"
+ },
+ "mon": {
+ "term": "Monthly",
+ "category": "frequency",
+ "description": "Monthly mean output"
+ },
+ "day": {
+ "term": "Daily",
+ "category": "frequency",
+ "description": "Daily mean output"
+ },
+ "6hr": {
+ "term": "6-hourly",
+ "category": "frequency",
+ "description": "6-hourly output (4 times per day)"
+ },
+ "3hr": {
+ "term": "3-hourly",
+ "category": "frequency",
+ "description": "3-hourly output (8 times per day)"
+ },
+ "1hr": {
+ "term": "Hourly",
+ "category": "frequency",
+ "description": "Hourly output"
+ },
+ "radctl": {
+ "term": "radctl",
+ "category": "namelist",
+ "description": "Radiation control namelist group"
+ },
+ "runctl": {
+ "term": "runctl",
+ "category": "namelist",
+ "description": "Run control namelist group"
+ },
+ "co2vmr": {
+ "term": "CO2 VMR",
+ "category": "parameter",
+ "description": "CO2 volume mixing ratio (concentration)"
+ },
+ "ch4vmr": {
+ "term": "CH4 VMR",
+ "category": "parameter",
+ "description": "Methane volume mixing ratio"
+ },
+ "n2ovmr": {
+ "term": "N2O VMR",
+ "category": "parameter",
+ "description": "Nitrous oxide volume mixing ratio"
+ }
+}
diff --git a/src/locales/en/texts.json b/src/locales/en/texts.json
index ef599d8a67..286c0995ea 100644
--- a/src/locales/en/texts.json
+++ b/src/locales/en/texts.json
@@ -311,7 +311,7 @@
"selectForItemSearch": "Select for Item Search",
"spatialExtent": "Spatial Extent",
"tabs": {
- "collections": "Search for Collections",
+ "collections": "Search for Experiments",
"items": "Search for Items"
},
"temporalExtent": "Temporal Extent",
diff --git a/src/models/cql2/operators/logical.js b/src/models/cql2/operators/logical.js
index e18a093f6a..724326f832 100644
--- a/src/models/cql2/operators/logical.js
+++ b/src/models/cql2/operators/logical.js
@@ -73,4 +73,13 @@ export class CqlNot extends CqlLogicalOperator {
return i18n.global.t('search.logical.not');
}
+ toText() {
+ // The base-class join() on a single-element array drops the operator.
+ // Override to produce correct CQL2-text syntax: NOT (inner expression).
+ if (this.args && this.args.length === 1) {
+ return `NOT (${this.args[0].toText()})`;
+ }
+ return super.toText();
+ }
+
}
diff --git a/src/router/index.js b/src/router/index.js
index b37651a163..5c840b52d6 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -57,6 +57,18 @@ function getRoutes(config) {
props: route => getPath(route, config)
});
+ routes.push({
+ path: "/collections/personal",
+ name: "personal-collections",
+ component: () => import("../views/PersonalCollectionsPage.vue")
+ });
+
+ routes.push({
+ path: "/compute",
+ name: "dask-dashboard",
+ component: () => import("../views/DaskDashboardPage.vue")
+ });
+
routes.push({
path: "/:pathMatch(.*)*",
name: "browse",
diff --git a/src/store/comparison.js b/src/store/comparison.js
new file mode 100644
index 0000000000..fd6f495802
--- /dev/null
+++ b/src/store/comparison.js
@@ -0,0 +1,130 @@
+/**
+ * Vuex module for collection comparison feature.
+ *
+ * Allows users to select 2-3 collections for side-by-side comparison
+ * of their namelist parameters and other properties.
+ */
+
+const MAX_SELECTIONS = 3;
+
+export default function() {
+ return {
+ namespaced: true,
+ state: () => ({
+ // Selected collection IDs for comparison
+ selectedCollections: [],
+ // Full collection data (fetched when comparison modal opens)
+ collectionData: {},
+ // Whether the comparison modal is open
+ isModalOpen: false
+ }),
+ getters: {
+ // Number of selected collections
+ selectionCount: state => state.selectedCollections.length,
+ // Check if a collection is selected
+ isSelected: state => collectionId => state.selectedCollections.includes(collectionId),
+ // Can we add more selections?
+ canAddMore: state => state.selectedCollections.length < MAX_SELECTIONS,
+ // Are we ready to compare? (need at least 2)
+ canCompare: state => state.selectedCollections.length >= 2,
+ // Get all namelist parameters across selected collections.
+ // Excludes collection-level aggregate keys like nml:parameters and nml:groups
+ // which hold bundled dicts rather than individual comparable values.
+ allParameters: state => {
+ const EXCLUDE = new Set(['nml:parameters', 'nml:groups']);
+ const params = new Set();
+ for (const data of Object.values(state.collectionData)) {
+ if (data && typeof data === 'object') {
+ for (const key of Object.keys(data)) {
+ if (key.startsWith('nml:') && !EXCLUDE.has(key)) {
+ params.add(key);
+ }
+ }
+ }
+ }
+ return Array.from(params).sort();
+ },
+ // Get parameter value for a collection
+ getParamValue: state => (collectionId, paramKey) => {
+ const data = state.collectionData[collectionId];
+ if (!data) return null;
+ return data[paramKey] ?? data.properties?.[paramKey] ?? null;
+ },
+ // Check if a parameter has different values across collections
+ hasDifference: (state, getters) => paramKey => {
+ const values = state.selectedCollections.map(id =>
+ getters.getParamValue(id, paramKey)
+ );
+ const uniqueValues = new Set(values.map(v => JSON.stringify(v)));
+ return uniqueValues.size > 1;
+ }
+ },
+ mutations: {
+ // Toggle selection of a collection
+ toggleSelection(state, collectionId) {
+ const index = state.selectedCollections.indexOf(collectionId);
+ if (index === -1) {
+ // Add if not at max
+ if (state.selectedCollections.length < MAX_SELECTIONS) {
+ state.selectedCollections.push(collectionId);
+ }
+ } else {
+ // Remove
+ state.selectedCollections.splice(index, 1);
+ // Also remove cached data
+ delete state.collectionData[collectionId];
+ }
+ },
+ // Clear all selections
+ clearSelections(state) {
+ state.selectedCollections = [];
+ state.collectionData = {};
+ },
+ // Store fetched collection data
+ setCollectionData(state, { collectionId, data }) {
+ state.collectionData[collectionId] = data;
+ },
+ // Open/close comparison modal
+ setModalOpen(state, isOpen) {
+ state.isModalOpen = isOpen;
+ }
+ },
+ actions: {
+ // Toggle selection and trigger data fetch if needed
+ toggleCollection({ commit, state }, collectionId) {
+ commit('toggleSelection', collectionId);
+ },
+ // Open comparison modal and fetch any missing data
+ async openComparison({ commit, state, dispatch }) {
+ commit('setModalOpen', true);
+ // Fetch data for any collections we don't have yet
+ for (const id of state.selectedCollections) {
+ if (!state.collectionData[id]) {
+ await dispatch('fetchCollectionData', id);
+ }
+ }
+ },
+ // Close comparison modal
+ closeComparison({ commit }) {
+ commit('setModalOpen', false);
+ },
+ // Fetch full collection data for comparison
+ async fetchCollectionData({ commit, rootState }, collectionId) {
+ try {
+ // Look up in the store's database
+ const stacData = rootState.database[collectionId];
+ if (stacData && typeof stacData === 'object' && !(stacData instanceof Error)) {
+ commit('setCollectionData', { collectionId, data: stacData });
+ return;
+ }
+ // If not in cache, we might need to fetch it
+ // For now, just store what we have
+ commit('setCollectionData', { collectionId, data: null });
+ } catch (error) {
+ console.warn('Failed to fetch collection data for comparison:', error);
+ commit('setCollectionData', { collectionId, data: null });
+ }
+ }
+ }
+ };
+}
diff --git a/src/store/index.js b/src/store/index.js
index 47c1321604..947d7c690c 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -10,6 +10,7 @@ import { addMissingChildren, getDisplayTitle, createSTAC } from '../models/stac'
import { CatalogLike, STAC } from 'stac-js';
import auth from './auth.js';
+import comparison from './comparison.js';
import { addQueryIfNotExists, hasAuthority, isAuthenticationError, Loading, processSTAC, stacRequest, stacRequestOptions } from './utils';
import { getBest } from 'stac-js/src/locales';
import fieldsI18n from '@radiantearth/stac-fields/I18N';
@@ -54,7 +55,8 @@ function getStore(config, router) {
return createStore({
strict: import.meta.env.NODE_ENV !== 'production',
modules: {
- auth: auth(router)
+ auth: auth(router),
+ comparison: comparison()
},
state: Object.assign({}, config, localDefaults(), catalogDefaults(), {
// Global settings
@@ -62,7 +64,8 @@ function getStore(config, router) {
downloads: {},
allowSelectCatalog: !config.catalogUrl,
globalRequestQueryParameters: config.requestQueryParameters,
- uiLanguage: config.locale
+ uiLanguage: config.locale,
+ user: { name: 'default' }
}),
getters: {
isRoot: (state, getters) => {
diff --git a/src/theme/page.scss b/src/theme/page.scss
index 02c4bffe3c..b8d97a5c30 100644
--- a/src/theme/page.scss
+++ b/src/theme/page.scss
@@ -366,12 +366,12 @@ body {
.catalog-card,
.item-card {
- grid-row: span 7;
+ grid-row: span 9;
.card-body {
display: grid;
grid-template-rows: subgrid;
- grid-row: span 4;
+ grid-row: span 6;
}
}
diff --git a/src/views/Catalog.vue b/src/views/Catalog.vue
index 835dd0b00c..1ea25ec2e0 100644
--- a/src/views/Catalog.vue
+++ b/src/views/Catalog.vue
@@ -3,7 +3,10 @@
- {{ $t('description') }}
+
+
{{ $t('description') }}
+
+
@@ -23,15 +26,25 @@
-
+
@@ -82,10 +95,12 @@ export default defineComponent({
BTab,
BTabs,
BCard,
+ AddToCollection: defineAsyncComponent(() => import('../components/AddToCollection.vue')),
AnonymizedNotice: defineAsyncComponent(() => import('../components/AnonymizedNotice.vue')),
Assets: defineAsyncComponent(() => import('../components/Assets.vue')),
Catalogs,
CollectionLink: defineAsyncComponent(() => import('../components/CollectionLink.vue')),
+ DataPreview: defineAsyncComponent(() => import('../components/DataPreview.vue')),
DeprecationNotice: defineAsyncComponent(() => import('../components/DeprecationNotice.vue')),
Description,
Items,
@@ -142,8 +157,12 @@ export default defineComponent({
};
},
computed: {
- ...mapState(['data', 'url', 'apiItems', 'apiItemsLink', 'apiItemsPagination', 'apiItemsNumberMatched', 'nextCollectionsLink', 'stateQueryParameters']),
+ ...mapState(['data', 'url', 'catalogUrl', 'apiItems', 'apiItemsLink', 'apiItemsPagination', 'apiItemsNumberMatched', 'nextCollectionsLink', 'stateQueryParameters', 'vizServer']),
...mapGetters(['catalogs', 'collectionLink', 'isCollection', 'items', 'getApiItemsLoading', 'parentLink', 'rootLink']),
+ stacApi() {
+ // Use catalogUrl as the STAC API endpoint
+ return this.catalogUrl || '';
+ },
cssStacType() {
if (hasText(this.data?.type)) {
return this.data?.type.toLowerCase();
diff --git a/src/views/DaskDashboardPage.vue b/src/views/DaskDashboardPage.vue
new file mode 100644
index 0000000000..07a81ba829
--- /dev/null
+++ b/src/views/DaskDashboardPage.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Item.vue b/src/views/Item.vue
index b408ca7ea3..72c35f3968 100644
--- a/src/views/Item.vue
+++ b/src/views/Item.vue
@@ -5,12 +5,22 @@
@@ -19,7 +29,10 @@
- {{ $t('description') }}
+
+
{{ $t('description') }}
+
+
@@ -51,9 +64,11 @@ export default defineComponent({
BTab,
BTabs,
BCard,
+ AddToCollection: defineAsyncComponent(() => import('../components/AddToCollection.vue')),
AnonymizedNotice: defineAsyncComponent(() => import('../components/AnonymizedNotice.vue')),
Assets: defineAsyncComponent(() => import('../components/Assets.vue')),
CollectionLink: defineAsyncComponent(() => import('../components/CollectionLink.vue')),
+ DataPreview: defineAsyncComponent(() => import('../components/DataPreview.vue')),
Description,
DeprecationNotice: defineAsyncComponent(() => import('../components/DeprecationNotice.vue')),
Keywords: defineAsyncComponent(() => import('../components/Keywords.vue')),
@@ -88,8 +103,12 @@ export default defineComponent({
};
},
computed: {
- ...mapState(['data', 'url']),
- ...mapGetters(['collectionLink', 'parentLink'])
+ ...mapState(['data', 'url', 'catalogUrl', 'vizServer']),
+ ...mapGetters(['collectionLink', 'parentLink']),
+ stacApi() {
+ // Use catalogUrl as the STAC API endpoint
+ return this.catalogUrl || '';
+ }
},
watch: {
data: {
diff --git a/src/views/PersonalCollectionsPage.vue b/src/views/PersonalCollectionsPage.vue
new file mode 100644
index 0000000000..94b56907d5
--- /dev/null
+++ b/src/views/PersonalCollectionsPage.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/data-preview.spec.js b/tests/e2e/data-preview.spec.js
new file mode 100644
index 0000000000..583bd866b3
--- /dev/null
+++ b/tests/e2e/data-preview.spec.js
@@ -0,0 +1,834 @@
+import { test, expect } from '@playwright/test';
+
+// Mock visualization server URL
+const VIZ_SERVER_URL = 'http://localhost:8001';
+
+// Mock STAC API URL
+const STAC_API_URL = 'https://stac-api.test.com/v1';
+
+// Fixture for item metadata response
+const ITEM_METADATA_FIXTURE = {
+ dimensions: {
+ time: 12,
+ lat: 180,
+ lon: 360
+ },
+ variables: [
+ { name: 'temperature', long_name: 'Temperature', units: 'K' },
+ { name: 'precipitation', long_name: 'Precipitation', units: 'mm/day' },
+ { name: 'pressure', long_name: 'Pressure', units: 'hPa' }
+ ]
+};
+
+// Fixture for a STAC Item
+const STAC_ITEM_FIXTURE = {
+ type: 'Feature',
+ stac_version: '1.0.0',
+ id: 'test-item-001',
+ collection: 'test-collection',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]]
+ },
+ bbox: [-180, -90, 180, 90],
+ properties: {
+ datetime: '2024-01-01T00:00:00Z',
+ title: 'Test Climate Data Item'
+ },
+ links: [],
+ assets: {
+ data: {
+ href: 'https://example.com/data.nc',
+ type: 'application/x-netcdf'
+ }
+ }
+};
+
+// Fixture for a STAC Collection
+const STAC_COLLECTION_FIXTURE = {
+ type: 'Collection',
+ stac_version: '1.0.0',
+ id: 'test-collection',
+ title: 'Test Climate Collection',
+ description: 'A test collection for climate data',
+ extent: {
+ spatial: { bbox: [[-180, -90, 180, 90]] },
+ temporal: { interval: [['2020-01-01T00:00:00Z', '2024-12-31T23:59:59Z']] }
+ },
+ links: []
+};
+
+/**
+ * Sets up route mocking for the visualization server endpoints
+ */
+const mockVizServer = async (page, options = {}) => {
+ const {
+ metadataResponse = ITEM_METADATA_FIXTURE,
+ metadataStatus = 200,
+ imageDelay = 0,
+ imageError = false
+ } = options;
+
+ // Mock the metadata endpoint
+ await page.route(`${VIZ_SERVER_URL}/preview/*.json**`, async (route) => {
+ if (metadataStatus !== 200) {
+ await route.fulfill({
+ status: metadataStatus,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'Failed to fetch metadata' })
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(metadataResponse)
+ });
+ });
+
+ // Mock the PNG preview endpoint
+ await page.route(`${VIZ_SERVER_URL}/preview/*.png**`, async (route) => {
+ if (imageError) {
+ await route.abort('failed');
+ return;
+ }
+ if (imageDelay > 0) {
+ await new Promise(resolve => setTimeout(resolve, imageDelay));
+ }
+ // Return a minimal 1x1 transparent PNG
+ const transparentPng = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
+ 'base64'
+ );
+ await route.fulfill({
+ status: 200,
+ contentType: 'image/png',
+ body: transparentPng
+ });
+ });
+
+ // Mock the Panel interactive endpoint (/preview/*/panel redirects to /_panel/)
+ await page.route(`${VIZ_SERVER_URL}/preview/*/panel**`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'text/html',
+ body: 'Panel Visualization
'
+ });
+ });
+};
+
+/**
+ * Creates a test page with the DataPreview component mounted
+ * Since DataPreview is typically embedded within Item/Collection pages,
+ * we create a minimal test harness
+ */
+const createDataPreviewTestPage = (item, options = {}) => {
+ const { vizServer = VIZ_SERVER_URL, stacApi = STAC_API_URL } = options;
+
+ return `
+
+
+
+ DataPreview Test
+
+
+
+
+
+
+
+
+
+ Interactive
+
+
+ Static
+
+
+
+
+
+ ${item.type === 'Collection' ? `
+
+ Select an item from the collection to preview its data interactively.
+
+ ` : `
+
+
Loading interactive preview...
+
+
+ `}
+
+
+
+
+ ${item.type === 'Collection' ? `
+
+ Select an item from the collection to preview its data.
+
+ ` : `
+
+
+
+
+
+
+ t=0
+
+ Viridis
+ Plasma
+ Coolwarm
+ Red-Blue
+
+
+ Refresh
+
+
+
+
+
+
+
+
+ `}
+
+
+
+
+
+
+
+ `;
+};
+
+test.describe('DataPreview Component', () => {
+
+ test.describe('Mode Toggle', () => {
+
+ test('should display mode toggle buttons with Interactive selected by default', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Verify both toggle buttons are visible
+ const interactiveBtn = page.locator('#btn-interactive');
+ const staticBtn = page.locator('#btn-static');
+
+ await expect(interactiveBtn).toBeVisible();
+ await expect(staticBtn).toBeVisible();
+
+ // Verify Interactive is selected (has btn-primary class)
+ await expect(interactiveBtn).toHaveClass(/btn-primary/);
+ await expect(staticBtn).toHaveClass(/btn-outline-secondary/);
+ });
+
+ test('should switch to static mode when Static button is clicked', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ const interactiveBtn = page.locator('#btn-interactive');
+ const staticBtn = page.locator('#btn-static');
+ const interactivePreview = page.locator('#interactive-preview');
+ const staticPreview = page.locator('#static-preview');
+
+ // Click Static button
+ await staticBtn.click();
+
+ // Verify Static is now selected
+ await expect(staticBtn).toHaveClass(/btn-primary/);
+ await expect(interactiveBtn).toHaveClass(/btn-outline-secondary/);
+
+ // Verify static preview is visible, interactive is hidden
+ await expect(staticPreview).toBeVisible();
+ await expect(interactivePreview).not.toBeVisible();
+ });
+
+ test('should switch back to interactive mode when Interactive button is clicked', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ const interactiveBtn = page.locator('#btn-interactive');
+ const staticBtn = page.locator('#btn-static');
+ const interactivePreview = page.locator('#interactive-preview');
+ const staticPreview = page.locator('#static-preview');
+
+ // Switch to static first
+ await staticBtn.click();
+ await expect(staticPreview).toBeVisible();
+
+ // Switch back to interactive
+ await interactiveBtn.click();
+
+ // Verify Interactive is selected again
+ await expect(interactiveBtn).toHaveClass(/btn-primary/);
+ await expect(staticBtn).toHaveClass(/btn-outline-secondary/);
+
+ // Verify interactive preview is visible
+ await expect(interactivePreview).toBeVisible();
+ await expect(staticPreview).not.toBeVisible();
+ });
+ });
+
+ test.describe('Interactive Mode', () => {
+
+ test('should show loading indicator before iframe loads', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Initially, loading indicator should be visible
+ const loadingIndicator = page.locator('#iframe-loading');
+ await expect(loadingIndicator).toBeVisible();
+ await expect(loadingIndicator).toContainText('Loading interactive preview');
+ });
+
+ test('should show iframe after it loads successfully', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ const iframe = page.locator('#preview-iframe');
+ const loadingIndicator = page.locator('#iframe-loading');
+
+ // Manually trigger the iframe load by calling the handler
+ // (iframe onload doesn't work with page.setContent for cross-origin src)
+ await page.evaluate(() => {
+ if (typeof handleIframeLoad === 'function') {
+ handleIframeLoad();
+ }
+ });
+
+ // Verify iframe is visible and loading is hidden
+ await expect(iframe).toBeVisible();
+ await expect(loadingIndicator).not.toBeVisible();
+ });
+
+ test('should construct correct iframe URL with item and STAC API parameters', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ const iframe = page.locator('#preview-iframe');
+ const src = await iframe.getAttribute('src');
+
+ // Verify URL structure: /preview/{item_id}/panel?stac_api=...&collection_id=...
+ expect(src).toContain(`${VIZ_SERVER_URL}/preview/${STAC_ITEM_FIXTURE.id}/panel`);
+ expect(src).toContain(`stac_api=${encodeURIComponent(STAC_API_URL)}`);
+ expect(src).toContain(`collection_id=${STAC_ITEM_FIXTURE.collection}`);
+ });
+ });
+
+ test.describe('Static Mode', () => {
+
+ test('should show loading indicator when switching to static mode', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Loading indicator exists (may be hidden quickly when mock data loads fast)
+ const loadingIndicator = page.locator('#static-loading');
+ await expect(loadingIndicator).toBeAttached();
+ await expect(loadingIndicator).toContainText('Loading preview');
+ });
+
+ test('should display variable selector with options from metadata', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode and wait for metadata to load
+ await page.locator('#btn-static').click();
+
+ // Wait for content to be visible
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ // Verify variable selector exists and has options
+ const variableSelect = page.locator('#variable-select');
+ await expect(variableSelect).toBeVisible();
+
+ // Check that the variables from metadata are present
+ const options = variableSelect.locator('option');
+ await expect(options).toHaveCount(3);
+
+ // Verify first option is temperature
+ await expect(options.first()).toContainText('Temperature');
+ await expect(options.first()).toContainText('(K)');
+ });
+
+ test('should display time slider for temporal data', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ // Verify time slider exists
+ const timeSlider = page.locator('#time-slider');
+ await expect(timeSlider).toBeVisible();
+
+ // Verify max is set correctly (time dimension - 1)
+ const max = await timeSlider.getAttribute('max');
+ expect(parseInt(max)).toBe(ITEM_METADATA_FIXTURE.dimensions.time - 1);
+
+ // Verify time label is visible
+ const timeLabel = page.locator('#time-label');
+ await expect(timeLabel).toBeVisible();
+ await expect(timeLabel).toHaveText('t=0');
+ });
+
+ test('should display colormap selector with options', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ // Verify colormap selector exists
+ const colormapSelect = page.locator('#colormap-select');
+ await expect(colormapSelect).toBeVisible();
+
+ // Verify standard colormap options
+ const options = colormapSelect.locator('option');
+ await expect(options).toHaveCount(4);
+
+ // Check specific options (use toHaveAttribute for option elements)
+ await expect(options.nth(0)).toHaveAttribute('value', 'viridis');
+ await expect(options.nth(1)).toHaveAttribute('value', 'plasma');
+ await expect(options.nth(2)).toHaveAttribute('value', 'coolwarm');
+ await expect(options.nth(3)).toHaveAttribute('value', 'RdBu_r');
+ });
+
+ test('should display refresh button', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ // Verify refresh button exists
+ const refreshBtn = page.locator('#refresh-btn');
+ await expect(refreshBtn).toBeVisible();
+ await expect(refreshBtn).toHaveText('Refresh');
+ });
+
+ test('should update time label when slider is moved', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ const timeSlider = page.locator('#time-slider');
+ const timeLabel = page.locator('#time-label');
+
+ // Change slider value
+ await timeSlider.fill('5');
+ await timeSlider.dispatchEvent('input');
+
+ // Verify label updates
+ await expect(timeLabel).toHaveText('t=5');
+ });
+
+ test('should display preview image with correct src', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ // Verify preview image exists and has correct src pattern
+ const previewImage = page.locator('#preview-image');
+ await expect(previewImage).toBeVisible();
+
+ const src = await previewImage.getAttribute('src');
+ expect(src).toContain(`${VIZ_SERVER_URL}/preview/${STAC_ITEM_FIXTURE.id}.png`);
+ expect(src).toContain('var=temperature');
+ expect(src).toContain('time=0');
+ expect(src).toContain('cmap=viridis');
+ });
+
+ test('should display metadata dimensions', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ // Verify metadata dimensions are displayed
+ const metadataDiv = page.locator('#preview-metadata');
+ await expect(metadataDiv).toBeVisible();
+ await expect(metadataDiv).toContainText('Dimensions');
+ await expect(metadataDiv).toContainText('time=12');
+ await expect(metadataDiv).toContainText('lat=180');
+ await expect(metadataDiv).toContainText('lon=360');
+ });
+
+ test('should show error when metadata fetch fails', async ({ page }) => {
+ await mockVizServer(page, { metadataStatus: 500 });
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for error to appear
+ const errorDiv = page.locator('#static-error');
+ await expect(errorDiv).toBeVisible({ timeout: 5000 });
+ await expect(errorDiv).toContainText('Preview unavailable');
+ });
+ });
+
+ test.describe('Collection Preview Warning', () => {
+
+ test('should show info message for collections in interactive mode', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_COLLECTION_FIXTURE);
+ await page.setContent(html);
+
+ // Verify collection warning message is shown
+ const infoAlert = page.locator('#interactive-preview .alert-info');
+ await expect(infoAlert).toBeVisible();
+ await expect(infoAlert).toContainText('Select an item from the collection to preview its data');
+ });
+
+ test('should show warning message for collections in static mode', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_COLLECTION_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Verify collection warning message is shown
+ const warningAlert = page.locator('#static-preview .alert-warning');
+ await expect(warningAlert).toBeVisible();
+ await expect(warningAlert).toContainText('Select an item from the collection to preview its data');
+ });
+
+ test('should not show iframe for collections', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_COLLECTION_FIXTURE);
+ await page.setContent(html);
+
+ // Verify no iframe exists for collections
+ const iframe = page.locator('#preview-iframe');
+ await expect(iframe).toHaveCount(0);
+ });
+
+ test('should not show preview controls for collections in static mode', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_COLLECTION_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Verify preview controls are not present
+ const variableSelect = page.locator('#variable-select');
+ const timeSlider = page.locator('#time-slider');
+ const colormapSelect = page.locator('#colormap-select');
+
+ await expect(variableSelect).toHaveCount(0);
+ await expect(timeSlider).toHaveCount(0);
+ await expect(colormapSelect).toHaveCount(0);
+ });
+ });
+
+ test.describe('Refresh Functionality', () => {
+
+ test('should update image src with refresh parameter when refresh clicked', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ // Get initial image src
+ const previewImage = page.locator('#preview-image');
+ const initialSrc = await previewImage.getAttribute('src');
+
+ // Click refresh
+ await page.locator('#refresh-btn').click();
+
+ // Verify src changed (has _refresh parameter)
+ const newSrc = await previewImage.getAttribute('src');
+ expect(newSrc).not.toBe(initialSrc);
+ expect(newSrc).toContain('_refresh=');
+ });
+ });
+
+ test.describe('Variable Selection', () => {
+
+ test('should update image when different variable is selected', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ const variableSelect = page.locator('#variable-select');
+ const previewImage = page.locator('#preview-image');
+
+ // Get initial src
+ const initialSrc = await previewImage.getAttribute('src');
+ expect(initialSrc).toContain('var=temperature');
+
+ // Change variable to precipitation
+ await variableSelect.selectOption('precipitation');
+
+ // Verify image src updated
+ const newSrc = await previewImage.getAttribute('src');
+ expect(newSrc).toContain('var=precipitation');
+ });
+ });
+
+ test.describe('Colormap Selection', () => {
+
+ test('should update image when different colormap is selected', async ({ page }) => {
+ await mockVizServer(page);
+
+ const html = createDataPreviewTestPage(STAC_ITEM_FIXTURE);
+ await page.setContent(html);
+
+ // Switch to static mode
+ await page.locator('#btn-static').click();
+
+ // Wait for content to load
+ const previewContent = page.locator('#preview-content');
+ await expect(previewContent).toBeVisible({ timeout: 5000 });
+
+ const colormapSelect = page.locator('#colormap-select');
+ const previewImage = page.locator('#preview-image');
+
+ // Get initial src
+ const initialSrc = await previewImage.getAttribute('src');
+ expect(initialSrc).toContain('cmap=viridis');
+
+ // Change colormap to plasma
+ await colormapSelect.selectOption('plasma');
+
+ // Verify image src updated
+ const newSrc = await previewImage.getAttribute('src');
+ expect(newSrc).toContain('cmap=plasma');
+ });
+ });
+});
diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js
index e7d66a5809..aad7642726 100644
--- a/tests/e2e/helpers.js
+++ b/tests/e2e/helpers.js
@@ -9,15 +9,26 @@ const API_ROOT_URL = "https://earth-search.aws.test.com/v1";
const API_COLLECTIONS_URL = "https://earth-search.aws.test.com/v1/collections";
// todo: Move STAC documents to separate files
+const API_QUERYABLES_URL = "https://earth-search.aws.test.com/v1/queryables";
+
const API_ROOT_FIXTURE = {
stac_version: "1.0.0",
id: "test-api",
title: "Test API",
description: "Test API root",
conformsTo: [
+ "https://api.stacspec.org/v1.0.0/core",
"https://api.stacspec.org/v1.0.0/item-search",
"https://api.stacspec.org/v1.0.0/item-search#sort",
+ "https://api.stacspec.org/v1.0.0/item-search#filter", // Required for CQL filters in item search
"https://api.stacspec.org/v1.0.0/collection-search",
+ "https://api.stacspec.org/v1.0.0/collection-search#filter", // Required for CQL filters in collection search
+ // CQL2 conformances - required for Quick Filters to appear
+ "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter",
+ "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter",
+ "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text",
+ "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json",
+ "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2",
],
links: [
{
@@ -36,6 +47,12 @@ const API_ROOT_FIXTURE = {
type: "application/json",
href: API_COLLECTIONS_URL,
},
+ {
+ rel: "http://www.opengis.net/def/rel/ogc/1.0/queryables",
+ type: "application/schema+json",
+ title: "Queryables",
+ href: API_QUERYABLES_URL,
+ },
],
};
@@ -89,6 +106,12 @@ const API_COLLECTIONS_FIXTURE = {
type: "application/json",
href: API_ROOT_URL,
},
+ {
+ rel: "http://www.opengis.net/def/rel/ogc/1.0/queryables",
+ type: "application/schema+json",
+ title: "Queryables",
+ href: API_QUERYABLES_URL,
+ },
],
context: {
page: 1,
@@ -124,29 +147,8 @@ export const mockApiRootAndCollections = async (page) => {
});
};
-export const waitForMapReady = async (page) => {
- const mapViewport = page.locator(".map .ol-viewport");
- await expect(mapViewport).toBeVisible();
-
- const mapCanvas = page.locator(".map .ol-viewport canvas.ol-layer");
- await expect(mapCanvas).toBeVisible();
-
- return mapViewport;
-};
-
-export const waitForBboxInputsPopulated = async (page) => {
- const westLonInput = page.getByLabel(/west longitude/i);
- const southLatInput = page.getByLabel(/south latitude/i);
- const eastLonInput = page.getByLabel(/east longitude/i);
- const northLatInput = page.getByLabel(/north latitude/i);
-
- await expect(westLonInput).not.toHaveValue("");
- await expect(southLatInput).not.toHaveValue("");
- await expect(eastLonInput).not.toHaveValue("");
- await expect(northLatInput).not.toHaveValue("");
-
- return { westLonInput, southLatInput, eastLonInput, northLatInput };
-};
+// NOTE: waitForMapReady and waitForBboxInputsPopulated removed
+// Spatial extent filtering not used - all climate models are global
export const waitForSearchPost = async (page, responseBody = null) => {
let handler;
diff --git a/tests/e2e/homepage.spec.js b/tests/e2e/homepage.spec.js
index 021878faa2..ec91e72ddd 100644
--- a/tests/e2e/homepage.spec.js
+++ b/tests/e2e/homepage.spec.js
@@ -32,9 +32,9 @@ test.describe('STAC Browser Homepage', () => {
const dropdownMenu = page.locator('.dropdown-menu');
await expect(dropdownMenu).toBeVisible();
- // Count the number of language options (should be 11)
+ // Count the number of language options (test config only has English)
const languageOptions = dropdownMenu.locator('.dropdown-item');
- await expect(languageOptions).toHaveCount(11);
+ await expect(languageOptions).toHaveCount(1);
// Verify English is visible in the list
const englishOption = dropdownMenu.getByText(/english/i);
diff --git a/tests/e2e/search-filter.spec.js b/tests/e2e/search-filter.spec.js
new file mode 100644
index 0000000000..218df50300
--- /dev/null
+++ b/tests/e2e/search-filter.spec.js
@@ -0,0 +1,705 @@
+import { test, expect } from '@playwright/test';
+import {
+ SEARCH_PATH,
+ mockApiRootAndCollections,
+ waitForSearchPost
+} from './helpers';
+
+/**
+ * Helper to mock the queryables endpoint with climate-science relevant properties
+ */
+const mockQueryablesEndpoint = async (page) => {
+ await page.route('**/queryables', async (route, request) => {
+ if (request.method() === 'GET') {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ '$schema': 'https://json-schema.org/draft/2020-12/schema',
+ '$id': 'https://example.com/queryables',
+ type: 'object',
+ title: 'Queryables',
+ properties: {
+ 'nml:radctl:co2vmr': {
+ title: 'CO2 Volume Mixing Ratio',
+ type: 'number',
+ description: 'CO2 concentration in volume mixing ratio'
+ },
+ 'nml:radctl:ch4vmr': {
+ title: 'CH4 Volume Mixing Ratio',
+ type: 'number',
+ description: 'CH4 concentration in volume mixing ratio'
+ },
+ 'nml:radctl:n2ovmr': {
+ title: 'N2O Volume Mixing Ratio',
+ type: 'number',
+ description: 'N2O concentration in volume mixing ratio'
+ },
+ 'model': {
+ title: 'Model Component',
+ type: 'string',
+ enum: ['echam', 'fesom', 'jsbach', 'hdmodel', 'oasis', 'recom']
+ },
+ 'experiment_type': {
+ title: 'Experiment Type',
+ type: 'string'
+ },
+ 'paleo:years_bp': {
+ title: 'Years Before Present',
+ type: 'number'
+ }
+ }
+ })
+ });
+ return;
+ }
+ await route.continue();
+ });
+};
+
+/**
+ * Helper to mock paleo presets endpoint
+ */
+const mockPaleoPresetsEndpoint = async (page) => {
+ await page.route('**/paleo-presets', async (route, request) => {
+ if (request.method() === 'GET') {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ presets: [
+ { id: 'lgm', name: 'LGM', display: '21.0 ka', years_bp: 21000, description: 'Last Glacial Maximum' },
+ { id: 'mid_holocene', name: 'Mid-Holocene', display: '6.0 ka', years_bp: 6000, description: 'Mid-Holocene warm period' },
+ { id: 'eemian', name: 'Eemian', display: '125.0 ka', years_bp: 125000, description: 'Last Interglacial' },
+ { id: 'preindustrial', name: 'PI', display: '1850 CE', years_bp: 100, description: 'Pre-industrial' }
+ ]
+ })
+ });
+ return;
+ }
+ await route.continue();
+ });
+};
+
+/**
+ * Combined mock setup for all search filter tests
+ */
+const setupMocks = async (page) => {
+ await mockApiRootAndCollections(page);
+ await mockQueryablesEndpoint(page);
+ await mockPaleoPresetsEndpoint(page);
+};
+
+test.describe('SearchFilter Quick Filters UI', () => {
+ test('Should render quick filters section with all components', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ // Wait for the page to load
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Verify Quick Filters section is visible', async () => {
+ const quickFiltersLabel = page.getByText('Quick Filters');
+ await expect(quickFiltersLabel).toBeVisible();
+ });
+
+ await test.step('Verify Model Components section exists', async () => {
+ const modelComponentsLabel = page.getByText('Model Components');
+ await expect(modelComponentsLabel).toBeVisible();
+ });
+
+ await test.step('Verify CO2 Level inputs exist', async () => {
+ const co2Label = page.getByText('CO2 Level (ppm)');
+ await expect(co2Label).toBeVisible();
+ });
+
+ await test.step('Verify CH4 Level inputs exist', async () => {
+ const ch4Label = page.getByText('CH4 Level (ppb)');
+ await expect(ch4Label).toBeVisible();
+ });
+
+ await test.step('Verify N2O Level inputs exist', async () => {
+ const n2oLabel = page.getByText('N2O Level (ppb)');
+ await expect(n2oLabel).toBeVisible();
+ });
+
+ await test.step('Verify Experiment Type dropdown exists', async () => {
+ // Use more specific selector - the label in Quick Filters section
+ const experimentTypeLabel = page.locator('.quick-filters').getByText('Experiment Type');
+ await expect(experimentTypeLabel).toBeVisible();
+ });
+
+ await test.step('Verify Output Frequency dropdown exists', async () => {
+ const frequencyLabel = page.locator('.quick-filters').getByText('Output Frequency');
+ await expect(frequencyLabel).toBeVisible();
+ });
+ });
+
+ test('Should render CO2 preset buttons', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Verify PI (284) preset button exists', async () => {
+ const piButton = page.getByRole('button', { name: /PI \(284\)/i });
+ await expect(piButton).toBeVisible();
+ });
+
+ await test.step('Verify 2xCO2 preset button exists', async () => {
+ const twoXButton = page.getByRole('button', { name: /2xCO2/i });
+ await expect(twoXButton).toBeVisible();
+ });
+
+ await test.step('Verify 4xCO2 preset button exists', async () => {
+ const fourXButton = page.getByRole('button', { name: /4xCO2/i });
+ await expect(fourXButton).toBeVisible();
+ });
+ });
+});
+
+test.describe('SearchFilter CO2 Preset Buttons', () => {
+ test('PI preset should set CO2 range to 280-290 ppm', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Click PI preset button', async () => {
+ const piButton = page.getByRole('button', { name: /PI \(284\)/i });
+ await piButton.click();
+ });
+
+ await test.step('Verify CO2 min/max values are set correctly', async () => {
+ // Find the CO2 inputs - they are in the quick-filter-row containing "CO2 Level"
+ const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' });
+ const minInput = co2Row.locator('input[type="number"]').first();
+ const maxInput = co2Row.locator('input[type="number"]').last();
+
+ await expect(minInput).toHaveValue('280');
+ await expect(maxInput).toHaveValue('290');
+ });
+ });
+
+ test('2xCO2 preset should set CO2 range to 550-570 ppm', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Click 2xCO2 preset button', async () => {
+ const twoXButton = page.getByRole('button', { name: /2xCO2/i });
+ await twoXButton.click();
+ });
+
+ await test.step('Verify CO2 min/max values are set correctly', async () => {
+ const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' });
+ const minInput = co2Row.locator('input[type="number"]').first();
+ const maxInput = co2Row.locator('input[type="number"]').last();
+
+ await expect(minInput).toHaveValue('550');
+ await expect(maxInput).toHaveValue('570');
+ });
+ });
+
+ test('4xCO2 preset should set CO2 range to 1100-1140 ppm', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Click 4xCO2 preset button', async () => {
+ const fourXButton = page.getByRole('button', { name: /4xCO2/i });
+ await fourXButton.click();
+ });
+
+ await test.step('Verify CO2 min/max values are set correctly', async () => {
+ const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' });
+ const minInput = co2Row.locator('input[type="number"]').first();
+ const maxInput = co2Row.locator('input[type="number"]').last();
+
+ await expect(minInput).toHaveValue('1100');
+ await expect(maxInput).toHaveValue('1140');
+ });
+ });
+});
+
+test.describe('SearchFilter GHG Unit Conversion', () => {
+ test('CO2 filter should convert ppm to decimal in CQL2 filter', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Enter CO2 range in ppm', async () => {
+ const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' });
+ const minInput = co2Row.locator('input[type="number"]').first();
+ const maxInput = co2Row.locator('input[type="number"]').last();
+
+ await minInput.fill('400');
+ await maxInput.fill('500');
+ });
+
+ await test.step('Submit search and verify CQL2 filter has decimal values', async () => {
+ const requestPromise = waitForSearchPost(page);
+ const submitButton = page.getByRole('button', { name: /submit/i });
+ await submitButton.click();
+
+ const { body } = await requestPromise;
+
+ // The filter should contain CO2 values converted from ppm to decimal
+ // 400 ppm = 400 * 1e-6 = 0.0004
+ // 500 ppm = 500 * 1e-6 = 0.0005
+ expect(body.filter).toBeDefined();
+
+ // Convert filter to string for easier inspection
+ const filterJson = JSON.stringify(body.filter);
+
+ // Check that the CO2 field is referenced
+ expect(filterJson).toContain('nml:radctl:co2vmr');
+
+ // Check that values are in decimal format (should be small numbers like 0.0004)
+ // The exact structure depends on CQL2 format, but values should be < 1
+ expect(filterJson).toMatch(/0\.000[0-9]/);
+ });
+ });
+
+ test('CH4 filter should convert ppb to decimal in CQL2 filter', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Enter CH4 range in ppb', async () => {
+ const ch4Row = page.locator('.quick-filter-row').filter({ hasText: 'CH4 Level' });
+ const minInput = ch4Row.locator('input[type="number"]').first();
+ const maxInput = ch4Row.locator('input[type="number"]').last();
+
+ await minInput.fill('1800');
+ await maxInput.fill('2000');
+ });
+
+ await test.step('Submit search and verify CQL2 filter has decimal values', async () => {
+ const requestPromise = waitForSearchPost(page);
+ const submitButton = page.getByRole('button', { name: /submit/i });
+ await submitButton.click();
+
+ const { body } = await requestPromise;
+
+ expect(body.filter).toBeDefined();
+
+ const filterJson = JSON.stringify(body.filter);
+
+ // Check that the CH4 field is referenced
+ expect(filterJson).toContain('nml:radctl:ch4vmr');
+
+ // 1800 ppb = 1800 * 1e-9 = 0.0000018
+ // Values should be very small (in the e-6 range)
+ expect(filterJson).toMatch(/[0-9]\.[0-9]+e-[0-9]|0\.0000[0-9]/);
+ });
+ });
+
+ test('N2O filter should convert ppb to decimal in CQL2 filter', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Enter N2O range in ppb', async () => {
+ const n2oRow = page.locator('.quick-filter-row').filter({ hasText: 'N2O Level' });
+ const minInput = n2oRow.locator('input[type="number"]').first();
+ const maxInput = n2oRow.locator('input[type="number"]').last();
+
+ await minInput.fill('300');
+ await maxInput.fill('350');
+ });
+
+ await test.step('Submit search and verify CQL2 filter has decimal values', async () => {
+ const requestPromise = waitForSearchPost(page);
+ const submitButton = page.getByRole('button', { name: /submit/i });
+ await submitButton.click();
+
+ const { body } = await requestPromise;
+
+ expect(body.filter).toBeDefined();
+
+ const filterJson = JSON.stringify(body.filter);
+
+ // Check that the N2O field is referenced
+ expect(filterJson).toContain('nml:radctl:n2ovmr');
+
+ // 300 ppb = 300 * 1e-9 = 0.0000003
+ expect(filterJson).toMatch(/[0-9]\.[0-9]+e-[0-9]|0\.0000[0-9]/);
+ });
+ });
+});
+
+test.describe('SearchFilter Paleo Presets', () => {
+ test('Should display paleo time period presets when available', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ // Wait for paleo presets to load
+ await page.waitForLoadState('networkidle');
+
+ await test.step('Verify Paleo Time Period label is visible', async () => {
+ const paleoLabel = page.getByText('Paleo Time Period');
+ await expect(paleoLabel).toBeVisible();
+ });
+
+ await test.step('Verify LGM preset button exists', async () => {
+ const lgmButton = page.getByRole('button', { name: /^LGM$/i });
+ await expect(lgmButton).toBeVisible();
+ });
+
+ await test.step('Verify Mid-Holocene preset button exists', async () => {
+ const midHoloceneButton = page.getByRole('button', { name: /Mid-Holocene/i });
+ await expect(midHoloceneButton).toBeVisible();
+ });
+
+ await test.step('Verify Eemian preset button exists', async () => {
+ const eemianButton = page.getByRole('button', { name: /^Eemian$/i });
+ await expect(eemianButton).toBeVisible();
+ });
+
+ await test.step('Verify PI preset button exists', async () => {
+ const piButton = page.getByRole('button', { name: /^PI$/i });
+ await expect(piButton).toBeVisible();
+ });
+ });
+
+ test('Selecting a paleo preset should highlight the button', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+ await page.waitForLoadState('networkidle');
+
+ await test.step('Click LGM preset and verify it becomes selected', async () => {
+ const lgmButton = page.getByRole('button', { name: /^LGM$/i });
+ await lgmButton.click();
+
+ // The selected button should have variant 'info' instead of 'outline-info'
+ // This typically translates to a different class
+ await expect(lgmButton).toHaveClass(/btn-info/);
+ });
+
+ await test.step('Verify Clear button appears after selection', async () => {
+ const clearButton = page.getByRole('button', { name: /^Clear$/i });
+ await expect(clearButton).toBeVisible();
+ });
+ });
+
+ test('Clear button should deselect paleo preset', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+ await page.waitForLoadState('networkidle');
+
+ await test.step('Select LGM preset', async () => {
+ const lgmButton = page.getByRole('button', { name: /^LGM$/i });
+ await lgmButton.click();
+ await expect(lgmButton).toHaveClass(/btn-info/);
+ });
+
+ await test.step('Click Clear button and verify preset is deselected', async () => {
+ const clearButton = page.getByRole('button', { name: /^Clear$/i });
+ await clearButton.click();
+
+ // After clearing, LGM should have outline-info variant
+ const lgmButton = page.getByRole('button', { name: /^LGM$/i });
+ await expect(lgmButton).toHaveClass(/btn-outline-info/);
+
+ // Clear button should be hidden
+ await expect(clearButton).not.toBeVisible();
+ });
+ });
+
+ test('Paleo preset selection should include years_bp filter in search', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+ await page.waitForLoadState('networkidle');
+
+ await test.step('Select LGM preset (21000 years BP)', async () => {
+ const lgmButton = page.getByRole('button', { name: /^LGM$/i });
+ await lgmButton.click();
+ });
+
+ await test.step('Submit search and verify paleo filter is included', async () => {
+ const requestPromise = waitForSearchPost(page);
+ const submitButton = page.getByRole('button', { name: /submit/i });
+ await submitButton.click();
+
+ const { body } = await requestPromise;
+
+ expect(body.filter).toBeDefined();
+
+ const filterJson = JSON.stringify(body.filter);
+
+ // Check that paleo years_bp field is referenced
+ expect(filterJson).toContain('paleo:years_bp');
+
+ // LGM is 21000 years BP, with 10% tolerance = 18900 to 23100
+ // Check for values in that range
+ expect(filterJson).toMatch(/1[89][0-9]{3}|2[0-3][0-9]{3}/);
+ });
+ });
+});
+
+test.describe('SearchFilter Form Submission with Quick Filters', () => {
+ test('Search submission should include model component filters', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Select ECHAM model component', async () => {
+ // Model components are rendered as checkbox-group with button styling
+ const echamLabel = page.locator('.quick-filters').getByText('ECHAM');
+ await echamLabel.click();
+ });
+
+ await test.step('Submit search and verify model filter is included', async () => {
+ const requestPromise = waitForSearchPost(page);
+ const submitButton = page.getByRole('button', { name: /submit/i });
+ await submitButton.click();
+
+ const { body } = await requestPromise;
+
+ expect(body.filter).toBeDefined();
+
+ const filterJson = JSON.stringify(body.filter);
+
+ // Check that model field is referenced
+ expect(filterJson).toContain('model');
+ expect(filterJson).toContain('echam');
+ });
+ });
+
+ test('Search submission should include experiment type filter', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Select Historical experiment type', async () => {
+ const experimentSelect = page.locator('.quick-filter-row').filter({ hasText: 'Experiment Type' }).locator('select');
+ await experimentSelect.selectOption('historical');
+ });
+
+ await test.step('Submit search and verify experiment type filter is included', async () => {
+ const requestPromise = waitForSearchPost(page);
+ const submitButton = page.getByRole('button', { name: /submit/i });
+ await submitButton.click();
+
+ const { body } = await requestPromise;
+
+ expect(body.filter).toBeDefined();
+
+ const filterJson = JSON.stringify(body.filter);
+
+ // Check that experiment_type is in the filter
+ expect(filterJson).toContain('experiment_type');
+ expect(filterJson).toContain('historical');
+ });
+ });
+
+ test('Search submission should include multiple quick filters combined', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+ await page.waitForLoadState('networkidle');
+
+ await test.step('Set multiple quick filters', async () => {
+ // Set CO2 range
+ const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' });
+ await co2Row.locator('input[type="number"]').first().fill('280');
+ await co2Row.locator('input[type="number"]').last().fill('300');
+
+ // Select model component (checkbox-group with button styling)
+ const fesom = page.locator('.quick-filters').getByText('FESOM');
+ await fesom.click();
+
+ // Select paleo preset
+ const lgmButton = page.getByRole('button', { name: /^LGM$/i });
+ await lgmButton.click();
+ });
+
+ await test.step('Submit search and verify all filters are combined', async () => {
+ const requestPromise = waitForSearchPost(page);
+ const submitButton = page.getByRole('button', { name: /submit/i });
+ await submitButton.click();
+
+ const { body } = await requestPromise;
+
+ expect(body.filter).toBeDefined();
+
+ const filterJson = JSON.stringify(body.filter);
+
+ // All fields should be present in the combined filter
+ expect(filterJson).toContain('nml:radctl:co2vmr');
+ expect(filterJson).toContain('model');
+ expect(filterJson).toContain('fesom');
+ expect(filterJson).toContain('paleo:years_bp');
+ });
+ });
+
+ test('Reset button should clear all quick filters', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Set some quick filters', async () => {
+ // Set CO2 range using PI preset
+ const piButton = page.getByRole('button', { name: /PI \(284\)/i });
+ await piButton.click();
+
+ // Verify values are set
+ const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' });
+ await expect(co2Row.locator('input[type="number"]').first()).toHaveValue('280');
+ });
+
+ await test.step('Click Reset button', async () => {
+ const resetButton = page.getByRole('button', { name: /reset/i });
+ await resetButton.click();
+ });
+
+ await test.step('Verify quick filters are cleared', async () => {
+ const co2Row = page.locator('.quick-filter-row').filter({ hasText: 'CO2 Level' });
+ const minInput = co2Row.locator('input[type="number"]').first();
+ const maxInput = co2Row.locator('input[type="number"]').last();
+
+ // Inputs should be empty after reset
+ await expect(minInput).toHaveValue('');
+ await expect(maxInput).toHaveValue('');
+ });
+ });
+});
+
+test.describe('SearchFilter Queryables Search', () => {
+ test('Should display Additional Filters dropdown with search input', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+ await page.waitForLoadState('networkidle');
+
+ await test.step('Verify Additional Filters section exists', async () => {
+ const additionalFiltersLabel = page.getByText('Additional Filters');
+ await expect(additionalFiltersLabel).toBeVisible();
+ });
+
+ await test.step('Open Add Filter dropdown', async () => {
+ const addFilterButton = page.getByRole('button', { name: /add filter/i });
+ await addFilterButton.click();
+
+ // Verify dropdown menu appears
+ const dropdownMenu = page.locator('.queryables-menu');
+ await expect(dropdownMenu).toBeVisible();
+ });
+
+ await test.step('Verify filter search input exists', async () => {
+ const searchInput = page.locator('.queryables-search input');
+ await expect(searchInput).toBeVisible();
+ });
+ });
+
+ test('Queryables search should filter properties by name', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+ await page.waitForLoadState('networkidle');
+
+ await test.step('Open Add Filter dropdown and search for CO2', async () => {
+ const addFilterButton = page.getByRole('button', { name: /add filter/i });
+ await addFilterButton.click();
+
+ const searchInput = page.locator('.queryables-search input');
+ await searchInput.fill('CO2');
+ });
+
+ await test.step('Verify only CO2-related group is shown and can be expanded', async () => {
+ const dropdownMenu = page.locator('.queryables-menu');
+
+ // Should show the Radctl group (contains CO2)
+ const radctlGroup = dropdownMenu.locator('.queryable-group-header').filter({ hasText: 'Radctl' });
+ await expect(radctlGroup).toBeVisible();
+
+ // Expand the group to see individual properties
+ await radctlGroup.click();
+
+ // Should show co2vmr property after expanding (visible as badge)
+ await expect(dropdownMenu.getByText('co2vmr')).toBeVisible();
+
+ // ch4vmr and n2ovmr should not be visible (filtered out by search)
+ await expect(dropdownMenu.getByText('ch4vmr')).not.toBeVisible();
+ await expect(dropdownMenu.getByText('n2ovmr')).not.toBeVisible();
+ });
+ });
+
+ test('Searching for non-existent property should show no results message', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+ await page.waitForLoadState('networkidle');
+
+ await test.step('Search for non-existent property', async () => {
+ const addFilterButton = page.getByRole('button', { name: /add filter/i });
+ await addFilterButton.click();
+
+ const searchInput = page.locator('.queryables-search input');
+ await searchInput.fill('xyz_nonexistent_property_123');
+ });
+
+ await test.step('Verify no matching properties message is shown', async () => {
+ // The dropdown menu should show no groups or properties
+ const dropdownMenu = page.locator('.queryables-menu');
+ // Match either translated text or i18n key fallback
+ const noResultsMessage = dropdownMenu.getByText(/no matching properties|noMatchingProperties/i);
+ await expect(noResultsMessage).toBeVisible();
+ });
+ });
+});
+
+test.describe('SearchFilter Output Frequency Dropdown', () => {
+ test('Should display frequency options in dropdown', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Verify frequency dropdown has expected options', async () => {
+ const frequencySelect = page.locator('.quick-filter-row').filter({ hasText: 'Output Frequency' }).locator('select');
+
+ // Check select element is visible
+ await expect(frequencySelect).toBeVisible();
+
+ // Verify some key options exist
+ const options = frequencySelect.locator('option');
+ await expect(options).toHaveCount(7); // Any Frequency + 6 time intervals
+ });
+ });
+
+ test('Selecting frequency should update the dropdown value', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto(SEARCH_PATH);
+
+ await expect(page.getByRole('heading', { name: 'Search' })).toBeVisible();
+
+ await test.step('Select Monthly frequency', async () => {
+ const frequencySelect = page.locator('.quick-filter-row').filter({ hasText: 'Output Frequency' }).locator('select');
+ await frequencySelect.selectOption('mon');
+
+ await expect(frequencySelect).toHaveValue('mon');
+ });
+ });
+});
diff --git a/tests/e2e/searchpage.spec.js b/tests/e2e/searchpage.spec.js
index 6e62c1b816..e23d2ae5d2 100644
--- a/tests/e2e/searchpage.spec.js
+++ b/tests/e2e/searchpage.spec.js
@@ -2,30 +2,10 @@ import { test, expect } from '@playwright/test';
import {
SEARCH_PATH,
mockApiRootAndCollections,
- waitForMapReady,
- waitForBboxInputsPopulated,
waitForSearchPost
} from './helpers';
-const enableSpatialExtentInputs = async (page) => {
- const enableSpatialCheckbox = page.getByRole('checkbox', { name: /filter by spatial extent/i });
- await enableSpatialCheckbox.check();
- const southLatInput = page.getByLabel(/south latitude/i);
- await expect(southLatInput).toBeVisible();
- return southLatInput;
-};
-
-const fillBboxInputs = async (page, values) => {
- const westLonInput = page.getByLabel(/west longitude/i);
- const southLatInput = page.getByLabel(/south latitude/i);
- const eastLonInput = page.getByLabel(/east longitude/i);
- const northLatInput = page.getByLabel(/north latitude/i);
-
- if (values.westLon != null) await westLonInput.fill(values.westLon);
- if (values.southLat != null) await southLatInput.fill(values.southLat);
- if (values.eastLon != null) await eastLonInput.fill(values.eastLon);
- if (values.northLat != null) await northLatInput.fill(values.northLat);
-};
+// NOTE: Spatial extent helpers removed - all climate models in this catalog are global
test.describe('STAC Browser Search page', () => {
test('Should load the Search page successfully', async ({ page }) => {
@@ -81,176 +61,8 @@ test.describe('STAC Browser Search page', () => {
});
});
- test('Search with spatial extent via map click should have valid POST body', async ({ page }) => {
- await mockApiRootAndCollections(page);
- await page.goto(SEARCH_PATH);
-
- await test.step('Enable spatial extent selection and click on map to create bounding box', async () => {
- const enableSpatialCheckbox = page.getByRole('checkbox', { name: /filter by spatial extent/i });
- await enableSpatialCheckbox.check();
-
- const mapViewport = await waitForMapReady(page);
-
- // Click on the map to create a bounding box
- await mapViewport.click({ position: { x: 300, y: 200 } });
-
- await waitForBboxInputsPopulated(page);
- });
-
- await test.step('Submit search and verify POST body contains a bbox', async () => {
- const submitButton = page.getByRole('button', { name: /submit/i });
- const requestPromise = waitForSearchPost(page);
-
- await submitButton.click();
-
- const { body } = await requestPromise;
- expect(body.bbox).toHaveLength(4);
- expect(body.bbox[0]).toBeLessThan(body.bbox[2]);
- expect(body.bbox[1]).toBeLessThan(body.bbox[3]);
- });
- });
-
- test('Search with spatial extent selection via manual input should have valid POST body', async ({ page }) => {
- await mockApiRootAndCollections(page);
- await page.goto(SEARCH_PATH);
-
- await test.step('Enable spatial extent selection and fill in bounding box values', async () => {
- const enableSpatialCheckbox = page.getByRole('checkbox', { name: /filter by spatial extent/i });
-
- await enableSpatialCheckbox.check();
-
- // Wait for network to be idle to ensure UI is ready
- await page.waitForLoadState('networkidle');
-
- // Fill in bounding box values
- await page.getByLabel(/west longitude/i).fill( '-116.1' );
- await page.getByLabel(/south latitude/i).fill( '44.3' );
- await page.getByLabel(/east longitude/i).fill( '-104' );
- await page.getByLabel(/north latitude/i).fill( '49' );
- await page.getByLabel(/north latitude/i).blur();
- });
-
- await test.step('Submit search and verify POST body contains correct bbox', async () => {
- const submitButton = page.getByRole('button', { name: /submit/i });
-
- const requestPromise = waitForSearchPost(page);
-
- await submitButton.click();
-
- const { body } = await requestPromise;
-
- // use toBeCloseTo for floating point comparisons
- expect(body.bbox[0]).toBeCloseTo(-116.1, 2);
- expect(body.bbox[1]).toBeCloseTo(44.3, 2);
- expect(body.bbox[2]).toBeCloseTo(-104, 2);
- expect(body.bbox[3]).toBeCloseTo(49, 2);
- });
- });
-
- test('Manual spatial extent shows incomplete error', async ({ page }) => {
- await mockApiRootAndCollections(page);
- await page.goto(SEARCH_PATH);
-
- await test.step('Enter 3 of 4 bounding box values', async () => {
- await enableSpatialExtentInputs(page);
- // Fill page with values
- await fillBboxInputs(page, {
- westLon: '-116.1',
- southLat: '44.3',
- eastLon: '-104',
- northLat: '60.0'
- });
- // Remove a value
- await fillBboxInputs(page, {
- southLat: ''
- });
- })
-
- await test.step('Verify error message appears with correct text', async () => {
- await page.getByLabel(/south latitude/i).blur();
-
- await expect(page.getByText(/Coordinate is missing/i)).toBeVisible();
- });
- });
-
- test('Manual spatial extent shows invalid latitude error', async ({ page }) => {
- await mockApiRootAndCollections(page);
- await page.goto(SEARCH_PATH);
-
- await enableSpatialExtentInputs(page);
-
- await fillBboxInputs(page, {
- westLon: '-116.1',
- southLat: '-100',
- eastLon: '-104',
- northLat: '49'
- });
-
- await page.getByLabel(/north latitude/i).blur();
-
- await expect(page.getByText(/Latitude must be between -90 and 90/i)).toBeVisible();
- });
-
- test('Manual spatial extent shows latitude order error', async ({ page }) => {
- await mockApiRootAndCollections(page);
- await page.goto(SEARCH_PATH);
-
- await test.step('Test south latitude > north latitude error', async () => {
- await enableSpatialExtentInputs(page);
-
- await fillBboxInputs(page, {
- westLon: '-116.1',
- southLat: '49',
- eastLon: '-104',
- northLat: '44.3'
- });
-
- await page.getByLabel(/north latitude/i).blur();
-
- await expect(page.getByText(/South Latitude must be less than North Latitude/i)).toBeVisible();
- });
- });
-
- test('Manual spatial extent shows longitude order error when west is east of east', async ({ page }) => {
- await mockApiRootAndCollections(page);
- await page.goto(SEARCH_PATH);
-
- await test.step('Test west longitude > east longitude error (both negative)', async () => {
- await enableSpatialExtentInputs(page);
-
- await fillBboxInputs(page, {
- westLon: '-80',
- southLat: '10',
- eastLon: '-120',
- northLat: '20'
- });
-
- await page.getByLabel(/north latitude/i).blur();
-
- await expect(page.getByText(/West Longitude must be less than East Longitude/i)).toBeVisible();
- });
- });
-
- test('Manual spatial extent allows antimeridian crossing (positive west, negative east)', async ({ page }) => {
- await mockApiRootAndCollections(page);
- await page.goto(SEARCH_PATH);
-
- await test.step('Allow west > east when crossing antimeridian', async () => {
- await enableSpatialExtentInputs(page);
-
- await fillBboxInputs(page, {
- westLon: '170',
- southLat: '-10',
- eastLon: '-170',
- northLat: '10'
- });
-
- await page.getByLabel(/north latitude/i).blur();
-
- await expect(page.getByText(/West Longitude must be less than East Longitude/i)).toHaveCount(0);
- });
- });
-
+ // NOTE: Spatial extent tests removed - all climate models in this catalog are global
+ // Spatial filtering may be re-added if paleoclimate proxies are included
test('Search with Collection ID should have valid POST body', async ({ page }) => {
await mockApiRootAndCollections(page);
diff --git a/vite.config.js b/vite.config.js
index b16c053bf2..250440c3a6 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -34,6 +34,7 @@ const optionsForType = (type) =>
const env = yargs()
.parserConfiguration({ "camel-case-expansion": false })
.env("SB")
+ .option("CONFIG", { type: "string", description: "Path to config file" })
.boolean(optionsForType("boolean"))
.number(optionsForType("number").concat(optionsForType("integer")))
.array(optionsForType("array"))
@@ -47,7 +48,8 @@ delete env._;
delete env.$0;
// For config.js, you need to use dynamic import
-const configFilePath = "file://" + path.resolve(env.CONFIG ? env.CONFIG : "./config.js");
+// Note: yargs lowercases option names, so SB_CONFIG becomes env.config
+const configFilePath = "file://" + path.resolve(env.config ? env.config : "./config.js");
// Note: This makes the config async - you'll need to handle this
let configFromFile;
@@ -124,6 +126,7 @@ export default defineConfig(({ mode }) => ({
BFormInvalidFeedback: true,
BFormSelect: true,
BFormCheckbox: true,
+ BFormCheckboxGroup: true,
BFormRadio: true,
BFormRadioGroup: true,
BInputGroup: true,
@@ -169,6 +172,7 @@ export default defineConfig(({ mode }) => ({
},
},
server: {
- port: 8080,
+ port: 8080,
+ allowedHosts: true,
},
}));