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 @@ + + + + + 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 @@
- + + +
+ Compare + +
+ +
+ {{ comp }} +
{{ $t('deprecated') }} {{ format }} {{ summarizeDescription }} + + + + {{ paleoDisplay }} + + CO2: {{ co2Display }} + {{ runLength }} + + {{ resolution }} + + {{ dataVolume }} + + +
+ + DOI + + + Cite + +
@@ -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 @@ +