diff --git a/.github/workflows/algorithm-catalog-portal-pr.yml b/.github/workflows/algorithm-catalog-portal-pr.yml index 29182e8f..5a0ba521 100644 --- a/.github/workflows/algorithm-catalog-portal-pr.yml +++ b/.github/workflows/algorithm-catalog-portal-pr.yml @@ -65,6 +65,8 @@ jobs: git config user.name "veda-algorithm-catalog" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" cp .env.example .env.local + # cwebp: page images are re-encoded to the portal's image standard (its PRs #556/#654). + sudo apt-get install -y --no-install-recommends webp > /dev/null corepack enable pnpm install --frozen-lockfile --ignore-scripts diff --git a/algorithm-catalog/data/algorithms.json b/algorithm-catalog/data/algorithms.json index 9bfb8d3d..94797ce2 100644 --- a/algorithm-catalog/data/algorithms.json +++ b/algorithm-catalog/data/algorithms.json @@ -52,7 +52,11 @@ "Landslide", "Volcano" ], - "portalData": "DATA__LANDSAT_TRUE_COLOR" + "portalData": "DATA__LANDSAT_TRUE_COLOR", + "stacAsset": "truecolor", + "stacCollections": [ + "landsat-truecolor-daily" + ] }, { "id": "pan", @@ -78,7 +82,11 @@ "Landslide" ], "primaryHazards": [], - "portalData": "DATA__LANDSAT_NATURAL_COLOR" + "portalData": "DATA__LANDSAT_NATURAL_COLOR", + "stacAsset": "naturalcolor", + "stacCollections": [ + "landsat-naturalcolor-daily" + ] }, { "id": "colorIR", @@ -96,7 +104,11 @@ "Fire", "Landslide" ], - "portalData": "DATA__LANDSAT_COLOR_INFRARED" + "portalData": "DATA__LANDSAT_COLOR_INFRARED", + "stacAsset": "colorir", + "stacCollections": [ + "landsat-colorir-daily" + ] }, { "id": "ndvi", @@ -114,7 +126,11 @@ "Drought", "Heat" ], - "portalData": "DATA__LANDSAT_NDVI" + "portalData": "DATA__LANDSAT_NDVI", + "stacAsset": "ndvi", + "stacCollections": [ + "landsat-ndvi-daily" + ] }, { "id": "ndwi", @@ -140,7 +156,11 @@ "Flood", "TropicalCyclone" ], - "portalData": "DATA__LANDSAT_MNDWI" + "portalData": "DATA__LANDSAT_MNDWI", + "stacAsset": "mndwi", + "stacCollections": [ + "landsat-mndwi-daily" + ] }, { "id": "evi", @@ -164,7 +184,11 @@ "primaryHazards": [ "Fire" ], - "portalData": "DATA__LANDSAT_NBR" + "portalData": "DATA__LANDSAT_NBR", + "stacAsset": "nbr", + "stacCollections": [ + "landsat-nbr-daily" + ] }, { "id": "we", @@ -334,7 +358,12 @@ "Volcano", "WinterWx" ], - "portalData": "DATA__SENTINEL_2_TRUE_COLOR" + "portalData": "DATA__SENTINEL_2_TRUE_COLOR", + "stacAsset": "truecolor", + "stacCollections": [ + "sentinel2-truecolor-daily", + "sentinel2-truecolor-subdaily" + ] }, { "id": "nat", @@ -347,7 +376,11 @@ "Landslide" ], "primaryHazards": [], - "portalData": "DATA__SENTINEL_2_NATURAL_COLOR" + "portalData": "DATA__SENTINEL_2_NATURAL_COLOR", + "stacAsset": "naturalcolor", + "stacCollections": [ + "sentinel2-naturalcolor-daily" + ] }, { "id": "swir", @@ -363,7 +396,12 @@ "Fire", "Volcano" ], - "portalData": "DATA__SENTINEL_2_SWIR" + "portalData": "DATA__SENTINEL_2_SWIR", + "stacAsset": "swir", + "stacCollections": [ + "sentinel2-swir-daily", + "sentinel2-swir-subdaily" + ] }, { "id": "colorIR", @@ -381,7 +419,12 @@ "Fire", "Flood" ], - "portalData": "DATA__SENTINEL_2_COLOR_INFRARED" + "portalData": "DATA__SENTINEL_2_COLOR_INFRARED", + "stacAsset": "colorir", + "stacCollections": [ + "sentinel2-colorir-daily", + "sentinel2-colorir-subdaily" + ] }, { "id": "ndvi", @@ -425,7 +468,11 @@ "Flood", "TropicalCyclone" ], - "portalData": "DATA__SENTINEL_2_MNDWI" + "portalData": "DATA__SENTINEL_2_MNDWI", + "stacAsset": "mndwi", + "stacCollections": [ + "sentinel2-mndwi-subdaily" + ] }, { "id": "nbr", @@ -437,7 +484,11 @@ "primaryHazards": [ "Fire" ], - "portalData": "DATA__SENTINEL_2_NBR" + "portalData": "DATA__SENTINEL_2_NBR", + "stacAsset": "nbr", + "stacCollections": [ + "sentinel2-nbr-daily" + ] }, { "id": "we", @@ -572,7 +623,11 @@ "TropicalCyclone", "Landslide" ], - "portalData": "DATA__SATELLOGIC_TRUE_COLOR" + "portalData": "DATA__SATELLOGIC_TRUE_COLOR", + "stacAsset": "truecolor", + "stacCollections": [ + "satellogic-truecolor-subdaily" + ] }, { "id": "colorir", @@ -586,7 +641,11 @@ "Landslide" ], "primaryHazards": [], - "portalData": "DATA__SATELLOGIC_COLOR_INFRARED" + "portalData": "DATA__SATELLOGIC_COLOR_INFRARED", + "stacAsset": "colorir", + "stacCollections": [ + "satellogic-colorir-subdaily" + ] }, { "id": "ndvi", @@ -733,7 +792,11 @@ "Earthquake", "Storm" ], - "portalData": "DATA__UMBRA_SIGMA_NAUGHT" + "portalData": "DATA__UMBRA_SIGMA_NAUGHT", + "stacAsset": "backscatter", + "stacCollections": [ + "umbra-gec-subdaily" + ] }, { "id": "beta", @@ -890,7 +953,11 @@ "Earthquake", "Storm" ], - "portalData": "DATA__CAPELLA_BACKSCATTER" + "portalData": "DATA__CAPELLA_BACKSCATTER", + "stacAsset": "backscatter", + "stacCollections": [ + "capella-backscatter-subdaily" + ] } ], "inputs": [ @@ -976,7 +1043,12 @@ "Storm", "WinterWx" ], - "portalData": "DATA__BLACK_MARBLE_HD" + "portalData": "DATA__BLACK_MARBLE_HD", + "stacAsset": "blackmarble_hd", + "stacCollections": [ + "blackmarble-hd-composite", + "blackmarble-hd-daily" + ] }, { "id": "hdnightlightscolored", @@ -1216,7 +1288,11 @@ ], "note": "Prototype — produced via notebook, not DPS.", "primaryHazards": [], - "portalData": "DATA__ICEYE_BACKSCATTER" + "portalData": "DATA__ICEYE_BACKSCATTER", + "stacAsset": "backscatter", + "stacCollections": [ + "iceye-backscatter-subdaily" + ] } ], "inputs": [ @@ -1235,5 +1311,1203 @@ "doc": "Activation event, e.g. 202511_Flood_TX." } ] + }, + { + "id": "opera", + "algorithmName": "", + "version": "", + "title": "OPERA", + "modality": "sar", + "status": "external", + "vendor": "NASA JPL OPERA (Sentinel-1 / HLS)", + "resolution": "30 m", + "temporalStart": "2014-04-03", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/opera.png", + "thumbCredit": "NASA Disasters Portal data gallery — OPERA RTC RGB Mosaic", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/opera-disp-s1-coherence", + "description": "NASA JPL's OPERA analysis-ready products: surface displacement (DISP-S1), surface disturbance (DIST-S1, DIST-ALERT), dynamic surface water extent (DSWx) and terrain-corrected backscatter (RTC). Most are derived from Sentinel-1 C-band SAR; DSWx and DIST-ALERT also use Harmonized Landsat Sentinel-2 (HLS) optical observations. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "opera", + "jpl", + "sentinel-1", + "hls", + "sar", + "insar", + "displacement", + "disturbance", + "surface-water", + "flood" + ], + "products": [ + { + "id": "dispCoherence", + "label": "OPERA Displacement Coherence (DISP-S1)", + "folder": "dispCoherence", + "hazards": [ + "Earthquake" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DISP_S1_COHERENCE", + "stacAsset": "coherence", + "stacCollections": [ + "opera-disp-s1-coherence-daily" + ] + }, + { + "id": "dispRng", + "label": "OPERA Displacement Range Change (DISP-S1 RNG)", + "folder": "dispRangeChange", + "hazards": [ + "Earthquake" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DISP_S1_RNG", + "stacAsset": "rng", + "stacCollections": [ + "opera-disp-s1-rng-daily" + ] + }, + { + "id": "dispUnw", + "label": "OPERA Unwrapped Interferometric Phase (DISP-S1 UNW)", + "folder": "dispUnwrappedPhase", + "hazards": [ + "Earthquake" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DISP_S1_UNW", + "stacAsset": "unw", + "stacCollections": [ + "opera-disp-s1-unw-daily" + ] + }, + { + "id": "dispWrp", + "label": "OPERA Wrapped Interferometric Phase (DISP-S1 WRP)", + "folder": "dispWrappedPhase", + "hazards": [ + "Earthquake" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DISP_S1_WRP", + "stacAsset": "wrp", + "stacCollections": [ + "opera-disp-s1-wrp-daily" + ] + }, + { + "id": "distVegAnom", + "label": "OPERA Maximum Vegetation Anomaly (DIST-ALERT VEG-ANOM-MAX)", + "folder": "distAlertVegAnom", + "hazards": [ + "Fire", + "Flood", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DIST_ALERT_DIST_VEG_ANOM", + "stacAsset": "veg-anom-max", + "stacCollections": [ + "opera-distalert-veg-anom-max-daily" + ] + }, + { + "id": "distVegStatus", + "label": "OPERA Vegetation Disturbance Status (DIST-ALERT VEG-DIST-STATUS)", + "folder": "distAlertVegStatus", + "hazards": [ + "Fire", + "Flood", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DIST_ALERT_DIST_VEG_STATUS", + "stacAsset": "veg-dist-status", + "stacCollections": [ + "opera-distalert-veg-dist-status-daily" + ] + }, + { + "id": "genDistStatus", + "label": "OPERA Surface Disturbance Status (DIST-ALERT GEN-DIST-STATUS)", + "folder": "distAlertGenStatus", + "hazards": [ + "Fire", + "Flood", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DIST_ALERT_GEN_DIST_STATUS", + "stacAsset": "gen-dist-status", + "stacCollections": [ + "opera-distalert-gen-dist-status-daily" + ] + }, + { + "id": "distS1", + "label": "OPERA Surface Disturbance from Sentinel-1 (DIST-S1)", + "folder": "distS1", + "hazards": [ + "Fire" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DIST_S1", + "stacAsset": "dist-s1", + "stacCollections": [ + "opera-distalert-s1-daily" + ] + }, + { + "id": "dswx", + "label": "OPERA Dynamic Surface Water Extent (DSWx)", + "folder": "dswx", + "hazards": [ + "Flood", + "TropicalCyclone", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_DSWX" + }, + { + "id": "rtcRgb", + "label": "OPERA RTC RGB Mosaic", + "folder": "rtcRgbMosaic", + "hazards": [ + "Fire", + "Flood", + "TropicalCyclone", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__OPERA_RTC_RGB_MOSAIC", + "stacAsset": "rgb", + "stacCollections": [ + "opera-rtc-mosaic-rgb-daily" + ] + } + ], + "inputs": [] + }, + { + "id": "uavsar", + "algorithmName": "", + "version": "", + "title": "UAVSAR", + "modality": "sar", + "status": "external", + "vendor": "NASA JPL (airborne)", + "resolution": "~6 m (airborne; varies by product)", + "temporalStart": "2009-01-01", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/uavsar.png", + "thumbCredit": "NASA Disasters Portal data gallery — UAVSAR Polarimetric False Color Imagery", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/uavsar-rgb", + "description": "NASA JPL's Uninhabited Aerial Vehicle Synthetic Aperture Radar: an airborne L-band, fully polarimetric SAR flown on a Gulfstream III. Products include polarimetric false color, flood classifications (quicklook and U-Net) and repeat-pass surface displacement. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "uavsar", + "jpl", + "airborne", + "l-band", + "sar", + "polarimetric", + "flood", + "displacement" + ], + "products": [ + { + "id": "rgb", + "label": "UAVSAR Polarimetric False Color Imagery", + "folder": "polarimetricRgb", + "hazards": [ + "Flood", + "TropicalCyclone", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__UAVSAR_RGB", + "stacAsset": "rgb", + "stacCollections": [ + "uavsar-rgb-composite", + "uavsar-rgb-subdaily" + ] + }, + { + "id": "quicklookClassified", + "label": "UAVSAR Quicklook Flood Classification", + "folder": "quicklookClassified", + "hazards": [ + "Flood", + "TropicalCyclone", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__UAVSAR_QUICKLOOK_CLASSIFIED", + "stacAsset": "quicklookclassified", + "stacCollections": [ + "uavsar-quicklookclassified-subdaily" + ] + }, + { + "id": "unetClassified", + "label": "UAVSAR U-Net Flood Classification", + "folder": "unetClassified", + "hazards": [ + "Flood", + "TropicalCyclone", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__UAVSAR_UNET_CLASSIFIED", + "stacAsset": "unetclassified", + "stacCollections": [ + "uavsar-unetclassified-composite", + "uavsar-unetclassified-daily" + ] + }, + { + "id": "unetGrayscale", + "label": "UAVSAR U-Net Flood Classification (Grayscale)", + "folder": "unetClassifiedGrayscale", + "hazards": [ + "Flood", + "TropicalCyclone", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__UAVSAR_UNET_CLASSIFIED_GRAYSCALE", + "stacAsset": "unetclassifiedgrayscale", + "stacCollections": [ + "uavsar-unetclassifiedgrayscale-composite", + "uavsar-unetclassifiedgrayscale-daily" + ] + }, + { + "id": "displacement", + "label": "UAVSAR Surface Displacement", + "folder": "displacement", + "hazards": [ + "Earthquake", + "Storm", + "Volcano" + ], + "primaryHazards": [], + "portalData": "DATA__UAVSAR_DISPLACEMENT", + "stacAsset": "displacement", + "stacCollections": [ + "uavsar-displacement-daily" + ] + } + ], + "inputs": [] + }, + { + "id": "aviris3", + "algorithmName": "", + "version": "", + "title": "AVIRIS-3", + "modality": "optical", + "status": "external", + "vendor": "NASA JPL (airborne)", + "resolution": "sub-meter to 13 m (airborne; varies with altitude)", + "temporalStart": "2023-01-01", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/aviris3.png", + "thumbCredit": "NASA Disasters Portal data gallery — AVIRIS-3 Principal Component Analysis (PCA)", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/aviris-3-quicklook-color-imagery", + "description": "NASA JPL's Airborne Visible/Infrared Imaging Spectrometer 3rd-Generation: airborne imaging spectroscopy across 284 wavelengths, visible to shortwave infrared. Products map char and ash, burn severity (dNBR), spectral differences (PCA) and quicklook false color. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "aviris-3", + "aviris", + "jpl", + "airborne", + "imaging-spectroscopy", + "hyperspectral", + "fire", + "burn-severity" + ], + "products": [ + { + "id": "quicklook", + "label": "AVIRIS-3 Quicklook False Color Imagery", + "folder": "quicklookFalseColor", + "hazards": [ + "Fire" + ], + "primaryHazards": [], + "portalData": "DATA__AVIRIS_3_QUICKLOOK_COLOR_IMAGERY" + }, + { + "id": "charAsh", + "label": "AVIRIS-3 Relative Char and Ash", + "folder": "charAndAsh", + "hazards": [ + "Fire" + ], + "primaryHazards": [], + "portalData": "DATA__AVIRIS_3_CHAR_AND_ASH", + "stacAsset": "charash", + "stacCollections": [ + "aviris3-charash-daily" + ] + }, + { + "id": "dnbr", + "label": "AVIRIS-3 Differenced Normalized Burn Ratio (dNBR)", + "folder": "dnbr", + "hazards": [ + "Fire" + ], + "primaryHazards": [], + "portalData": "DATA__AVIRIS_3_DNBR", + "stacAsset": "dnbr", + "stacCollections": [ + "aviris3-dnbr-daily" + ] + }, + { + "id": "pca", + "label": "AVIRIS-3 Principal Component Analysis (PCA)", + "folder": "pca", + "hazards": [ + "Fire" + ], + "primaryHazards": [], + "portalData": "DATA__AVIRIS_3_PCA", + "stacAsset": "pca", + "stacCollections": [ + "aviris3-pca-daily" + ] + } + ], + "inputs": [] + }, + { + "id": "planet", + "algorithmName": "", + "version": "", + "title": "PlanetScope", + "modality": "optical", + "status": "external", + "vendor": "Planet Labs PBC (NASA CSDA)", + "resolution": "~3 m", + "temporalStart": "2016-01-01", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/planet.png", + "thumbCredit": "Planet Labs PBC (NASA CSDA), via the NASA Disasters Portal data gallery — Planet True Color Imagery", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/planet-true-color", + "description": "PlanetScope Dove and SuperDove optical imagery from Planet Labs PBC, acquired through NASA's Commercial Satellite Data Acquisition (CSDA) Program: true color plus NDVI, NDVI change and NDWI. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "planet", + "planetscope", + "commercial", + "csda", + "ndvi", + "ndwi", + "flood", + "fire" + ], + "products": [ + { + "id": "true", + "label": "Planet True Color Imagery", + "folder": "trueColor", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__PLANET_TRUE_COLOR", + "stacAsset": "truecolor", + "stacCollections": [ + "planet-truecolor-daily" + ] + }, + { + "id": "ndvi", + "label": "Planet Normalized Difference Vegetation Index (NDVI)", + "folder": "ndvi", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__PLANET_NDVI", + "stacAsset": "ndvi", + "stacCollections": [ + "planet-ndvi-daily" + ] + }, + { + "id": "ndviChange", + "label": "Planet NDVI Change", + "folder": "ndviChange", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__PLANET_NDVI_CHANGE", + "stacAsset": "ndvichange", + "stacCollections": [ + "planet-ndvichange-composite" + ] + }, + { + "id": "ndwi", + "label": "Planet Normalized Difference Water Index (NDWI)", + "folder": "ndwi", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__PLANET_NDWI", + "stacAsset": "ndwi", + "stacCollections": [ + "planet-ndwi-daily" + ] + } + ], + "inputs": [] + }, + { + "id": "vantor", + "algorithmName": "", + "version": "", + "title": "Vantor", + "modality": "optical", + "status": "external", + "vendor": "Vantor, formerly Maxar (WorldView-2/3)", + "resolution": "30 cm panchromatic; ~35 cm multispectral", + "temporalStart": "2009-10-08", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/vantor.png", + "thumbCredit": "Vantor (formerly Maxar), via the NASA Disasters Portal data gallery — Vantor True Color", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/vantor-true-color", + "description": "Very high resolution WorldView-2 and WorldView-3 optical imagery from Vantor (formerly Maxar): true color, color infrared and 30 cm panchromatic, for building-scale damage assessment. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "vantor", + "maxar", + "worldview", + "commercial", + "very-high-resolution", + "damage" + ], + "products": [ + { + "id": "true", + "label": "Vantor True Color", + "folder": "trueColor", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__VANTOR_TRUE_COLOR", + "stacAsset": "truecolor", + "stacCollections": [ + "vantor-truecolor-subdaily" + ] + }, + { + "id": "colorIR", + "label": "Vantor Color Infrared", + "folder": "colorInfrared", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__VANTOR_COLOR_INFRARED", + "stacAsset": "colorir", + "stacCollections": [ + "vantor-colorir-subdaily" + ] + }, + { + "id": "pan", + "label": "Vantor Panchromatic", + "folder": "panchromatic", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Landslide", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__VANTOR_PANCHROMATIC", + "stacAsset": "panchromatic", + "stacCollections": [ + "vantor-panchromatic-subdaily" + ] + } + ], + "inputs": [] + }, + { + "id": "skysat", + "algorithmName": "", + "version": "", + "title": "SkySat", + "modality": "optical", + "status": "external", + "vendor": "Planet Labs PBC (NASA CSDA)", + "resolution": "~50 cm", + "temporalStart": "2013-11-21", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/skysat.png", + "thumbCredit": "Planet Labs PBC (NASA CSDA), via the NASA Disasters Portal data gallery — Planet SkySat True Color Imagery", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/skysat-true-color", + "description": "Very high resolution optical imagery from Planet's SkySat constellation, acquired through NASA's Commercial Satellite Data Acquisition (CSDA) Program: true color and color infrared. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "skysat", + "planet", + "commercial", + "csda", + "very-high-resolution" + ], + "products": [ + { + "id": "true", + "label": "Planet SkySat True Color Imagery", + "folder": "trueColor", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__SKYSAT_TRUE_COLOR", + "stacAsset": "truecolor", + "stacCollections": [ + "skysat-truecolor-subdaily" + ] + }, + { + "id": "colorIR", + "label": "Planet SkySat Color Infrared Imagery", + "folder": "colorInfrared", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__SKYSAT_COLOR_INFRARED", + "stacAsset": "colorir", + "stacCollections": [ + "skysat-colorir-subdaily" + ] + } + ], + "inputs": [] + }, + { + "id": "ecostress", + "algorithmName": "", + "version": "", + "title": "ECOSTRESS", + "modality": "optical", + "status": "external", + "vendor": "NASA JPL (ISS)", + "resolution": "70 m", + "temporalStart": "2018-06-29", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/ecostress.png", + "thumbCredit": "NASA Disasters Portal data gallery — ECOSTRESS Land Surface Temperature", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/ecostress-land-surface-temperature", + "description": "Atmospherically corrected land surface temperature from the ECOSTRESS thermal infrared radiometer on the International Space Station. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "ecostress", + "jpl", + "iss", + "thermal-infrared", + "land-surface-temperature", + "heat", + "fire" + ], + "products": [ + { + "id": "lst", + "label": "ECOSTRESS Land Surface Temperature", + "folder": "landSurfaceTemperature", + "hazards": [ + "Fire", + "Heat" + ], + "primaryHazards": [], + "portalData": "DATA__ECOSTRESS_LAND_SURFACE_TEMPERATURE", + "stacAsset": "lst", + "stacCollections": [ + "ecostress-lst-subdaily" + ] + } + ], + "inputs": [] + }, + { + "id": "gpm_imerg", + "algorithmName": "", + "version": "", + "title": "GPM IMERG", + "modality": "model", + "status": "external", + "vendor": "NASA GSFC / GES DISC", + "resolution": "0.1° (~10 km), daily accumulation", + "temporalStart": "2000-06-01", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/gpm_imerg.png", + "thumbCredit": "NASA Disasters Portal data gallery — GPM IMERG Accumulated Precipitation", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/gpm-imerg-precipitation", + "description": "Daily accumulated precipitation from NASA's Integrated Multi-satellitE Retrievals for GPM (IMERG): a near-real-time global rainfall estimate blending passive microwave, radar and infrared satellite observations onto a 0.1° grid. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "gpm", + "imerg", + "precipitation", + "rainfall", + "giovanni", + "flood", + "storm" + ], + "products": [ + { + "id": "precip", + "label": "GPM IMERG Accumulated Precipitation", + "folder": "accumulatedPrecipitation", + "hazards": [ + "Flood", + "TropicalCyclone", + "Storm", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__GPM_IMERG_PRECIPITATION", + "stacAsset": "precipitation", + "stacCollections": [ + "gpm-imerg-daily" + ] + } + ], + "inputs": [] + }, + { + "id": "hydrosar", + "algorithmName": "", + "version": "", + "title": "HydroSAR", + "modality": "sar", + "status": "external", + "vendor": "ASF DAAC / University of Alaska Fairbanks", + "resolution": "30 m", + "temporalStart": "2014-04-03", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/hydrosar.png", + "thumbCredit": "NASA Disasters Portal data gallery — HydroSAR Sentinel-1 Water Extent Classifications", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/hydrosar-s1-classifications", + "description": "HydroSAR water extent classifications from Sentinel-1 C-band SAR: post-event open water and inundated developed, vegetated and agricultural areas. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "hydrosar", + "asf", + "sentinel-1", + "sar", + "flood", + "water-extent" + ], + "products": [ + { + "id": "classifications", + "label": "HydroSAR Sentinel-1 Water Extent Classifications", + "folder": "waterExtentClassifications", + "hazards": [ + "Flood", + "TropicalCyclone", + "Storm" + ], + "primaryHazards": [], + "portalData": "DATA__HYDROSAR_S1_CLASSIFICATIONS" + } + ], + "inputs": [] + }, + { + "id": "nisar", + "algorithmName": "", + "version": "", + "title": "NISAR", + "modality": "sar", + "status": "external", + "vendor": "NASA JPL / ISRO", + "resolution": "80 m", + "temporalStart": "2025-07-30", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/nisar.png", + "thumbCredit": "NASA Disasters Portal data gallery — NISAR GUNW Surface Displacement", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/nisar-gunw-displacement", + "description": "Surface displacement along the radar line of sight from the NASA-ISRO NISAR L-band SAR Geocoded Unwrapped Interferogram (GUNW) product. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "nisar", + "isro", + "jpl", + "l-band", + "sar", + "insar", + "displacement", + "earthquake" + ], + "products": [ + { + "id": "gunwDisplacement", + "label": "NISAR GUNW Surface Displacement", + "folder": "gunwDisplacement", + "hazards": [ + "Earthquake" + ], + "primaryHazards": [], + "portalData": "DATA__NISAR_GUNW_DISPLACEMENT", + "stacAsset": "gunw", + "stacCollections": [ + "nisar-gunw-composite" + ] + } + ], + "inputs": [] + }, + { + "id": "gaia", + "algorithmName": "", + "version": "", + "title": "GAIA", + "modality": "model", + "status": "external", + "vendor": "ImageCat, Inc. (NASA Disasters Program)", + "resolution": "100 m grid", + "temporalStart": "2025-01-01", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/gaia.png", + "thumbCredit": "NASA Disasters Portal data gallery — Global Assessment of Infrastructure Assets (GAIA)", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/gaia-infrastructure-assets", + "description": "The Global Assessment of Infrastructure Assets: an open, global, modeled gridded building exposure dataset (built-up area per 100 m cell) for disaster risk assessment, response and recovery. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "gaia", + "imagecat", + "infrastructure", + "buildings", + "exposure", + "risk" + ], + "products": [ + { + "id": "buildingExposure", + "label": "Global Assessment of Infrastructure Assets (GAIA)", + "folder": "buildingExposure", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Landslide", + "Heat", + "WinterWx", + "Volcano" + ], + "primaryHazards": [], + "portalData": "DATA__GAIA" + } + ], + "inputs": [] + }, + { + "id": "wb57_dynamite", + "algorithmName": "", + "version": "", + "title": "WB-57 DyNAMITE", + "modality": "optical", + "status": "external", + "vendor": "NASA Johnson Space Center (airborne)", + "resolution": "~10 cm (varies with altitude)", + "temporalStart": "2025-01-01", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/wb57_dynamite.png", + "thumbCredit": "NASA Disasters Portal data gallery — WB-57 DyNAMITE Airborne Imagery", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/wb57-dynamite", + "description": "High-resolution visible and infrared imagery from the DyNAMITE instrument aboard NASA's WB-57 high-altitude aircraft, streamed to responders during flight. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "wb-57", + "dynamite", + "jsc", + "airborne", + "visible", + "infrared" + ], + "products": [ + { + "id": "imagery", + "label": "WB-57 DyNAMITE Airborne Imagery", + "folder": "airborneImagery", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Landslide", + "Volcano" + ], + "primaryHazards": [], + "portalData": "DATA__WB57_DYNAMITE" + } + ], + "inputs": [] + }, + { + "id": "blackmarble_composites", + "algorithmName": "", + "version": "", + "title": "Black Marble composites", + "modality": "nightlights", + "status": "external", + "vendor": "NASA GSFC / LANCE / U.S. Naval Research Laboratory", + "resolution": "~500 m", + "temporalStart": "2012-01-19", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/blackmarble_composites.png", + "thumbCredit": "NASA Disasters Portal data gallery — Black Marble Nighttime Blue/Yellow Composite", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/black-marble-blue-yellow", + "description": "VIIRS nighttime composites made outside the blackmarble DPS algorithms: the LANCE/NRL blue/yellow composite (lights, fires and clouds) and BRDF-corrected daily nighttime lights with a cloud-free monthly pre-event composite, for power outage and recovery assessment. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "black-marble", + "viirs", + "nighttime-lights", + "lance", + "nrl", + "brdf", + "power-outage" + ], + "products": [ + { + "id": "blueYellow", + "label": "Black Marble Nighttime Blue/Yellow Composite", + "folder": "blueYellowComposite", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Landslide", + "Heat", + "WinterWx", + "Volcano" + ], + "primaryHazards": [], + "portalData": "DATA__BLACK_MARBLE_BLUE_YELLOW" + }, + { + "id": "brdf", + "label": "Black Marble BRDF-Corrected Nighttime Lights", + "folder": "brdfCorrected", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Landslide", + "Heat", + "WinterWx", + "Volcano" + ], + "primaryHazards": [], + "portalData": "DATA__BLACK_MARBLE_BRDF", + "stacAsset": "blackmarble_brdf", + "stacCollections": [ + "blackmarble-brdf-composite", + "blackmarble-brdf-daily" + ] + } + ], + "inputs": [] + }, + { + "id": "s1_damage", + "algorithmName": "", + "version": "", + "title": "Sentinel-1 damage assessment", + "modality": "sar", + "status": "external", + "vendor": "CUNY Graduate Center / Oregon State University", + "resolution": "40 m", + "temporalStart": "2014-04-03", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/s1_damage.png", + "thumbCredit": "NASA Disasters Portal data gallery — Sentinel-1 Likely Damaged Areas", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/sentinel-1-damage-assessment", + "description": "Likely damaged or destroyed built-up areas from Sentinel-1 interferometric coherence change detection. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "sentinel-1", + "sar", + "insar", + "coherence", + "damage", + "fire" + ], + "products": [ + { + "id": "damage", + "label": "Sentinel-1 Likely Damaged Areas", + "folder": "likelyDamagedAreas", + "hazards": [ + "Fire" + ], + "primaryHazards": [], + "portalData": "DATA__SENTINEL_1_DAMAGE_ASSESSMENT", + "stacAsset": "damage", + "stacCollections": [ + "sentinel1-dmgassessment-daily" + ] + } + ], + "inputs": [] + }, + { + "id": "s1s2_burn", + "algorithmName": "", + "version": "", + "title": "Sentinel-1 + Sentinel-2 burn severity", + "modality": "optical", + "status": "external", + "vendor": "NASA Ames Research Center", + "resolution": "~10 m", + "temporalStart": "2015-06-23", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/s1s2_burn.png", + "thumbCredit": "NASA Disasters Portal data gallery — Sentinel-1 and Sentinel-2 Burn Severity", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/sentinel-1-sentinel-2-burn-severity", + "description": "Burned vegetation and urban areas with relative burn severity, combining Sentinel-2 optical reflectance change with Sentinel-1 SAR backscatter change. No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "sentinel-1", + "sentinel-2", + "sar", + "burn-severity", + "fire" + ], + "products": [ + { + "id": "burnSeverity", + "label": "Sentinel-1 and Sentinel-2 Burn Severity", + "folder": "burnSeverity", + "hazards": [ + "Fire" + ], + "primaryHazards": [], + "portalData": "DATA__SENTINEL_1_SENTINEL_2_BURN_SEVERITY", + "stacAsset": "burnseverity", + "stacCollections": [ + "sentinel1_2-burnseverity-daily" + ] + } + ], + "inputs": [] + }, + { + "id": "sentinel2_change", + "algorithmName": "", + "version": "", + "title": "Sentinel-2 change products", + "modality": "optical", + "status": "external", + "vendor": "NASA GSFC / USGS / ESA Copernicus", + "resolution": "10 m", + "temporalStart": "2015-06-23", + "temporalStartApprox": true, + "spatialSelector": "none", + "resources": { + "ramMin": 0, + "coresMin": 0, + "outdirMax": 0 + }, + "thumb": "/thumbs/sentinel2_change.png", + "thumbCredit": "NASA Disasters Portal data gallery — Sentinel-2 Differenced Normalized Burn Ratio (dNBR)", + "configUrl": "https://science.data.nasa.gov/disasters/data-gallery/sentinel-2-dnbr", + "description": "Two-date Sentinel-2 change products not made by the sentinel2 DPS algorithm: differenced Normalized Burn Ratio (dNBR) and binary NDVI change (a decrease of 0.25 or more). No algorithm in this catalog produces these layers yet: they are made outside it and published on the Disasters Portal, so a request records the need but creates no DPS job.", + "keywords": [ + "sentinel-2", + "change-detection", + "dnbr", + "ndvi", + "burn-severity", + "fire" + ], + "products": [ + { + "id": "dnbr", + "label": "Sentinel-2 Differenced Normalized Burn Ratio (dNBR)", + "folder": "dnbr", + "hazards": [ + "Fire" + ], + "primaryHazards": [], + "portalData": "DATA__SENTINEL_2_DNBR", + "stacAsset": "dnbr", + "stacCollections": [ + "sentinel2-dnbr-daily" + ] + }, + { + "id": "ndviChangeBinary", + "label": "Sentinel-2 Binary NDVI Change", + "folder": "ndviChangeBinary", + "hazards": [ + "Fire", + "Flood", + "Earthquake", + "TropicalCyclone", + "Storm", + "Heat", + "WinterWx" + ], + "primaryHazards": [], + "portalData": "DATA__SENTINEL_2_NDVI_CHANGE_BINARY", + "stacAsset": "ndvichange", + "stacCollections": [ + "sentinel2-ndvichange-daily" + ] + } + ], + "inputs": [] } ] diff --git a/algorithm-catalog/data/portal-cards.json b/algorithm-catalog/data/portal-cards.json new file mode 100644 index 00000000..86ec4c72 --- /dev/null +++ b/algorithm-catalog/data/portal-cards.json @@ -0,0 +1,1008 @@ +{ + "DATA__AVIRIS_3_CHAR_AND_ASH": { + "title": "AVIRIS-3 Relative Char and Ash", + "description": "The AVIRIS-3 Relative Char and Ash product estimates the fraction of each pixel covered by char and ash, using spectral unmixing of airborne imaging spectroscopy measurements.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/aviris-3-char-and-ash.webp" + }, + "DATA__AVIRIS_3_DNBR": { + "title": "AVIRIS-3 Differenced Normalized Burn Ratio (dNBR)", + "description": "The AVIRIS-3 Differenced Normalized Burn Ratio (dNBR) product uses high-resolution imaging spectroscopy to identify vegetation change associated with wildfire and provide a proxy for burn severity.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/aviris-3-dnbr.webp" + }, + "DATA__AVIRIS_3_PCA": { + "title": "AVIRIS-3 Principal Component Analysis (PCA)", + "description": "The AVIRIS-3 Principal Component Analysis (PCA) product compresses 284 bands of imaging spectroscopy into a three-color image, so surfaces that share a spectral signature — such as burned structures — share a color.", + "themes": [ + "respond" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/aviris-3-pca.webp" + }, + "DATA__AVIRIS_3_QUICKLOOK_COLOR_IMAGERY": { + "title": "AVIRIS-3 Quicklook False Color Imagery", + "description": "The AVIRIS-3 false color quicklook imagery provides at-sensor radiance data from high resolution, hyperspectral sensor which can provide a snapshot of burned areas, ash distribution and smoke emissions. ", + "themes": [ + "respond" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/aviris-3-quicklook-color-imagery.webp" + }, + "DATA__BLACK_MARBLE_BLUE_YELLOW": { + "title": "Black Marble Nighttime Blue/Yellow Composite", + "description": "The Black Marble Nighttime Blue/Yellow Composite uses VIIRS nighttime observations to visualize artificial lights, fires, and clouds, supporting assessment of potential power outages and other disaster impacts.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "earthquake", + "fire", + "flood", + "heat", + "landslide", + "severe weather", + "tropical cyclone", + "hurricane", + "typhoon", + "cyclone", + "volcano", + "tsunami", + "winter weather" + ], + "image": "/portal/cards/black-marble-blue-yellow.webp" + }, + "DATA__BLACK_MARBLE_BRDF": { + "title": "Black Marble BRDF-Corrected Nighttime Lights", + "description": "Daily VIIRS nighttime lights corrected for moonlight, atmosphere, and viewing geometry (BRDF), delivered with a cloud-free monthly pre-event composite and a daily cloud mask for assessing power outages and recovery after disasters.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "earthquake", + "fire", + "flood", + "heat", + "landslide", + "severe weather", + "tropical cyclone", + "hurricane", + "typhoon", + "cyclone", + "volcano", + "tsunami", + "winter weather" + ], + "image": "/portal/cards/black-marble-brdf.webp" + }, + "DATA__BLACK_MARBLE_HD": { + "title": "Black Marble HD", + "description": "HD Black Marble combines VIIRS nighttime lights with Landsat-derived land surface information and OpenStreetMap road networks to create enhanced 30-meter nighttime light imagery for disaster impact assessment.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/black-marble-hd.webp" + }, + "DATA__CAPELLA_BACKSCATTER": { + "title": "Capella SAR Backscatter", + "description": "Sigma Naught backscatter derived from high-resolution Capella Space synthetic aperture radar (SAR) imagery acquired through NASA's Commercial Satellite Data Acquisition (CSDA) Program.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/capella-backscatter.webp" + }, + "DATA__ECOSTRESS_LAND_SURFACE_TEMPERATURE": { + "title": "ECOSTRESS Land Surface Temperature", + "description": "Atmospherically corrected land surface temperature measured from the International Space Station by the ECOSTRESS thermal infrared radiometer, showing how hot the ground, vegetation, rooftops, and other surfaces actually are.", + "themes": [ + "respond", + "prepare", + "resilience" + ], + "categories": [ + "fire", + "heat" + ], + "image": "/portal/cards/ecostress-land-surface-temperature.webp" + }, + "DATA__GAIA": { + "title": "Global Assessment of Infrastructure Assets (GAIA)", + "description": "The Global Assessment of Infrastructure Assets (GAIA) provides gridded building exposure data to support disaster risk assessment, response, and recovery.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "earthquake", + "fire", + "flood", + "heat", + "landslide", + "severe weather", + "tropical cyclone", + "hurricane", + "typhoon", + "cyclone", + "volcano", + "tsunami", + "winter weather" + ], + "image": "/portal/cards/gaia.webp" + }, + "DATA__GPM_IMERG_PRECIPITATION": { + "title": "GPM IMERG Accumulated Precipitation", + "description": "Daily accumulated precipitation from NASA's Integrated Multi-satellitE Retrievals for GPM (IMERG), a near-real-time global rainfall estimate that blends passive microwave, radar, and infrared satellite observations onto a 0.1 degree grid.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "flood", + "tropical cyclone", + "winter weather" + ], + "image": "/portal/cards/gpm-imerg-precipitation.webp" + }, + "DATA__HYDROSAR_S1_CLASSIFICATIONS": { + "title": "HydroSAR Sentinel-1 Water Extent Classifications", + "description": "HydroSAR Sentinel-1 Water Extent Classifications identify post-event open water and inundated developed, vegetated, and agricultural areas from synthetic aperture radar observations.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "flood", + "tropical cyclone", + "severe weather" + ], + "image": "/portal/cards/hydrosar-s1-classifications.webp" + }, + "DATA__ICEYE_BACKSCATTER": { + "title": "ICEYE SAR Backscatter", + "description": "Sigma Naught backscatter derived from high-resolution ICEYE synthetic aperture radar (SAR) imagery acquired through NASA's Commercial Satellite Data Acquisition (CSDA) Program.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/iceye-backscatter.webp" + }, + "DATA__LANDSAT_COLOR_INFRARED": { + "title": "Landsat Color Infrared Imagery", + "description": "Created using near-infrared, red, and green channels to highlight vegetation, water, burned areas, and other surface changes associated with natural hazards.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/landsat-color-infrared.webp" + }, + "DATA__LANDSAT_MNDWI": { + "title": "Landsat Modified Normalized Difference Water Index (MNDWI)", + "description": "Modified Normalized Difference Water Index (MNDWI) uses green and shortwave-infrared reflectance to enhance open water features and suppress vegetation, soil, and built-up surfaces.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "flood", + "tropical cyclone", + "winter weather" + ], + "image": "/portal/cards/landsat-mndwi.webp" + }, + "DATA__LANDSAT_NATURAL_COLOR": { + "title": "Landsat Natural Color Imagery", + "description": "The Landsat Natural Color composite uses shortwave infrared, near-infrared, and red channels to emphasize vegetation, water, urban areas, and other surface features.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "earthquake", + "fire", + "flood", + "heat", + "landslide", + "severe weather", + "tropical cyclone", + "hurricane", + "typhoon", + "cyclone", + "volcano", + "tsunami", + "winter weather" + ], + "image": "/portal/cards/landsat-natural-color.webp" + }, + "DATA__LANDSAT_NBR": { + "title": "Landsat Normalized Burn Ratio (NBR)", + "description": "Normalized Burn Ratio (NBR) is defined mathematically as (NIR - SWIR)/(NIR + SWIR), where NIR is near-infrared and SWIR is shortwave infrared. NBR is commonly used as a proxy to identify areas of burned or charred vegetation.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/landsat-nbr.webp" + }, + "DATA__LANDSAT_NDVI": { + "title": "Landsat Normalized Difference Vegetation Index (NDVI)", + "description": "Normalized Difference Vegetation Index (NDVI) uses near-infrared and red reflectance to indicate the presence and relative condition of green vegetation.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "earthquake", + "fire", + "flood", + "heat", + "landslide", + "severe weather", + "tropical cyclone", + "hurricane", + "typhoon", + "cyclone", + "volcano", + "tsunami", + "winter weather" + ], + "image": "/portal/cards/landsat-ndvi.webp" + }, + "DATA__LANDSAT_TRUE_COLOR": { + "title": "Landsat True Color Imagery", + "description": "The Landsat True Color RGB composite provides a natural-looking view of the Earth's surface similar to how it would appear to the human eye.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/landsat-true-color.webp" + }, + "DATA__NISAR_GUNW_DISPLACEMENT": { + "title": "NISAR GUNW Surface Displacement", + "description": "The NISAR Geocoded Unwrapped Interferogram (GUNW) displacement product uses L-band synthetic aperture radar observations from two acquisitions to measure changes in the Earth's surface along the radar line of sight.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "earthquake" + ], + "image": "/portal/cards/nisar-gunw-displacement.webp" + }, + "DATA__OPERA_DISP_S1_COHERENCE": { + "title": "OPERA Displacement Coherence (DISP-S1)", + "description": "The OPERA Displacement Coherence product provides interferometric coherence imagery from Sentinel-1 acquisition pairs to indicate the consistency and reliability of radar measurements used for surface displacement analysis.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "earthquake" + ], + "image": "/portal/cards/opera-disp-s1-coherence.webp" + }, + "DATA__OPERA_DISP_S1_RNG": { + "title": "OPERA Displacement Range Change (DISP-S1 RNG)", + "description": "The OPERA DISP-S1 Range Change product provides Sentinel-1 interferometric measurements of relative surface motion along the radar line of sight between reference and secondary satellite acquisitions.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "earthquake" + ], + "image": "/portal/cards/opera-disp-s1-rng.webp" + }, + "DATA__OPERA_DISP_S1_UNW": { + "title": "OPERA Unwrapped Interferometric Phase (DISP-S1 UNW)", + "description": "The OPERA DISP-S1 Unwrapped Phase product provides a continuous representation of Sentinel-1 interferometric phase differences used to derive radar line-of-sight surface displacement.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "earthquake" + ], + "image": "/portal/cards/opera-disp-s1-unw.webp" + }, + "DATA__OPERA_DISP_S1_WRP": { + "title": "OPERA Wrapped Interferometric Phase (DISP-S1 WRP)", + "description": "The OPERA DISP-S1 Wrapped Phase product displays Sentinel-1 interferometric phase differences between acquisition pairs before phase unwrapping, highlighting spatial patterns associated with surface deformation.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "earthquake" + ], + "image": "/portal/cards/opera-disp-s1-wrp.webp" + }, + "DATA__OPERA_DIST_ALERT_DIST_VEG_ANOM": { + "title": "OPERA Maximum Vegetation Anomaly (DIST-ALERT VEG-ANOM-MAX)", + "description": "The OPERA Land Surface Disturbance Alert (DIST-ALERT) VEG-ANOM-MAX layer reports the maximum observed decrease in vegetation cover relative to a historical baseline using Harmonized Landsat Sentinel-2 observations.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "flood", + "fire", + "severe weather" + ], + "image": "/portal/cards/opera-dist-alert-dist-veg-anom.webp" + }, + "DATA__OPERA_DIST_ALERT_DIST_VEG_STATUS": { + "title": "OPERA Vegetation Disturbance Status (DIST-ALERT VEG-DIST-STATUS)", + "description": "The OPERA Land Surface Disturbance Alert (DIST-ALERT) VEG-DIST-STATUS layer identifies vegetation disturbance, its detection stage, and whether the maximum observed vegetation loss is below or at least 50%.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "flood", + "fire", + "severe weather" + ], + "image": "/portal/cards/opera-dist-alert-dist-veg-status.webp" + }, + "DATA__OPERA_DIST_ALERT_GEN_DIST_STATUS": { + "title": "OPERA Surface Disturbance Status (DIST-ALERT GEN-DIST-STATUS)", + "description": "The OPERA Land Surface Disturbance Alert (DIST-ALERT) GEN-DIST-STATUS layer reports, for every pixel, whether a change to the land surface has been detected and how confident that detection is, from Sentinel-1 radar or Harmonized Landsat Sentinel-2 optical observations compared against a pre-event baseline.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "flood", + "fire", + "severe weather" + ], + "image": "/portal/cards/opera-dist-alert-gen-dist-status.webp" + }, + "DATA__OPERA_DIST_S1": { + "title": "OPERA Surface Disturbance from Sentinel-1 (DIST-S1)", + "description": "The OPERA Surface Disturbance from Sentinel-1 (DIST-S1) product provides maps of surface disturbance detected from Sentinel-1 synthetic aperture radar (SAR) observations, with per-pixel classifications indicating the confidence of detected disturbance.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/opera-dist-s1.webp" + }, + "DATA__OPERA_DSWX": { + "title": "OPERA Dynamic Surface Water Extent (DSWx)", + "description": "The OPERA Dynamic Surface Water Extent (DSWx) product maps the presence of surface water, derived from both optical Harmonized Landsat Sentinel-2 observations and Sentinel-1 synthetic aperture radar, to support flood mapping and monitoring of open water.", + "themes": [ + "respond", + "recover", + "prepare" + ], + "categories": [ + "flood", + "tropical cyclone", + "severe weather" + ], + "image": "/portal/cards/opera-dswx.webp" + }, + "DATA__OPERA_RTC_RGB_MOSAIC": { + "title": "OPERA RTC RGB Mosaic", + "description": "The OPERA RTC RGB Mosaic is a false-color mosaic derived from radiometrically terrain-corrected Sentinel-1 radar backscatter and designed to support visual interpretation of land and water surface conditions during disasters.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "flood", + "fire", + "severe weather", + "tropical cyclone" + ], + "image": "/portal/cards/opera-rtc-rgb-mosaic.webp" + }, + "DATA__PLANET_NDVI": { + "title": "Planet Normalized Difference Vegetation Index (NDVI)", + "description": "Normalized Difference Vegetation Index (NDVI) derived from PlanetScope observations, using near-infrared and red reflectance to indicate the presence and relative condition of green vegetation at high spatial resolution.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/planet-ndvi.webp" + }, + "DATA__PLANET_NDVI_CHANGE": { + "title": "Planet NDVI Change", + "description": "Change in Normalized Difference Vegetation Index (NDVI) between two PlanetScope acquisitions, highlighting where vegetation has been lost, stressed, or has recovered between a pre-event baseline and a later observation.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/planet-ndvi-change.webp" + }, + "DATA__PLANET_NDWI": { + "title": "Planet Normalized Difference Water Index (NDWI)", + "description": "Normalized Difference Water Index (NDWI) derived from PlanetScope observations, using green and near-infrared reflectance to highlight surface water and water-related changes at high spatial resolution.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/planet-ndwi.webp" + }, + "DATA__PLANET_TRUE_COLOR": { + "title": "Planet True Color Imagery", + "description": "Planet True Color imagery combines red, green, and blue PlanetScope observations to provide a high-resolution, natural-looking view of the Earth's surface for disaster response and assessment.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/planet-true-color.webp" + }, + "DATA__SATELLOGIC_COLOR_INFRARED": { + "title": "Satellogic Color Infrared Imagery", + "description": "Satellogic Color Infrared imagery combines near-infrared, red, and green observations from Satellogic's high-resolution multispectral satellites to highlight vegetation, water, and burned or damaged areas affected by floods, storms, fires, and other hazards.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/satellogic-color-infrared.webp" + }, + "DATA__SATELLOGIC_TRUE_COLOR": { + "title": "Satellogic True Color Imagery", + "description": "Satellogic True Color imagery combines red, green, and blue observations from Satellogic's high-resolution multispectral satellites to provide a natural-looking, sub-meter to meter-scale view of the Earth's surface for disaster response and assessment.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/satellogic-true-color.webp" + }, + "DATA__SENTINEL_1_DAMAGE_ASSESSMENT": { + "title": "Sentinel-1 Likely Damaged Areas", + "description": "The Sentinel-1 Likely Damaged Areas product uses coherent change detection from interferometric SAR imagery to identify urban and built-up areas likely damaged or destroyed by the January 2025 Southern California wildfires.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/sentinel-1-damage-assessment.webp" + }, + "DATA__SENTINEL_1_SENTINEL_2_BURN_SEVERITY": { + "title": "Sentinel-1 and Sentinel-2 Burn Severity", + "description": "This combined Sentinel-1 and Sentinel-2 product maps burned vegetation and urban areas and provides relative burn severity information using changes in optical reflectance and synthetic aperture radar backscatter.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/sentinel-1-sentinel-2-burn-severity.webp" + }, + "DATA__SENTINEL_2_COLOR_INFRARED": { + "title": "Sentinel-2 Color Infrared Imagery", + "description": "Created using near-infrared, red, and green channels to highlight vegetation, water, burned areas, and other surface changes associated with floods, fires, and other hazards.", + "themes": [ + "prepare", + "respond", + "recover", + "resilience" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/sentinel-2-color-infrared.webp" + }, + "DATA__SENTINEL_2_DNBR": { + "title": "Sentinel-2 Differenced Normalized Burn Ratio (dNBR)", + "description": "Differenced Normalized Burn Ratio (dNBR) compares pre- and post-fire Sentinel-2 observations to highlight changes in vegetation associated with burning and provide a proxy for relative vegetation burn severity.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/sentinel-2-dnbr.webp" + }, + "DATA__SENTINEL_2_MNDWI": { + "title": "Sentinel-2 Modified Normalized Difference Water Index (MNDWI)", + "description": "Modified Normalized Difference Water Index (MNDWI) uses green and shortwave-infrared reflectance to enhance open water features and suppress vegetation, soil, and built-up surfaces.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "flood", + "tropical cyclone", + "winter weather" + ], + "image": "/portal/cards/sentinel-2-mndwi.webp" + }, + "DATA__SENTINEL_2_NATURAL_COLOR": { + "title": "Sentinel-2 Natural Color", + "description": "Natural color imagery from Sentinel-2 combines the red, green, and blue bands of the MultiSpectral Instrument into a view approximating what the human eye would see, supporting visual assessment of surface conditions before and after a disaster.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "earthquake", + "fire", + "flood", + "heat", + "landslide", + "severe weather", + "tropical cyclone", + "hurricane", + "typhoon", + "cyclone", + "volcano", + "tsunami", + "winter weather" + ], + "image": "/portal/cards/sentinel-2-natural-color.webp" + }, + "DATA__SENTINEL_2_NBR": { + "title": "Sentinel-2 Normalized Burn Ratio (NBR)", + "description": "Normalized Burn Ratio (NBR) is defined mathematically as (NIR – SWIR)/(NIR + SWIR) where NIR is near-infrared and SWIR is short-wave infrared. NBR is commonly used as a proxy to indicate areas which have charred vegetation.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "fire" + ], + "image": "/portal/cards/sentinel-2-nbr.webp" + }, + "DATA__SENTINEL_2_NDVI": { + "title": "Sentinel-2 Normalized Difference Vegetation Index (NDVI)", + "description": "Normalized Difference Vegetation Index (NDVI) uses near-infrared and red reflectance to indicate the presence and relative condition of green vegetation.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "earthquake", + "fire", + "flood", + "heat", + "landslide", + "severe weather", + "tropical cyclone", + "hurricane", + "typhoon", + "cyclone", + "volcano", + "tsunami", + "winter weather" + ], + "image": "/portal/cards/sentinel-2-ndvi.webp" + }, + "DATA__SENTINEL_2_NDVI_CHANGE_BINARY": { + "title": "Sentinel-2 Binary NDVI Change", + "description": "Binary vegetation change derived from Sentinel-2 Normalized Difference Vegetation Index (NDVI), identifying locations where NDVI decreased by 0.25 or more between two observation dates.", + "themes": [ + "respond", + "prepare" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/sentinel-2-ndvi-change-binary.webp" + }, + "DATA__SENTINEL_2_SWIR": { + "title": "Sentinel-2 Shortwave Infrared Imagery", + "description": "A false color composite created from Sentinel-2 shortwave infrared, near-infrared, and red observations to highlight differences among water, vegetation, burned areas, snow, bare ground, and developed surfaces.", + "themes": [ + "prepare", + "respond", + "recover", + "resilience" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/sentinel-2-swir.webp" + }, + "DATA__SENTINEL_2_TRUE_COLOR": { + "title": "Sentinel-2 True Color Imagery", + "description": "A visible-light composite created from Sentinel-2 red, green, and blue observations to show the Earth's surface approximately as it would appear to the human eye from space.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/sentinel-2-true-color.webp" + }, + "DATA__SKYSAT_COLOR_INFRARED": { + "title": "Planet SkySat Color Infrared Imagery", + "description": "Planet SkySat Color Infrared imagery combines near-infrared, red, and green observations from Planet's SkySat constellation to highlight vegetation, water, and burned areas at very high resolution.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/skysat-color-infrared.webp" + }, + "DATA__SKYSAT_TRUE_COLOR": { + "title": "Planet SkySat True Color Imagery", + "description": "Planet SkySat True Color imagery combines red, green, and blue observations from Planet's SkySat constellation to provide very-high-resolution, natural-looking views of the Earth's surface for disaster response and assessment.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/skysat-true-color.webp" + }, + "DATA__UAVSAR_DISPLACEMENT": { + "title": "UAVSAR Surface Displacement", + "description": "Ground displacement measured along the radar line of sight from repeat-pass UAVSAR interferometry, produced by the ARIA and UAVSAR teams at NASA's Jet Propulsion Laboratory to identify areas of significant surface motion.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "earthquake", + "volcano", + "severe weather" + ], + "image": "/portal/cards/uavsar-displacement.webp" + }, + "DATA__UAVSAR_QUICKLOOK_CLASSIFIED": { + "title": "UAVSAR Quicklook Flood Classification", + "description": "Preliminary flood classification generated from UAVSAR radar imagery within hours of a flight, providing an early view of open water and inundated areas while the fully processed classification is still being produced.", + "themes": [ + "respond" + ], + "categories": [ + "flood", + "tropical cyclone", + "severe weather" + ], + "image": "/portal/cards/uavsar-quicklook-classified.webp" + }, + "DATA__UAVSAR_RGB": { + "title": "UAVSAR Polarimetric False Color Imagery", + "description": "Fully polarimetric L-band radar imagery from NASA's airborne UAVSAR instrument, rendered as a false color composite of the HH, HV, and VV polarization channels to reveal flooding and inundation beneath tree canopy that optical imagery cannot see.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "flood", + "tropical cyclone", + "severe weather" + ], + "image": "/portal/cards/uavsar-rgb.webp" + }, + "DATA__UAVSAR_UNET_CLASSIFIED": { + "title": "UAVSAR U-Net Flood Classification", + "description": "Machine learning flood maps derived from UAVSAR polarimetric radar using a U-Net algorithm, identifying open water flooding and inundation beneath tree canopy and labeling the type of land cover that was flooded.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "flood", + "tropical cyclone", + "severe weather" + ], + "image": "/portal/cards/uavsar-unet-classified.webp" + }, + "DATA__UAVSAR_UNET_CLASSIFIED_GRAYSCALE": { + "title": "UAVSAR U-Net Flood Classification (Grayscale)", + "description": "A single-band grayscale rendering of the UAVSAR U-Net flood classification, intended for use as an analysis-ready input layer or as a neutral underlay beneath other thematic map layers.", + "themes": [ + "respond", + "recover" + ], + "categories": [ + "flood", + "tropical cyclone", + "severe weather" + ], + "image": "/portal/cards/uavsar-unet-classified-grayscale.webp" + }, + "DATA__UMBRA_SIGMA_NAUGHT": { + "title": "Umbra Backscatter", + "description": "Sigma Naught backscatter derived from high-resolution Umbra synthetic aperture radar (SAR) imagery acquired through NASA's Commercial Satellite Data Acquisition (CSDA) Program.", + "themes": [ + "respond", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/umbra-sigma-naught.webp" + }, + "DATA__VANTOR_COLOR_INFRARED": { + "title": "Vantor Color Infrared", + "description": "Vantor Color Infrared imagery combines the near-infrared, red, and green bands of the WorldView eight-band multispectral sensors into a false-color view in which healthy vegetation glows red and water reads near-black, sharpening the distinction between damaged and intact ground cover.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/vantor-color-infrared.webp" + }, + "DATA__VANTOR_PANCHROMATIC": { + "title": "Vantor Panchromatic", + "description": "Vantor Panchromatic imagery collects all visible light into a single grayscale band at 30 centimeters per pixel, the sharpest view in the delivery, for reading structural damage where fine detail matters more than color.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "flood", + "tropical cyclone", + "earthquake", + "landslide", + "winter weather" + ], + "image": "/portal/cards/vantor-panchromatic.webp" + }, + "DATA__VANTOR_TRUE_COLOR": { + "title": "Vantor True Color", + "description": "Vantor True Color imagery combines the red, green, and blue bands of the WorldView eight-band multispectral sensors into a natural-looking view of the surface at roughly a third of a meter per pixel, supporting visual assessment at the scale of individual buildings.", + "themes": [ + "respond", + "resilience", + "prepare", + "recover" + ], + "categories": [ + "severe weather", + "fire", + "heat", + "flood", + "tropical cyclone", + "earthquake", + "winter weather" + ], + "image": "/portal/cards/vantor-true-color.webp" + }, + "DATA__WB57_DYNAMITE": { + "title": "WB-57 DyNAMITE Airborne Imagery", + "description": "High-resolution visible and infrared imagery collected by the DyNAMITE instrument aboard NASA's WB-57 high-altitude aircraft, streamed to responders during flight.", + "themes": [ + "respond" + ], + "categories": [ + "earthquake", + "fire", + "flood", + "landslide", + "severe weather", + "tropical cyclone", + "volcano" + ], + "image": "/portal/cards/wb57-dynamite.webp" + } +} diff --git a/algorithm-catalog/public/portal/cards/aviris-3-char-and-ash.webp b/algorithm-catalog/public/portal/cards/aviris-3-char-and-ash.webp new file mode 100644 index 00000000..8cc0f9fc Binary files /dev/null and b/algorithm-catalog/public/portal/cards/aviris-3-char-and-ash.webp differ diff --git a/algorithm-catalog/public/portal/cards/aviris-3-dnbr.webp b/algorithm-catalog/public/portal/cards/aviris-3-dnbr.webp new file mode 100644 index 00000000..c0e76182 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/aviris-3-dnbr.webp differ diff --git a/algorithm-catalog/public/portal/cards/aviris-3-pca.webp b/algorithm-catalog/public/portal/cards/aviris-3-pca.webp new file mode 100644 index 00000000..738d27ec Binary files /dev/null and b/algorithm-catalog/public/portal/cards/aviris-3-pca.webp differ diff --git a/algorithm-catalog/public/portal/cards/aviris-3-quicklook-color-imagery.webp b/algorithm-catalog/public/portal/cards/aviris-3-quicklook-color-imagery.webp new file mode 100644 index 00000000..6a5a0259 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/aviris-3-quicklook-color-imagery.webp differ diff --git a/algorithm-catalog/public/portal/cards/black-marble-blue-yellow.webp b/algorithm-catalog/public/portal/cards/black-marble-blue-yellow.webp new file mode 100644 index 00000000..bff65634 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/black-marble-blue-yellow.webp differ diff --git a/algorithm-catalog/public/portal/cards/black-marble-brdf.webp b/algorithm-catalog/public/portal/cards/black-marble-brdf.webp new file mode 100644 index 00000000..ad571e02 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/black-marble-brdf.webp differ diff --git a/algorithm-catalog/public/portal/cards/black-marble-hd.webp b/algorithm-catalog/public/portal/cards/black-marble-hd.webp new file mode 100644 index 00000000..b93a9b39 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/black-marble-hd.webp differ diff --git a/algorithm-catalog/public/portal/cards/capella-backscatter.webp b/algorithm-catalog/public/portal/cards/capella-backscatter.webp new file mode 100644 index 00000000..3902cbb5 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/capella-backscatter.webp differ diff --git a/algorithm-catalog/public/portal/cards/ecostress-land-surface-temperature.webp b/algorithm-catalog/public/portal/cards/ecostress-land-surface-temperature.webp new file mode 100644 index 00000000..74b4b54b Binary files /dev/null and b/algorithm-catalog/public/portal/cards/ecostress-land-surface-temperature.webp differ diff --git a/algorithm-catalog/public/portal/cards/gaia.webp b/algorithm-catalog/public/portal/cards/gaia.webp new file mode 100644 index 00000000..2ca84e19 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/gaia.webp differ diff --git a/algorithm-catalog/public/portal/cards/gpm-imerg-precipitation.webp b/algorithm-catalog/public/portal/cards/gpm-imerg-precipitation.webp new file mode 100644 index 00000000..dbb23a8a Binary files /dev/null and b/algorithm-catalog/public/portal/cards/gpm-imerg-precipitation.webp differ diff --git a/algorithm-catalog/public/portal/cards/hydrosar-s1-classifications.webp b/algorithm-catalog/public/portal/cards/hydrosar-s1-classifications.webp new file mode 100644 index 00000000..f18f945b Binary files /dev/null and b/algorithm-catalog/public/portal/cards/hydrosar-s1-classifications.webp differ diff --git a/algorithm-catalog/public/portal/cards/iceye-backscatter.webp b/algorithm-catalog/public/portal/cards/iceye-backscatter.webp new file mode 100644 index 00000000..9f93126b Binary files /dev/null and b/algorithm-catalog/public/portal/cards/iceye-backscatter.webp differ diff --git a/algorithm-catalog/public/portal/cards/landsat-color-infrared.webp b/algorithm-catalog/public/portal/cards/landsat-color-infrared.webp new file mode 100644 index 00000000..ae4ca19c Binary files /dev/null and b/algorithm-catalog/public/portal/cards/landsat-color-infrared.webp differ diff --git a/algorithm-catalog/public/portal/cards/landsat-mndwi.webp b/algorithm-catalog/public/portal/cards/landsat-mndwi.webp new file mode 100644 index 00000000..ea66bd3b Binary files /dev/null and b/algorithm-catalog/public/portal/cards/landsat-mndwi.webp differ diff --git a/algorithm-catalog/public/portal/cards/landsat-natural-color.webp b/algorithm-catalog/public/portal/cards/landsat-natural-color.webp new file mode 100644 index 00000000..29acaa1b Binary files /dev/null and b/algorithm-catalog/public/portal/cards/landsat-natural-color.webp differ diff --git a/algorithm-catalog/public/portal/cards/landsat-nbr.webp b/algorithm-catalog/public/portal/cards/landsat-nbr.webp new file mode 100644 index 00000000..73d8440f Binary files /dev/null and b/algorithm-catalog/public/portal/cards/landsat-nbr.webp differ diff --git a/algorithm-catalog/public/portal/cards/landsat-ndvi.webp b/algorithm-catalog/public/portal/cards/landsat-ndvi.webp new file mode 100644 index 00000000..094b16a5 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/landsat-ndvi.webp differ diff --git a/algorithm-catalog/public/portal/cards/landsat-true-color.webp b/algorithm-catalog/public/portal/cards/landsat-true-color.webp new file mode 100644 index 00000000..9141c712 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/landsat-true-color.webp differ diff --git a/algorithm-catalog/public/portal/cards/nisar-gunw-displacement.webp b/algorithm-catalog/public/portal/cards/nisar-gunw-displacement.webp new file mode 100644 index 00000000..a4432488 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/nisar-gunw-displacement.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-disp-s1-coherence.webp b/algorithm-catalog/public/portal/cards/opera-disp-s1-coherence.webp new file mode 100644 index 00000000..3f62ba63 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-disp-s1-coherence.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-disp-s1-rng.webp b/algorithm-catalog/public/portal/cards/opera-disp-s1-rng.webp new file mode 100644 index 00000000..be1ecb1c Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-disp-s1-rng.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-disp-s1-unw.webp b/algorithm-catalog/public/portal/cards/opera-disp-s1-unw.webp new file mode 100644 index 00000000..daef4e6c Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-disp-s1-unw.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-disp-s1-wrp.webp b/algorithm-catalog/public/portal/cards/opera-disp-s1-wrp.webp new file mode 100644 index 00000000..49f98ada Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-disp-s1-wrp.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-dist-alert-dist-veg-anom.webp b/algorithm-catalog/public/portal/cards/opera-dist-alert-dist-veg-anom.webp new file mode 100644 index 00000000..77611563 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-dist-alert-dist-veg-anom.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-dist-alert-dist-veg-status.webp b/algorithm-catalog/public/portal/cards/opera-dist-alert-dist-veg-status.webp new file mode 100644 index 00000000..08702ca2 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-dist-alert-dist-veg-status.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-dist-alert-gen-dist-status.webp b/algorithm-catalog/public/portal/cards/opera-dist-alert-gen-dist-status.webp new file mode 100644 index 00000000..1ea5fa70 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-dist-alert-gen-dist-status.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-dist-s1.webp b/algorithm-catalog/public/portal/cards/opera-dist-s1.webp new file mode 100644 index 00000000..aed61493 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-dist-s1.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-dswx.webp b/algorithm-catalog/public/portal/cards/opera-dswx.webp new file mode 100644 index 00000000..f8b64c4d Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-dswx.webp differ diff --git a/algorithm-catalog/public/portal/cards/opera-rtc-rgb-mosaic.webp b/algorithm-catalog/public/portal/cards/opera-rtc-rgb-mosaic.webp new file mode 100644 index 00000000..4113d6de Binary files /dev/null and b/algorithm-catalog/public/portal/cards/opera-rtc-rgb-mosaic.webp differ diff --git a/algorithm-catalog/public/portal/cards/planet-ndvi-change.webp b/algorithm-catalog/public/portal/cards/planet-ndvi-change.webp new file mode 100644 index 00000000..2e2b021c Binary files /dev/null and b/algorithm-catalog/public/portal/cards/planet-ndvi-change.webp differ diff --git a/algorithm-catalog/public/portal/cards/planet-ndvi.webp b/algorithm-catalog/public/portal/cards/planet-ndvi.webp new file mode 100644 index 00000000..a7cf7b50 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/planet-ndvi.webp differ diff --git a/algorithm-catalog/public/portal/cards/planet-ndwi.webp b/algorithm-catalog/public/portal/cards/planet-ndwi.webp new file mode 100644 index 00000000..2b073fa8 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/planet-ndwi.webp differ diff --git a/algorithm-catalog/public/portal/cards/planet-true-color.webp b/algorithm-catalog/public/portal/cards/planet-true-color.webp new file mode 100644 index 00000000..b3930f90 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/planet-true-color.webp differ diff --git a/algorithm-catalog/public/portal/cards/satellogic-color-infrared.webp b/algorithm-catalog/public/portal/cards/satellogic-color-infrared.webp new file mode 100644 index 00000000..545b7123 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/satellogic-color-infrared.webp differ diff --git a/algorithm-catalog/public/portal/cards/satellogic-true-color.webp b/algorithm-catalog/public/portal/cards/satellogic-true-color.webp new file mode 100644 index 00000000..e0039a3f Binary files /dev/null and b/algorithm-catalog/public/portal/cards/satellogic-true-color.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-1-damage-assessment.webp b/algorithm-catalog/public/portal/cards/sentinel-1-damage-assessment.webp new file mode 100644 index 00000000..3ebe9962 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-1-damage-assessment.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-1-sentinel-2-burn-severity.webp b/algorithm-catalog/public/portal/cards/sentinel-1-sentinel-2-burn-severity.webp new file mode 100644 index 00000000..eb068c77 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-1-sentinel-2-burn-severity.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-color-infrared.webp b/algorithm-catalog/public/portal/cards/sentinel-2-color-infrared.webp new file mode 100644 index 00000000..ac7b4411 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-color-infrared.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-dnbr.webp b/algorithm-catalog/public/portal/cards/sentinel-2-dnbr.webp new file mode 100644 index 00000000..7fa969e2 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-dnbr.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-mndwi.webp b/algorithm-catalog/public/portal/cards/sentinel-2-mndwi.webp new file mode 100644 index 00000000..e20f9cf4 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-mndwi.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-natural-color.webp b/algorithm-catalog/public/portal/cards/sentinel-2-natural-color.webp new file mode 100644 index 00000000..5ec02cd3 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-natural-color.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-nbr.webp b/algorithm-catalog/public/portal/cards/sentinel-2-nbr.webp new file mode 100644 index 00000000..01f915c1 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-nbr.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-ndvi-change-binary.webp b/algorithm-catalog/public/portal/cards/sentinel-2-ndvi-change-binary.webp new file mode 100644 index 00000000..a5c71cc5 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-ndvi-change-binary.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-ndvi.webp b/algorithm-catalog/public/portal/cards/sentinel-2-ndvi.webp new file mode 100644 index 00000000..92056993 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-ndvi.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-swir.webp b/algorithm-catalog/public/portal/cards/sentinel-2-swir.webp new file mode 100644 index 00000000..7d5bde37 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-swir.webp differ diff --git a/algorithm-catalog/public/portal/cards/sentinel-2-true-color.webp b/algorithm-catalog/public/portal/cards/sentinel-2-true-color.webp new file mode 100644 index 00000000..45c87c60 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/sentinel-2-true-color.webp differ diff --git a/algorithm-catalog/public/portal/cards/skysat-color-infrared.webp b/algorithm-catalog/public/portal/cards/skysat-color-infrared.webp new file mode 100644 index 00000000..16b118c2 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/skysat-color-infrared.webp differ diff --git a/algorithm-catalog/public/portal/cards/skysat-true-color.webp b/algorithm-catalog/public/portal/cards/skysat-true-color.webp new file mode 100644 index 00000000..e15d476c Binary files /dev/null and b/algorithm-catalog/public/portal/cards/skysat-true-color.webp differ diff --git a/algorithm-catalog/public/portal/cards/uavsar-displacement.webp b/algorithm-catalog/public/portal/cards/uavsar-displacement.webp new file mode 100644 index 00000000..d1452144 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/uavsar-displacement.webp differ diff --git a/algorithm-catalog/public/portal/cards/uavsar-quicklook-classified.webp b/algorithm-catalog/public/portal/cards/uavsar-quicklook-classified.webp new file mode 100644 index 00000000..78a4f747 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/uavsar-quicklook-classified.webp differ diff --git a/algorithm-catalog/public/portal/cards/uavsar-rgb.webp b/algorithm-catalog/public/portal/cards/uavsar-rgb.webp new file mode 100644 index 00000000..177e87a1 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/uavsar-rgb.webp differ diff --git a/algorithm-catalog/public/portal/cards/uavsar-unet-classified-grayscale.webp b/algorithm-catalog/public/portal/cards/uavsar-unet-classified-grayscale.webp new file mode 100644 index 00000000..e5ebee3d Binary files /dev/null and b/algorithm-catalog/public/portal/cards/uavsar-unet-classified-grayscale.webp differ diff --git a/algorithm-catalog/public/portal/cards/uavsar-unet-classified.webp b/algorithm-catalog/public/portal/cards/uavsar-unet-classified.webp new file mode 100644 index 00000000..4e7f239d Binary files /dev/null and b/algorithm-catalog/public/portal/cards/uavsar-unet-classified.webp differ diff --git a/algorithm-catalog/public/portal/cards/umbra-sigma-naught.webp b/algorithm-catalog/public/portal/cards/umbra-sigma-naught.webp new file mode 100644 index 00000000..d5b600b9 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/umbra-sigma-naught.webp differ diff --git a/algorithm-catalog/public/portal/cards/vantor-color-infrared.webp b/algorithm-catalog/public/portal/cards/vantor-color-infrared.webp new file mode 100644 index 00000000..74ef1f95 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/vantor-color-infrared.webp differ diff --git a/algorithm-catalog/public/portal/cards/vantor-panchromatic.webp b/algorithm-catalog/public/portal/cards/vantor-panchromatic.webp new file mode 100644 index 00000000..7f33a568 Binary files /dev/null and b/algorithm-catalog/public/portal/cards/vantor-panchromatic.webp differ diff --git a/algorithm-catalog/public/portal/cards/vantor-true-color.webp b/algorithm-catalog/public/portal/cards/vantor-true-color.webp new file mode 100644 index 00000000..2c61d2ae Binary files /dev/null and b/algorithm-catalog/public/portal/cards/vantor-true-color.webp differ diff --git a/algorithm-catalog/public/portal/cards/wb57-dynamite.webp b/algorithm-catalog/public/portal/cards/wb57-dynamite.webp new file mode 100644 index 00000000..424fad6c Binary files /dev/null and b/algorithm-catalog/public/portal/cards/wb57-dynamite.webp differ diff --git a/algorithm-catalog/public/portal/disasters-logo.svg b/algorithm-catalog/public/portal/disasters-logo.svg new file mode 100644 index 00000000..1fe57f64 --- /dev/null +++ b/algorithm-catalog/public/portal/disasters-logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/algorithm-catalog/public/portal/template-hero.webp b/algorithm-catalog/public/portal/template-hero.webp new file mode 100644 index 00000000..4941ff43 Binary files /dev/null and b/algorithm-catalog/public/portal/template-hero.webp differ diff --git a/algorithm-catalog/public/thumbs/CREDITS.md b/algorithm-catalog/public/thumbs/CREDITS.md index 05202e0e..b4ecfc40 100644 --- a/algorithm-catalog/public/thumbs/CREDITS.md +++ b/algorithm-catalog/public/thumbs/CREDITS.md @@ -1,6 +1,6 @@ # Thumbnail credits -Every image in this folder is **NASA-produced imagery**, which carries no rights restrictions +Every image in the first table is **NASA-produced imagery**, which carries no rights restrictions (). Each was downloaded from the source URL below, centre-cropped to 3:2, resized to **600 × 400** and palette-quantized to keep every file **under 150 KB** (the catalog renders them as small cards, so full resolution is wasted bytes). @@ -28,8 +28,38 @@ succeeded. | `blackmarble_noaa.png` | VIIRS Day/Night Band at-sensor radiance, **NOAA-20**, same scene and date — the NOAA-20 counterpart | `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=2025-01-09&BBOX=24,-100,44,-70&CRS=EPSG:4326&LAYERS=VIIRS_NOAA20_DayNightBand_At_Sensor_Radiance&FORMAT=image/jpeg&WIDTH=900&HEIGHT=600` | NASA Worldview / NASA Earthdata GIBS — VIIRS DNB, NOAA-20 (JPSS-1) | | `fallback.png` | Blue Marble shaded relief + bathymetry, Americas — the generic card for any algorithm without its own thumbnail | `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=2024-01-01&BBOX=-56,-170,72,10&CRS=EPSG:4326&LAYERS=BlueMarble_ShadedRelief_Bathymetry&FORMAT=image/jpeg&WIDTH=900&HEIGHT=600` | NASA Earth Observatory — Blue Marble: Next Generation, via NASA Worldview / GIBS | +### Products with no algorithm yet (`status: "external"`) + +These algorithms list Disasters Portal products that no algorithm in this catalog makes yet. Each +thumbnail is that product's own **NASA Disasters Portal data-gallery card image** (the portal repo's +`public/img/data/.webp`, shown at `https://science.data.nasa.gov/disasters/data-gallery/`), +scaled to cover 600 × 400 and centre-cropped with macOS `sips`, then palette-quantized (≤ 150 KB). + +| File | Portal card image | Credit | +|---|---|---| +| `opera.png` | `opera-rtc-rgb-mosaic.webp` — OPERA RTC RGB Mosaic | NASA Disasters Portal; NASA JPL OPERA; contains modified Copernicus Sentinel data (ESA) | +| `uavsar.png` | `uavsar-rgb.webp` — UAVSAR Polarimetric False Color Imagery | NASA Disasters Portal; NASA/JPL-Caltech UAVSAR team | +| `aviris3.png` | `aviris-3-pca.webp` — AVIRIS-3 Principal Component Analysis (PCA) | NASA Disasters Portal; NASA/JPL-Caltech AVIRIS Science Team | +| `planet.png` | `planet-true-color.webp` — Planet True Color Imagery | NASA Disasters Portal; **Planet Labs PBC** via NASA CSDA (commercial imagery, not public domain) | +| `vantor.png` | `vantor-true-color.webp` — Vantor True Color | NASA Disasters Portal; **Vantor, formerly Maxar** (commercial imagery, not public domain) | +| `skysat.png` | `skysat-true-color.webp` — Planet SkySat True Color Imagery | NASA Disasters Portal; **Planet Labs PBC** via NASA CSDA (commercial imagery, not public domain) | +| `ecostress.png` | `ecostress-land-surface-temperature.webp` — ECOSTRESS Land Surface Temperature | NASA Disasters Portal; NASA/JPL-Caltech ECOSTRESS | +| `gpm_imerg.png` | `gpm-imerg-precipitation.webp` — GPM IMERG Accumulated Precipitation | NASA Disasters Portal; NASA GSFC GES DISC (IMERG V07, via Giovanni) | +| `hydrosar.png` | `hydrosar-s1-classifications.webp` — HydroSAR Sentinel-1 Water Extent Classifications | NASA Disasters Portal; ASF DAAC / UAF HydroSAR; contains modified Copernicus Sentinel data (ESA) | +| `nisar.png` | `nisar-gunw-displacement.webp` — NISAR GUNW Surface Displacement | NASA Disasters Portal; NASA/JPL-Caltech, ISRO, ASF DAAC | +| `gaia.png` | `gaia-building-exposure.webp` — Global Assessment of Infrastructure Assets (GAIA) | NASA Disasters Portal; ImageCat, Inc., NASA Disasters Program | +| `wb57_dynamite.png` | `wb57-dynamite.webp` — WB-57 DyNAMITE Airborne Imagery | NASA Disasters Portal; NASA Johnson Space Center | +| `blackmarble_composites.png` | `black-marble-blue-yellow.webp` — Black Marble Nighttime Blue/Yellow Composite | NASA Disasters Portal; NASA GSFC, LANCE, NOAA, U.S. Naval Research Laboratory | +| `s1_damage.png` | `sentinel-1-damage-assessment.webp` — Sentinel-1 Likely Damaged Areas | NASA Disasters Portal; C. Scher (CUNY) and J. Van Den Hoek (OSU); contains modified Copernicus Sentinel data (ESA) | +| `s1s2_burn.png` | `sentinel-1-sentinel-2-burn-severity.webp` — Sentinel-1 and Sentinel-2 Burn Severity | NASA Disasters Portal; NASA Ames Research Center; contains modified Copernicus Sentinel data (ESA) | +| `sentinel2_change.png` | `sentinel-2-dnbr.webp` — Sentinel-2 Differenced Normalized Burn Ratio (dNBR) | NASA Disasters Portal; NASA/GSFC, USGS; contains modified Copernicus Sentinel data (ESA) | + ## Notes on honesty +- Unlike the rows above them, `planet.png`, `skysat.png` and `vantor.png` **are** commercial vendor + imagery: the same image the Disasters Portal publishes on that product's card, credited to the + vendor. They are not public domain. + - The **SAR** thumbnails are NASA/JPL shuttle-radar imagery, not imagery from Umbra, Capella or ICEYE — those are commercial vendors whose imagery is **not** public domain. The thumbnails illustrate the *modality*; the `thumbCredit` field in `data/algorithms.json` is what the UI shows. diff --git a/algorithm-catalog/public/thumbs/aviris3.png b/algorithm-catalog/public/thumbs/aviris3.png new file mode 100644 index 00000000..ed94b3c6 Binary files /dev/null and b/algorithm-catalog/public/thumbs/aviris3.png differ diff --git a/algorithm-catalog/public/thumbs/blackmarble_composites.png b/algorithm-catalog/public/thumbs/blackmarble_composites.png new file mode 100644 index 00000000..1775716a Binary files /dev/null and b/algorithm-catalog/public/thumbs/blackmarble_composites.png differ diff --git a/algorithm-catalog/public/thumbs/ecostress.png b/algorithm-catalog/public/thumbs/ecostress.png new file mode 100644 index 00000000..f5f12582 Binary files /dev/null and b/algorithm-catalog/public/thumbs/ecostress.png differ diff --git a/algorithm-catalog/public/thumbs/gaia.png b/algorithm-catalog/public/thumbs/gaia.png new file mode 100644 index 00000000..fafe2ac3 Binary files /dev/null and b/algorithm-catalog/public/thumbs/gaia.png differ diff --git a/algorithm-catalog/public/thumbs/gpm_imerg.png b/algorithm-catalog/public/thumbs/gpm_imerg.png new file mode 100644 index 00000000..a94780e4 Binary files /dev/null and b/algorithm-catalog/public/thumbs/gpm_imerg.png differ diff --git a/algorithm-catalog/public/thumbs/hydrosar.png b/algorithm-catalog/public/thumbs/hydrosar.png new file mode 100644 index 00000000..a1acf6a2 Binary files /dev/null and b/algorithm-catalog/public/thumbs/hydrosar.png differ diff --git a/algorithm-catalog/public/thumbs/nisar.png b/algorithm-catalog/public/thumbs/nisar.png new file mode 100644 index 00000000..6b9ebaa7 Binary files /dev/null and b/algorithm-catalog/public/thumbs/nisar.png differ diff --git a/algorithm-catalog/public/thumbs/opera.png b/algorithm-catalog/public/thumbs/opera.png new file mode 100644 index 00000000..8a3d1892 Binary files /dev/null and b/algorithm-catalog/public/thumbs/opera.png differ diff --git a/algorithm-catalog/public/thumbs/planet.png b/algorithm-catalog/public/thumbs/planet.png new file mode 100644 index 00000000..1971d1d0 Binary files /dev/null and b/algorithm-catalog/public/thumbs/planet.png differ diff --git a/algorithm-catalog/public/thumbs/s1_damage.png b/algorithm-catalog/public/thumbs/s1_damage.png new file mode 100644 index 00000000..00253ede Binary files /dev/null and b/algorithm-catalog/public/thumbs/s1_damage.png differ diff --git a/algorithm-catalog/public/thumbs/s1s2_burn.png b/algorithm-catalog/public/thumbs/s1s2_burn.png new file mode 100644 index 00000000..ee481946 Binary files /dev/null and b/algorithm-catalog/public/thumbs/s1s2_burn.png differ diff --git a/algorithm-catalog/public/thumbs/sentinel2_change.png b/algorithm-catalog/public/thumbs/sentinel2_change.png new file mode 100644 index 00000000..11ef4546 Binary files /dev/null and b/algorithm-catalog/public/thumbs/sentinel2_change.png differ diff --git a/algorithm-catalog/public/thumbs/skysat.png b/algorithm-catalog/public/thumbs/skysat.png new file mode 100644 index 00000000..f8c7b90c Binary files /dev/null and b/algorithm-catalog/public/thumbs/skysat.png differ diff --git a/algorithm-catalog/public/thumbs/uavsar.png b/algorithm-catalog/public/thumbs/uavsar.png new file mode 100644 index 00000000..70443ed6 Binary files /dev/null and b/algorithm-catalog/public/thumbs/uavsar.png differ diff --git a/algorithm-catalog/public/thumbs/vantor.png b/algorithm-catalog/public/thumbs/vantor.png new file mode 100644 index 00000000..bd91668a Binary files /dev/null and b/algorithm-catalog/public/thumbs/vantor.png differ diff --git a/algorithm-catalog/public/thumbs/wb57_dynamite.png b/algorithm-catalog/public/thumbs/wb57_dynamite.png new file mode 100644 index 00000000..2351ba3d Binary files /dev/null and b/algorithm-catalog/public/thumbs/wb57_dynamite.png differ diff --git a/algorithm-catalog/scripts/e2e.mjs b/algorithm-catalog/scripts/e2e.mjs index c14ccb65..93cd87f9 100644 --- a/algorithm-catalog/scripts/e2e.mjs +++ b/algorithm-catalog/scripts/e2e.mjs @@ -83,7 +83,13 @@ const cards = await page.locator(".acard").count(); check("algorithm cards render", cards >= 8, `${cards} cards`); // naturalWidth > 0 is the only honest proof an actually decoded — a 200 on the URL does -// not mean the browser rendered it. +// not mean the browser rendered it. Card thumbs are loading="lazy", so scroll each one into view +// first; otherwise a card below the fold is measured before it was ever requested. +for (const img of await page.locator("img.acard-thumb").all()) await img.scrollIntoViewIfNeeded(); +await page + .waitForFunction(() => [...document.querySelectorAll("img.acard-thumb")].every((i) => i.complete), null, { timeout: 5000 }) + .catch(() => {}); +await page.evaluate(() => window.scrollTo(0, 0)); const thumbs = ( await page.locator("img").evaluateAll((els) => els.map((e) => ({ src: e.getAttribute("src") || "", w: e.naturalWidth })), @@ -235,10 +241,10 @@ check( await page.locator("button", { hasText: "Reset" }).click(); await page.locator(".ecard", { hasText: "Hurricane Isaias October 2026" }).click(); await page.locator('[role="tab"]', { hasText: "Page preview" }).click(); -const preview = await page.locator(".page-preview").innerText(); +const preview = await page.locator(".overlay .pp").innerText(); check( "Page preview follows the portal's section order", - /Hurricane Isaias October 2026[\s\S]*Type[\s\S]*Need[\s\S]*Hazard[\s\S]*Region[\s\S]*Start Date[\s\S]*Overview[\s\S]*Event Data Gallery/.test(preview), + /Hurricane Isaias October 2026[\s\S]*PUBLISHED:[\s\S]*Type[\s\S]*Region[\s\S]*Start Date[\s\S]*Overview/i.test(preview), ); await shot(page, "09-page-preview"); await page.locator("button", { hasText: "Edit page" }).click(); @@ -250,12 +256,45 @@ await page.locator("#req-subtitle").fill('As Hurricane Isaias made landfall, NAS await page.locator("button.req-haz", { hasText: /^Respond$/ }).click(); await page.locator("#req-intro").fill("Hurricane Isaias brought storm surge to Florida and Alabama.\n\nNASA is sharing maps."); await page.locator("#req-govlink").fill("https://www.fema.gov/disaster/hurricane-isaias"); +check( + "the page address is never asked for — inferred, and shown only in the live preview", + (await page.locator("#req-portalid, #req-pageaddr").count()) === 0 && + /\/hurricane-isaias-oct-2026$/.test((await page.locator(".req-live-head code").innerText()).trim()), +); +// The one image after the intro: a Drive FOLDER link is refused; a file link + alt text is accepted. +const imgUrl = page.locator("#req-image-url"); +await imgUrl.fill("https://drive.google.com/drive/folders/1AbCdEfGhIjKlMnOp"); +await imgUrl.blur(); +await page.waitForTimeout(200); +check("a Drive folder link is refused", /FOLDER link/.test(await page.locator(".req-form").innerText())); +await imgUrl.fill("https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrSt/view?usp=sharing"); +await page.locator("#req-image-alt").fill("Flooding in Mobile, Alabama"); +await page.locator("#req-image-caption").fill("Sentinel-2 view of Mobile Bay, Oct. 10, 2026. Credit: NASA"); +await page.waitForTimeout(1500); // the Drive thumbnail request fails for this made-up id +check( + "the preview shows the image after the intro — or says plainly it can't load it", + (await page.locator(".req-live .pp-figure").count()) === 1 && + /Sentinel-2 view of Mobile Bay/.test(await page.locator(".req-live .pp-figure").innerText()), +); await page.locator("button", { hasText: "Add products" }).click(); await page.locator(".req-picker-row", { hasText: "True color" }).first().click(); await page.keyboard.press("Escape"); await page.waitForTimeout(300); const review = await page.locator(".req-form").innerText(); -check("Review shows the live page preview", /Hurricane Isaias brought storm surge/.test(review)); +const live = page.locator(".req-live .pp"); +check("the live portal preview is docked beside the form", (await live.count()) === 1); +check( + "…and updates as you type (sub-title, Need, intro)", + // curly, exactly as the generated portal file will carry it — not the straight quotes typed + (await live.locator(".pp-hero p").innerText()).includes("NASA “activated” to support FEMA") && + (await live.locator(".pp-meta").innerText()).includes("RESPOND") && + (await live.locator(".pp-main").innerText()).includes("Hurricane Isaias brought storm surge"), +); +check( + "…and shows the REAL portal gallery card for the product", + (await live.locator(".pp-card h3").allInnerTexts()).includes("Landsat True Color Imagery") && + (await live.locator(".pp-card img").first().evaluate((i) => i.complete && i.naturalWidth > 0)), +); check("Review shows the portal file path", /app\/site-config\/event\/event__hurricane-isaias-oct-2026\.ts/.test(review)); const portalSrc = await page.locator(".req-whatruns pre.urlbox").last().textContent(); check("portal file uses curly quotes (Biome-safe)", portalSrc.includes("NASA “activated”") && !portalSrc.includes('\\"')); @@ -272,10 +311,211 @@ check( doc.page?.intro?.startsWith("Hurricane Isaias") && doc.page?.needs?.[0] === "respond" && doc.event?.places?.length === 2 && - !("end" in (doc.event ?? {})), + !("end" in (doc.event ?? {})) && + doc.page?.image?.alt === "Flooding in Mobile, Alabama", opened ? new URL(opened).searchParams.get("filename") : "no window.open", ); +// --- 7. a NEW page's name must carry the portal convention, since it becomes the address ---------- +await page.goto(BASE, { waitUntil: "networkidle" }); +await page.locator("#req-name").fill("Hurricane Isaias"); +await page.locator("#req-subtitle").fill("Test"); +await page.locator("#req-name").blur(); +await page.waitForTimeout(200); +check( + "a new page's name must end with Month YYYY", + /End the name with the month and year/.test(await page.locator(".req-form").innerText()), +); + +// --- 8. "+ Add products": sensors on the left, the picked sensor's products on the right --------- +await page.goto(BASE, { waitUntil: "networkidle" }); +const picker = page.locator(".req-prodpick"); +const sensorTabs = picker.locator('[role="tablist"] [role="tab"]'); +const panel = picker.locator('[role="tabpanel"]'); +await page.locator("button", { hasText: "Add products" }).click(); +check( + "picker lists every algorithm as a sensor on the left", + (await sensorTabs.count()) === cards, + `${await sensorTabs.count()} sensors, ${cards} algorithm cards`, +); +check( + "…the first sensor is selected by default", + (await sensorTabs.first().getAttribute("aria-selected")) === "true" && + (await panel.locator(".req-picker-group-head").innerText()).includes("Landsat"), +); +const tokenOf = async (label) => + panel.locator(".req-picker-row", { hasText: label }).first().locator(".req-picker-token"); +check( + "product tokens are the LIVE STAC asset names (Landsat True color → truecolor)", + (await (await tokenOf("True color")).innerText()) === "truecolor" && + (await (await tokenOf("Color infrared")).innerText()) === "colorir", +); +check( + "…and a product no STAC collection carries yet is marked as such (Panchromatic)", + (await (await tokenOf("Panchromatic")).getAttribute("class")).includes("is-nostac"), +); +await shot(page, "11-picker"); +// Exact title: "Sentinel-2" also appears in "Sentinel-2 change products" and the S1+S2 burn severity. +await sensorTabs.filter({ has: page.locator(".req-picker-stitle", { hasText: /^Sentinel-2$/ }) }).click(); +const s2Panel = await panel.innerText(); +check( + "clicking Sentinel-2 shows its products on the right — and not Landsat's", + /Shortwave infrared/.test(s2Panel) && !/Panchromatic/.test(s2Panel) && !/Landsat/.test(s2Panel), +); +await picker.locator(".req-picker-search").fill("swir"); +await page.waitForTimeout(150); +const swirTabs = await sensorTabs.allInnerTexts(); +check( + "searching “swir” narrows the sensor list (plus an All matches entry)", + swirTabs.length > 1 && swirTabs.length < cards + 1 && /All matches/.test(swirTabs[0]), + swirTabs.map((t) => t.replace(/\s+/g, " ")).join(" | "), +); +await panel.locator(".req-picker-row", { hasText: "Shortwave infrared" }).click(); +check( + "…and the match can still be added", + (await panel.locator(".req-picker-row.is-added", { hasText: "Shortwave infrared" }).count()) === 1, +); +await picker.locator(".req-picker-search").fill(""); +await picker.locator(".req-picker-search").press("ArrowDown"); // into the sensor list +await page.keyboard.press("Home"); // -> Landsat (first) +await page.keyboard.press("ArrowDown"); // -> Sentinel-2 +const kbTab = await page.evaluate(() => document.activeElement?.textContent ?? ""); +await page.keyboard.press("Enter"); // -> into its products +const kbRow = await page.evaluate(() => + document.activeElement?.classList.contains("req-picker-row") + ? document.activeElement.querySelector(".req-picker-name")?.textContent + : null, +); +await page.keyboard.press("Enter"); // add it +check( + "keyboard: ↓ Home ↓ Enter Enter adds a Sentinel-2 product", + /Sentinel-2/.test(kbTab) && + !!kbRow && + (await panel.locator(".req-picker-row.is-added", { hasText: kbRow }).count()) === 1, + `${kbTab.replace(/\s+/g, " ")} → ${kbRow}`, +); +await page.keyboard.press("Escape"); +check("Esc closes the picker", (await picker.count()) === 0); + +// --- 9. Portal products with no algorithm yet (status "external") ------------------------------ +// Every Disasters Portal product can be requested, even one no algorithm here makes. Those are +// added by hand only: a hazard must never auto-select a layer that has no DPS job behind it. +await page.goto(BASE, { waitUntil: "networkidle" }); +const hazButtons = page.locator(".req-haz:not(.req-need)"); +const nHaz = await hazButtons.count(); +for (let i = 0; i < nHaz; i++) await hazButtons.nth(i).click(); +await page.waitForTimeout(300); +const autoHeads = await page.locator(".req-prod-group-hd").allInnerTexts(); +check( + "every hazard on: nothing with no algorithm yet is auto-selected", + nHaz >= 10 && autoHeads.length > 0 && !autoHeads.some((t) => /no algorithm yet/i.test(t)), + `${nHaz} hazards → ${autoHeads.length} auto groups`, +); +check( + "…so OPERA DSWx is not auto-selected", + !(await page.locator(".req-chip-prod").allInnerTexts()).some((t) => /DSWx/.test(t)), +); +await page.locator("button", { hasText: "Add products" }).click(); +await page.locator(".req-prodpick .req-picker-search").fill("DSWx"); +await page.waitForTimeout(200); +// Two-column picker: the OPERA sensor on the left, its products on the right. +const operaTab = page.locator('.req-prodpick [role="tab"]').filter({ hasText: /OPERA/ }).first(); +await operaTab.click(); +const operaPanel = page.locator('.req-prodpick [role="tabpanel"]'); +const dswxRow = operaPanel.locator(".req-picker-row", { hasText: "Dynamic Surface Water Extent" }).first(); +check( + "the picker offers OPERA DSWx, labelled “no algorithm yet”", + /no algorithm yet/i.test(await operaTab.innerText()) && + /no algorithm yet/i.test(await operaPanel.locator(".req-picker-group-head").innerText()) && + (await dswxRow.count()) === 1, +); +await dswxRow.click(); +await page.keyboard.press("Escape"); +await page.waitForTimeout(300); +// Anchored on the exact title: a bare hasText "OPERA" is case-insensitive and also hits "operator". +const operaGroup = page.locator(".req-prod-group").filter({ + has: page.locator(".req-prod-group-name", { hasText: /^OPERA$/ }), +}); +check( + "…adding it puts a manual OPERA DSWx chip under a “no algorithm yet” group", + (await operaGroup.count()) === 1 && + /no algorithm yet/i.test(await operaGroup.locator(".req-prod-group-hd").innerText()) && + /OPERA Dynamic Surface Water Extent \(DSWx\)\s*manual/i.test(await operaGroup.locator(".req-chip").innerText()), +); +const runs = page.locator(".req-runblock").filter({ has: page.locator(".req-runblock-head b", { hasText: /^OPERA$/ }) }); +check( + "“What this will run” says it is produced outside the catalog, with no DPS job", + // textContent, not innerText: the panel is a closed
, whose body innerText hides. + (await runs.count()) === 1 && /produced outside this catalog[\s\S]*No DPS job is created/.test(await runs.textContent()), +); +check( + "…and the live portal preview shows the REAL DSWx gallery card", + (await page.locator(".req-live .pp-card h3").allInnerTexts()).includes("OPERA Dynamic Surface Water Extent (DSWx)"), +); +await shot(page, "12-external-product"); + +// --- 10. Picker rows toggle; a product with no portal card shows as a draft gallery card ---------- +await page.goto(BASE, { waitUntil: "networkidle" }); +await page.locator("button", { hasText: "Add products" }).click(); +const pk = page.locator(".req-prodpick"); +await pk.locator('[role="tab"]').filter({ has: page.locator(".req-picker-stitle", { hasText: /^Umbra SAR$/ }) }).click(); +const gammaRow = pk.locator('[role="tabpanel"] .req-picker-row', { hasText: "Gamma-naught" }); +await gammaRow.click(); +await page.waitForTimeout(200); +check("picker: + adds the product (row becomes a pressed ✓)", (await gammaRow.getAttribute("aria-pressed")) === "true"); +check( + "…a picked product with no portal card shows as a draft gallery card, not silently dropped", + (await page.locator(".req-live .pp-card-ghost h3").allInnerTexts()).some((t) => /Gamma-naught/.test(t)), +); +await gammaRow.click(); +await page.waitForTimeout(200); +check( + "picker: clicking the ✓ again removes it (chip and draft card gone)", + (await gammaRow.getAttribute("aria-pressed")) === "false" && + !(await page.locator(".req-chip-prod").allInnerTexts()).some((t) => /Gamma/.test(t)) && + (await page.locator(".req-live .pp-card-ghost").count()) === 0, +); +await page.keyboard.press("Escape"); + +// --- 11. Adding from the open picker must not shove it off screen -------------------------------- +// The selected list sits ABOVE the picker, so each new sensor group grows the page above it. The +// picker must stay put across each add/remove itself. (Clicking a sensor tab that is half below +// the window may scroll it into view — that is the browser doing its job, so drift is measured +// from just before each add, not from when the picker opened.) +// A sensor title's textContent runs straight into its status badge ("Vantorno algorithm yet"), so +// match the name optionally followed by exactly that badge. +const sensorTitle = (name) => new RegExp(`^${name}(?:no algorithm yet|prototype)?$`); +await page.goto(BASE, { waitUntil: "networkidle" }); +await page.locator("button", { hasText: "Add products" }).click(); +const pk11 = page.locator(".req-prodpick"); +const pickTop = async () => (await pk11.boundingBox()).y; +const scroll0 = await page.evaluate(() => window.scrollY); +const toggle = async (sensor, product) => { + await pk11.locator('[role="tab"]').filter({ has: page.locator(".req-picker-stitle", { hasText: sensorTitle(sensor) }) }).click(); + await page.waitForTimeout(100); + const before = await pickTop(); + await pk11.locator('[role="tabpanel"] .req-picker-row', { hasText: product }).first().click(); + await page.waitForTimeout(150); + return Math.round((await pickTop()) - before); +}; +const drift = []; +for (const [sensor, product] of [ + ["Umbra SAR", "Gamma-naught"], + ["Capella SAR", "Sigma-naught"], + ["Vantor", "Vantor True Color"], + ["GPM IMERG", "GPM"], +]) { + drift.push(await toggle(sensor, product)); +} +check( + "the open picker stays put on screen as each product is added above it", + drift.every((d) => Math.abs(d) <= 1), + `picker drift per add: ${drift.join(", ")} px; page scrolled ${Math.round((await page.evaluate(() => window.scrollY)) - scroll0)} px to compensate`, +); +const removeDrift = await toggle("Vantor", "Vantor True Color"); // its ✓ — removes it +check("…and when one is removed", Math.abs(removeDrift) <= 1, `${removeDrift} px`); +await page.keyboard.press("Escape"); + check("no console/page errors", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" ¶ ")); await browser.close(); diff --git a/algorithm-catalog/scripts/portal_pr.mjs b/algorithm-catalog/scripts/portal_pr.mjs index d42034b5..9c4f6596 100644 --- a/algorithm-catalog/scripts/portal_pr.mjs +++ b/algorithm-catalog/scripts/portal_pr.mjs @@ -7,6 +7,10 @@ // [--title ] [--source-pr ] [--dry-run] ... // // For each request with a `page`, in the portal checkout: +// 0. download its one Google Drive image, sniffing the bytes — a file that isn't shared "Anyone +// with the link" comes back as an HTML page, not an image — and bring it to the PORTAL'S IMAGE +// STANDARD (disasters-portal #556, refined by #654) before it goes in public/img/event/: +// real WebP via `cwebp -q 80 -m 6 -metadata none`, at most 1920px wide, under 500 KB. // 1. write app/site-config/event/event__.ts (src/portal.ts — the same code the app previews) // 2. register it in app/site-config/event/index.ts (import + EVENTS entry, newest startDate first) // 3. run the portal's OWN gates — `biome check --write` on the touched files, then the full @@ -19,14 +23,123 @@ // Plain Node (>= 22.18, which strips TypeScript types natively) + git + pnpm + gh. No npm deps. import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { PORTAL_BRANCH, PORTAL_MARKER, PORTAL_REPO, portalEventFile } from "../src/portal.ts"; +import { driveFileId } from "../src/rules.ts"; const HERE = dirname(fileURLToPath(import.meta.url)); const DATA = join(HERE, "..", "data"); const INDEX = "app/site-config/event/index.ts"; +const IMG_DIR = "public/img/event"; +// Overridable so the download path can be tested against a local server. +const DRIVE_DOWNLOAD = process.env.DRIVE_DOWNLOAD_URL ?? "https://drive.usercontent.google.com/download?export=download&id="; +/** What we will download at all — originals are compressed below, so this only stops absurd files. */ +const MAX_DOWNLOAD_BYTES = 40 * 1024 * 1024; + +// The portal's image standard (disasters-portal PR #556, refined by #654). +const PORTAL_MAX_WIDTH = 1920; +const PORTAL_MAX_BYTES = 500 * 1024; +const CWEBP_ARGS = ["-q", "80", "-m", "6", "-metadata", "none"]; +/** #654 stepped an over-budget image's width down rather than drop below q80; this is the floor. */ +const MIN_STEP_WIDTH = 640; + +const kb = (n) => `${Math.round(n / 1024).toLocaleString("en-US")} KB`; + +/** + * Bring an image to the portal's standard. Per #654: a WebP that ALREADY meets it is kept byte for + * byte (re-encoding lossy WebP only loses quality); anything else is encoded with cwebp at q80, + * capped at 1920px, and — only if still over 500 KB — stepped down in width until it fits. Body + * images are NOT capped at 1280px: #654 dropped that rule because it blurred high-DPI screenshots. + */ +export function toPortalWebp(bytes, kind) { + if (kind.ext === "webp" && bytes.length <= PORTAL_MAX_BYTES && kind.width <= PORTAL_MAX_WIDTH) { + return { bytes, width: kind.width, height: kind.height, note: `${kb(bytes.length)} WebP, ${kind.width}px — already meets the portal standard, kept as is` }; + } + if (kind.ext === "gif") return { error: "GIF can't be converted to the portal's WebP standard — export it as PNG or JPEG" }; + const dir = mkdtempSync(join(tmpdir(), "portal-img-")); + try { + const src = join(dir, `source.${kind.ext}`); + const out = join(dir, "out.webp"); + writeFileSync(src, bytes); + let width = Math.min(kind.width, PORTAL_MAX_WIDTH); + let encoded; + for (;;) { + const resize = width < kind.width ? ["-resize", String(width), "0"] : []; + try { + execFileSync("cwebp", [...CWEBP_ARGS, ...resize, src, "-o", out], { stdio: "pipe" }); + } catch (e) { + if (e.code === "ENOENT") return { error: "cwebp is not installed (brew install webp / apt-get install webp)" }; + return { error: `cwebp could not encode it (${String(e.stderr || e.message).trim().split("\n").pop()})` }; + } + encoded = readFileSync(out); + if (encoded.length <= PORTAL_MAX_BYTES || width <= MIN_STEP_WIDTH) break; + width = Math.max(MIN_STEP_WIDTH, Math.round(width * 0.85)); + } + const got = sniffImage(encoded); + if (got?.ext !== "webp") return { error: "the encoded file is not a real WebP" }; + if (encoded.length > PORTAL_MAX_BYTES) return { error: `still ${kb(encoded.length)} at ${got.width}px — over the portal's 500 KB limit; crop or simplify it` }; + const resized = got.width !== kind.width ? `, ${kind.width} → ${got.width}px` : `, ${got.width}px`; + return { bytes: encoded, width: got.width, height: got.height, note: `${kb(bytes.length)} ${kind.ext.toUpperCase()} → ${kb(encoded.length)} WebP (q80${resized})` }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Format + pixel size from the file's own bytes (the portal's `image` block needs width/height). + * Returns null for anything that is not a PNG, JPEG, WebP or GIF — e.g. Drive's HTML sign-in page. + */ +export function sniffImage(b) { + if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { ext: "png", width: b.readUInt32BE(16), height: b.readUInt32BE(20) }; + if (b.length > 10 && b.toString("latin1", 0, 3) === "GIF") return { ext: "gif", width: b.readUInt16LE(6), height: b.readUInt16LE(8) }; + if (b.length > 30 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP") { + const chunk = b.toString("latin1", 12, 16); + if (chunk === "VP8 ") return { ext: "webp", width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff }; + if (chunk === "VP8L") { + const bits = b.readUInt32LE(21); + return { ext: "webp", width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }; + } + if (chunk === "VP8X") return { ext: "webp", width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 }; + } + if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) { + let o = 2; + while (o + 9 < b.length && b[o] === 0xff) { + const marker = b[o + 1]; + // SOF0-3, 5-7, 9-11, 13-15 carry the frame size; everything else is skipped by its length. + if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) { + return { ext: "jpg", width: b.readUInt16BE(o + 7), height: b.readUInt16BE(o + 5) }; + } + o += 2 + b.readUInt16BE(o + 2); + } + } + return null; +} + +/** Download one page image into the portal checkout. Returns the resolved image or a reason. */ +async function downloadImage(portal, id, img) { + const fileId = driveFileId(img.url); + if (!fileId) return { error: "not a Google Drive file link" }; + let bytes; + try { + const res = await fetch(DRIVE_DOWNLOAD + encodeURIComponent(fileId), { redirect: "follow" }); + if (!res.ok) return { error: `Drive answered HTTP ${res.status} — is it shared “Anyone with the link”?` }; + bytes = Buffer.from(await res.arrayBuffer()); + } catch (e) { + return { error: `download failed (${e.message})` }; + } + const kind = sniffImage(bytes); + if (!kind) return { error: "Drive returned a page, not an image — set its sharing to “Anyone with the link”" }; + if (bytes.length > MAX_DOWNLOAD_BYTES) return { error: `${(bytes.length / 1048576).toFixed(1)} MB — too large to process; export a smaller original` }; + const web = toPortalWebp(bytes, kind); + if (web.error) return { error: web.error }; + const name = `${id}__overview.webp`; + mkdirSync(join(portal, IMG_DIR), { recursive: true }); + writeFileSync(join(portal, IMG_DIR, name), web.bytes); + return { image: { ...img, src: `/img/event/${name}`, width: web.width, height: web.height }, path: `${IMG_DIR}/${name}`, note: web.note }; +} function parseArgs(argv) { const opts = { files: [], dryRun: false }; @@ -94,7 +207,7 @@ export function registerInIndex(portal, constName, id, startDate) { return true; } -function main() { +async function main() { const opts = parseArgs(process.argv.slice(2)); const algorithms = JSON.parse(readFileSync(join(DATA, "algorithms.json"), "utf8")); const hazards = JSON.parse(readFileSync(join(DATA, "hazards.json"), "utf8")); @@ -108,24 +221,38 @@ function main() { const written = []; const skipped = []; + const imageFiles = []; + const imageFailures = []; + const imageNotes = []; for (const r of requests) { - const f = portalEventFile( - { ...r.event, layers: r.products.map(({ algorithm, product }) => ({ algorithm, product })), page: r.page }, - algorithms, - hazards, - today, - ); - const target = join(opts.portal, f.path); + const input = { ...r.event, layers: r.products.map(({ algorithm, product }) => ({ algorithm, product })), page: r.page }; + const f0 = portalEventFile(input, algorithms, hazards, today); + const target = join(opts.portal, f0.path); // Hand-written pages are often .tsx (they embed JSX), so check BOTH extensions — and treat a // page already registered in index.ts under this constant as existing too. const existing = [target, target.replace(/\.ts$/, ".tsx")].find(existsSync); - const registered = new RegExp(`\\b${f.constName}\\b`).test(readFileSync(join(opts.portal, INDEX), "utf8")); + const registered = new RegExp(`\\b${f0.constName}\\b`).test(readFileSync(join(opts.portal, INDEX), "utf8")); if ((existing && !readFileSync(existing, "utf8").startsWith(PORTAL_MARKER)) || (registered && !existing)) { skipped.push( - `${f.id} already exists on the portal and was written by hand — not overwritten. Edit it there directly.`, + `${f0.id} already exists on the portal and was written by hand — not overwritten. Edit it there directly.`, ); continue; } + // The image first: its block needs the real path and pixel size. + const resolved = []; + for (const img of r.page.image?.url?.trim() ? [r.page.image] : []) { + const got = await downloadImage(opts.portal, f0.id, img); + if (got.image) { + resolved.push(got.image); + imageFiles.push(got.path); + imageNotes.push(`\`${got.path}\` — ${got.note}`); + console.log(`image ${got.path} (${got.image.width}×${got.image.height}) — ${got.note}`); + } else { + imageFailures.push(`${r.event.name} — image ${img.url}: ${got.error}`); + console.log(`image FAILED ${img.url}: ${got.error}`); + } + } + const f = portalEventFile(input, algorithms, hazards, today, resolved); writeFileSync(target, f.source); registerInIndex(opts.portal, f.constName, f.id, r.event.start); written.push({ ...f, name: r.event.name, stacName: r.event.stacName }); @@ -138,6 +265,7 @@ function main() { // difference can never be what fails the PR; then the real checks over the WHOLE repo. const touched = [...written.map((w) => w.path), INDEX]; run(opts.portal, "pnpm", ["exec", "biome", "check", "--write", ...touched]); + touched.push(...imageFiles); // committed, but not Biome's business run(opts.portal, "pnpm", ["lint:ci"]); run(opts.portal, "pnpm", ["typecheck"]); console.log("portal lint:ci + typecheck passed"); @@ -148,7 +276,14 @@ function main() { "", ...written.map((w) => `- **${w.name}** (\`${w.stacName}\`) → \`${w.path}\`${w.unmapped.length ? ` — no data-gallery card yet for: ${w.unmapped.join(", ")}` : ""}`), "", - "The hero and thumbnail use the portal's placeholder image (`/img/event/template-hero.webp`) — swap in the real one before merging.", + ...(imageNotes.length + ? ["", "Image from Google Drive, brought to the portal's image standard (#556/#654: real WebP, q80, ≤ 1920px, < 500 KB):", ...imageNotes.map((n) => `- ${n}`)] + : []), + ...(imageFailures.length + ? ["", "⚠ **Could not download the image** — it is listed in the page file as a comment; add it by hand:", ...imageFailures.map((m) => `- ${m}`)] + : []), + "", + "The masthead and card thumbnail use the portal's default event image (`/img/event/template-hero.webp`).", ...(opts.sourcePr ? ["", `Source request: ${opts.sourcePr}`] : []), "", "Lint (`pnpm lint:ci`) and typecheck (`pnpm typecheck`) were run against this branch before the PR was opened.", @@ -175,4 +310,4 @@ function main() { if (process.env.GITHUB_OUTPUT) writeFileSync(process.env.GITHUB_OUTPUT, `url=${url}\n`, { flag: "a" }); } -if (process.argv[1] === fileURLToPath(import.meta.url)) main(); +if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); diff --git a/algorithm-catalog/scripts/rules_parity_test.py b/algorithm-catalog/scripts/rules_parity_test.py index 1bf6669e..32b63910 100644 --- a/algorithm-catalog/scripts/rules_parity_test.py +++ b/algorithm-catalog/scripts/rules_parity_test.py @@ -34,6 +34,7 @@ from validate_data import ( # noqa: E402 BBOX_MIN_LAT_SPAN, + DRIVE_FILE_RE, HTTPS_URL_RE, ISO_DATE_RE, MGRS_TILE_RE, @@ -41,6 +42,7 @@ PLACE_ID_RE, PORTAL_CATEGORIES_TEXT, PORTAL_ID_RE, + PORTAL_NAME_RE, STAC_EVENT_RE, STAC_EXAMPLE, STAC_PART_COUNT, @@ -95,7 +97,9 @@ def fail(msg: str) -> None: "HTTPS_URL_RE": HTTPS_URL_RE, "PLACE_ID_RE": PLACE_ID_RE, "PORTAL_ID_RE": PORTAL_ID_RE, + "PORTAL_NAME_RE": PORTAL_NAME_RE, "PORTAL_CATEGORIES_TEXT": PORTAL_CATEGORIES_TEXT, + "DRIVE_FILE_RE": DRIVE_FILE_RE, } @@ -273,6 +277,23 @@ def check_inline_regexes() -> None: ("page", {"portalId": "southern-california-fire-jan-2025", "intro": "x", "needs": ["respond"]}, True, None, "a real portal id"), ("page", {"portalId": "Southern_California"}, False, "format", "portal ids are lowercase-hyphenated"), ("page", {"intro": "x", "needs": ["respond"], "start": "1999-12-31"}, False, "portal-year", "portal DateString is 20xx only"), + # --- the page address is derived from the name, so a NEW page's name must end " " -- + ("page", {"intro": "x", "needs": ["respond"], "name": "Hurricane Isaias October 2026", "start": "2026-10-09"}, True, None, "month + year"), + ("page", {"intro": "x", "needs": ["respond"], "name": "Hurricane Isaias", "start": "2026-10-09"}, False, "portal-name", "no month/year"), + ("page", {"intro": "x", "needs": ["respond"], "name": "Hurricane Isaias Oct 2026", "start": "2026-10-09"}, False, "portal-name", "month spelled out"), + ("page", {"intro": "x", "needs": ["respond"], "name": "California Fires", "portalId": "southern-california-fire-jan-2025"}, True, None, "an existing portal page keeps its id — exempt"), + # --- the page's one image (a Google Drive link) -------------------------------------------------------- + *[ + ("page", {"intro": "x", "needs": ["respond"], "image": img}, ok, rule, note) + for img, ok, rule, note in [ + ({"url": "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOp/view?usp=sharing", "alt": "Burn scar"}, True, None, "a shared Drive file link"), + ({"url": "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOp", "alt": "Fire"}, True, None, "the open?id= form"), + ({"url": "https://drive.google.com/uc?export=view&id=1AbCdEfGhIjKlMnOp", "alt": "Fire"}, True, None, "the uc?export=view form"), + ({"url": "https://drive.google.com/drive/folders/1AbCdEfGhIjKlMnOp", "alt": "x"}, False, "drive-folder", "a FOLDER is not an image"), + ({"url": "https://example.com/fire.jpg", "alt": "x"}, False, "drive-link", "not Google Drive"), + ({"url": "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOp/view", "alt": " "}, False, "alt-required", "alt text is required"), + ] + ], ("page", {"govLink": "http://www.usa.gov/x"}, False, "format", "http is refused"), ("page", {"govLink": "https://usa"}, False, "format", "a host needs a dot"), ("page", {"govLink": "www.usa.gov"}, False, "format", "no scheme"), @@ -304,6 +325,7 @@ def check_inline_regexes() -> None: ("places", ["US-AL-c51000", "US-GA"], [], "a city and a different state — fine"), ("page", {"needs": ["respond"]}, ["missing"], "a page was started but has no intro"), ("page", {"intro": "x"}, ["missing"], "a page with no Need"), + ("page", {"intro": "x", "needs": ["respond"], "name": "Hurricane Isaias September 2026", "start": "2026-10-09"}, ["name-date"], "name month ≠ start month"), ("page", {}, [], "no page started — no nag"), ] diff --git a/algorithm-catalog/scripts/sync_portal_cards.py b/algorithm-catalog/scripts/sync_portal_cards.py new file mode 100644 index 00000000..205e4ec4 --- /dev/null +++ b/algorithm-catalog/scripts/sync_portal_cards.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Snapshot what the live-portal preview needs from a Disasters Portal checkout. + +The Submit form's live preview renders the event page the way the portal will — including the +REAL data-gallery cards the generated page points at (data/algorithms.json `portalData`). Those +cards live in the portal repo, and this app never fetches at run time, so their text and a +downsized thumbnail are copied in here: + + data/portal-cards.json {"DATA__X": {title, description, themes, categories, image}} + public/portal/cards/.webp thumbnails at 480px, encoded the portal's own way (its PRs + #556/#654: `cwebp -q 80 -m 6 -metadata none`) — needs `cwebp` + (brew install webp) + public/portal/template-hero.webp the portal's placeholder hero (every generated page uses it) + public/portal/disasters-logo.svg the header logo + +Re-run when the portal's cards change or a new `portalData` mapping is added: + git clone --depth 1 -b develop https://github.com/Disasters-Learning-Portal/disasters-portal.git /tmp/portal + python3 -I algorithm-catalog/scripts/sync_portal_cards.py /tmp/portal + +stdlib only. Reads the portal's TypeScript with regexes — only the plain string-literal fields. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent # algorithm-catalog/ +OUT_JSON = ROOT / "data" / "portal-cards.json" +OUT_PUBLIC = ROOT / "public" / "portal" + +_STR = r'"((?:[^"\\]|\\.)*)"' + + +def field(src: str, name: str) -> str: + m = re.search(rf"\b{name}:\s*{_STR}", src) + return json.loads(f'"{m.group(1)}"') if m else "" + + +def array(src: str, name: str) -> list[str]: + m = re.search(rf"\b{name}:\s*\[(.*?)\]", src, re.S) + return [json.loads(f'"{s}"') for s in re.findall(_STR, m.group(1))] if m else [] + + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print(__doc__) + return 2 + portal = Path(argv[0]).resolve() + algorithms = json.loads((ROOT / "data" / "algorithms.json").read_text()) + wanted = sorted({p["portalData"] for a in algorithms for p in a["products"] if p.get("portalData")}) + + (OUT_PUBLIC / "cards").mkdir(parents=True, exist_ok=True) + if shutil.which("cwebp") is None: + print("cwebp is required (brew install webp / apt-get install webp)") + return 2 + written: set[str] = set() + cards: dict[str, dict] = {} + for const in wanted: + slug = const.removeprefix("DATA__").lower().replace("_", "-") + path = next((p for p in (portal / f"app/site-config/data/data__{slug}{x}" for x in (".ts", ".tsx")) if p.exists()), None) + if path is None: + print(f"MISSING {const}: no app/site-config/data/data__{slug}.ts(x) in the portal") + continue + src = path.read_text() + thumb = re.search(r"thumbnailImage:\s*\{\s*src:\s*" + _STR, src) + image = "" + if thumb: + source = portal / "public" / thumb.group(1).lstrip("/") + dest = OUT_PUBLIC / "cards" / f"{slug}.webp" + # 480px is plenty: the card image is 200px wide in the 1280px preview. + subprocess.run(["cwebp", "-q", "80", "-m", "6", "-metadata", "none", "-resize", "480", "0", + str(source), "-o", str(dest)], check=True, capture_output=True) + written.add(dest.name) + image = f"/portal/cards/{dest.name}" + cards[const] = { + "title": field(src, "title"), + "description": field(src, "description"), + "themes": array(src, "themes"), + "categories": array(src, "categories"), + "image": image, + } + print(f"ok {const}: {cards[const]['title']}") + + for stale in (OUT_PUBLIC / "cards").iterdir(): + if stale.name not in written: + stale.unlink() # a card no longer referenced, or an older format + for name, rel in (("template-hero.webp", "img/event/template-hero.webp"), ("disasters-logo.svg", "img/disasters-logo.svg")): + shutil.copyfile(portal / "public" / rel, OUT_PUBLIC / name) + OUT_JSON.write_text(json.dumps(cards, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + size = sum(f.stat().st_size for f in OUT_PUBLIC.rglob("*") if f.is_file()) + print(f"\nwrote data/portal-cards.json ({len(cards)} cards) and public/portal/ ({size:,} bytes)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/algorithm-catalog/scripts/sync_stac_assets.py b/algorithm-catalog/scripts/sync_stac_assets.py new file mode 100644 index 00000000..2b935178 --- /dev/null +++ b/algorithm-catalog/scripts/sync_stac_assets.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Fill each product's STAC names in data/algorithms.json FROM THE LIVE DISASTERS STAC — never by hand. + + stacAsset the product-specific asset key on the live items, e.g. "truecolor", "colorir", + "backscatter", "blackmarble_hd" — the asset whose roles include "data" + stacCollections every live collection carrying it, e.g. ["sentinel2-truecolor-daily", + "sentinel2-truecolor-subdaily"] + +These two fields are GENERATED. Nobody types them and the app offers no way to edit them; re-run this +script instead. `cog_default` (the collections' generic item_assets entry) and the +`rendered_preview_*` / dashboard renders are NOT asset names and are never used. + +Which collection belongs to which product is decided by rule, and an unsure match is reported, +never guessed. A product CLAIMS a collection when: + * its sensor (the id's first segment, e.g. "sentinel2", "umbra") is the product's algorithm id + [strong] or appears in the product's portal card title [weak], and + * its live asset key equals the product's token or output folder [strong], or appears in the + product's portal card title [weak] — that is how "Umbra Backscatter" finds `backscatter` and + "Black Marble HD" finds `blackmarble_hd`; + * or, regardless of sensor, the asset key equals the product's token/folder AND no other live + collection uses that key (an exact, unique name cannot be ambiguous: `burnseverity`). +A product with any strong-sensor claim drops its weak-sensor ones: SkySat's card says "Planet", but +SkySat has its own skysat-* collections, so it must not also take planet-colorir-daily. +Each collection goes to its single highest-scoring claimant (strong = 2, weak = 1). A tie at the +top is assigned to nobody and listed for a human — so "Planet NDVI Change" mentioning NDVI never +steals `planet-ndvi-daily` from Planet NDVI, but two equally good claims are never guessed. + +Run (needs network; stdlib only): + python3 -I algorithm-catalog/scripts/sync_stac_assets.py # update algorithms.json + python3 -I algorithm-catalog/scripts/sync_stac_assets.py --check # exit 1 if it is stale +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent # algorithm-catalog/ +ALGORITHMS = ROOT / "data" / "algorithms.json" +CARDS = ROOT / "data" / "portal-cards.json" +DEFAULT_API = "https://disasters.openveda.cloud/api/stac" +CADENCE = re.compile(r"-(daily|subdaily|composite)$") + +norm = lambda s: re.sub(r"[^a-z0-9]", "", (s or "").lower()) # noqa: E731 + + +def get(url: str) -> dict: + with urllib.request.urlopen(url, timeout=60) as r: + return json.load(r) + + +def live_assets(api: str) -> dict[str, str]: + """{collection id: its product-specific asset key}, read off a real item of each collection.""" + ids = [c["id"] for c in get(f"{api}/collections?limit=1000")["collections"]] + + def one(cid: str) -> tuple[str, str | None]: + feats = get(f"{api}/collections/{cid}/items?limit=1").get("features") or [] + if not feats: + return cid, None + keys = [k for k, a in feats[0].get("assets", {}).items() if "data" in (a.get("roles") or []) and k != "cog_default"] + return cid, keys[0] if len(keys) == 1 else None + + with ThreadPoolExecutor(max_workers=8) as pool: + return {cid: key for cid, key in pool.map(one, ids) if key} + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--api", default=DEFAULT_API, help=f"STAC API root (default {DEFAULT_API})") + ap.add_argument("--check", action="store_true", help="report only; exit 1 if algorithms.json is stale") + args = ap.parse_args(argv) + + text = ALGORITHMS.read_text() + algorithms = json.loads(text) + cards = json.loads(CARDS.read_text()) if CARDS.exists() else {} + assets = live_assets(args.api) + print(f"{len(assets)} live collections with a product-specific asset ({args.api})") + + key_uses: dict[str, int] = {} + for key in assets.values(): + key_uses[norm(key)] = key_uses.get(norm(key), 0) + 1 + + claims: dict[str, list[tuple[int, dict, dict]]] = {} + for a in algorithms: + for p in a["products"]: + title = norm(cards.get(p.get("portalData", ""), {}).get("title", "")) + names = {norm(p["id"]), norm(p.get("folder", ""))} + mine: list[tuple[str, int, int]] = [] # (collection, sensor score, total score) + for cid, key in assets.items(): + sensor = norm(cid.split("-")[0]) + k = norm(key) + sensor_score = 2 if sensor == norm(a["id"]) else 1 if sensor in title else 0 + name_score = 2 if k in names else 1 if (title and k in title) else 0 + if sensor_score and name_score: + mine.append((cid, sensor_score, sensor_score + name_score)) + elif name_score == 2 and key_uses[k] == 1: + mine.append((cid, 0, 2)) + strong_sensor = any(ss == 2 for _, ss, _ in mine) + for cid, ss, score in mine: + if strong_sensor and ss == 1: + continue # a sensor only named in the card title, when the product has its own + claims.setdefault(cid, []).append((score, a, p)) + + found: dict[int, set[str]] = {} + ambiguous = [] + for cid, who in sorted(claims.items()): + top = max(score for score, _, _ in who) + best = [(a, p) for score, a, p in who if score == top] + if len(best) > 1: + ambiguous.append(f"{cid} ({assets[cid]}) — equally claimed by {', '.join(f'{a['id']}:{p['id']}' for a, p in best)}") + continue + found.setdefault(id(best[0][1]), set()).add(cid) + + changes = [] + for a in algorithms: + for p in a["products"]: + cols = sorted(found.get(id(p), set())) + keys = sorted({assets[c] for c in cols}) + if len(keys) > 1: + ambiguous.append(f"{a['id']}:{p['id']} — its collections disagree on the asset key: {keys}") + cols, keys = [], [] + before = (p.get("stacAsset"), p.get("stacCollections")) + p.pop("stacAsset", None) + p.pop("stacCollections", None) + if keys: + p["stacAsset"] = keys[0] + p["stacCollections"] = cols + after = (p.get("stacAsset"), p.get("stacCollections")) + if before != after: + changes.append(f"{a['id']}:{p['id']}: {before[0] or '—'} -> {after[0] or '—'} {after[1] or ''}") + + linked = {c for cs in found.values() for c in cs} + print(f"{sum(1 for a in algorithms for p in a['products'] if p.get('stacAsset'))} products linked to live STAC") + for c in changes: + print(f" change {c}") + for x in ambiguous: + print(f" UNSURE {x}") + unclaimed = sorted(c for c in assets if c not in linked and c not in claims) + print(f" {len(unclaimed)} live collections match no catalog product: {', '.join(unclaimed)}") + + if args.check: + return 1 if changes else 0 + out = json.dumps(algorithms, indent=2, ensure_ascii=False) + "\n" + if out != text: + ALGORITHMS.write_text(out, encoding="utf-8") + print("wrote data/algorithms.json") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/algorithm-catalog/scripts/validate_data.py b/algorithm-catalog/scripts/validate_data.py index 9b1d9b8f..154daecb 100644 --- a/algorithm-catalog/scripts/validate_data.py +++ b/algorithm-catalog/scripts/validate_data.py @@ -91,9 +91,15 @@ #: A gazetteer id: country, state, county FIPS, or "c" + city FIPS. A parent prefixes its children. PLACE_ID_RE = r'^[A-Z]{2}(-[A-Z]{2}(-([0-9]{3}|c[0-9]{5}))?)?$' +#: A Google Drive FILE link; group 1 is the file id. Folder links are rejected with their own message. +DRIVE_FILE_RE = r'^https:\/\/drive\.google\.com\/(?:file\/d\/|open\?id=|uc\?(?:export=[a-z]+&)?id=)([A-Za-z0-9_-]{10,})' + #: A Disasters Portal content id: its URL slug and file name. PORTAL_ID_RE = r'^[a-z0-9]+(-[a-z0-9]+)*$' +#: A NEW portal page's id is derived from the event name, so the name must end " ". +PORTAL_NAME_RE = r'\s(January|February|March|April|May|June|July|August|September|October|November|December)\s(20[0-9]{2})$' + #: The portal's CONTENT_CATEGORIES (disasters-portal app/site-config/types.ts), pipe-joined. PORTAL_CATEGORIES_TEXT = "cyclone|earthquake|fire|flood|heat|hurricane|landslide|severe weather|tropical cyclone|tsunami|typhoon|volcano|winter weather" PORTAL_CATEGORIES = PORTAL_CATEGORIES_TEXT.split("|") @@ -105,6 +111,16 @@ _HTTPS_URL = re.compile(HTTPS_URL_RE) _PLACE_ID = re.compile(PLACE_ID_RE) _PORTAL_ID = re.compile(PORTAL_ID_RE) +_PORTAL_NAME = re.compile(PORTAL_NAME_RE) +_MONTH_NAMES = PORTAL_NAME_RE.split("(")[1].split(")")[0].split("|") +_DRIVE_FILE = re.compile(DRIVE_FILE_RE) +_DRIVE_FOLDER = re.compile(r'^https:\/\/drive\.google\.com\/drive\/(u\/[0-9]+\/)?folders\/') + + +def drive_file_id(url: str) -> str | None: + """The Drive file id in a link, or None. Mirrors rules.ts driveFileId.""" + m = _DRIVE_FILE.search(url.strip()) if "\n" not in url else None + return m.group(1) if m else None _ISO_DATE = re.compile(ISO_DATE_RE) _YEARMONTH = re.compile(r'^[0-9]{6}$') _MONTH = re.compile(r'^(0[1-9]|1[0-2])$') @@ -370,6 +386,15 @@ def check_page(p: dict[str, Any], vocabulary: list[dict[str, Any]] | None = None """The event page's own text. All optional; only a bad value is an error. Once a page is started, the Disasters Portal's own type rules apply too.""" out: list[Violation] = [] + img = _obj(p, "image") if _s(_obj(p, "image"), "url").strip() else None + if img: + url = _s(img, "url").strip() + if _DRIVE_FOLDER.search(url): + out.append(_err("image", "drive-folder", "That is a Drive FOLDER link — link the image file itself (right-click it → Share → Copy link).")) + elif not drive_file_id(url): + out.append(_err("image", "drive-link", "The image must be a Google Drive file link, e.g. https://drive.google.com/file/d/…/view.")) + if not _s(img, "alt").strip(): + out.append(_err("image", "alt-required", "The image needs alt text — a short description of it for screen readers.")) needs = _strs(p, "needs") for n in needs: if n not in NEEDS: @@ -382,9 +407,18 @@ def check_page(p: dict[str, Any], vocabulary: list[dict[str, Any]] | None = None out.append( _err("portalId", "format", f'"{portal_id}" must be lowercase words joined by hyphens, e.g. southern-california-fire-jan-2025.') ) - started = bool(_s(p, "subtitle").strip() or _s(p, "intro").strip() or link or needs or portal_id) + started = bool(_s(p, "subtitle").strip() or _s(p, "intro").strip() or link or needs or portal_id or img) if not started: return out + name = _s(p, "name").strip() + if not portal_id and name: + m = _PORTAL_NAME.search(name) if "\n" not in name else None + if not m: + out.append(_err("name", "portal-name", 'End the name with the month and year, e.g. "Hurricane Isaias October 2026" — it becomes the page\'s web address.')) + elif re.match(r"^[0-9]{4}-[0-9]{2}", _s(p, "start")): + mm = f"{_MONTH_NAMES.index(m.group(1)) + 1:02d}" + if f"{m.group(2)}-{mm}" != _s(p, "start")[:7]: + out.append(_warn("name", "name-date", f"The name says {m.group(1)} {m.group(2)} but the event starts {_s(p, 'start')}.")) if not _s(p, "intro").strip(): out.append(_warn("intro", "missing", "The page has no intro paragraph, so its Overview section will be empty.")) if not needs: @@ -467,6 +501,7 @@ def validate_request(r: dict[str, Any], vocabulary: list[dict[str, Any]]) -> lis "places": '"places": ["US-AL"] or ["US-AL-c51000"] (ids from data/places*.json)', "needs": '"needs": ["respond"] (portal theme ids: prepare, respond, recover, resilience)', "govLink": '"govLink": "https://www.usa.gov/disasters-and-emergencies"', + "image": '"image": {"url": "https://drive.google.com/file/d//view", "alt": "…", "caption": "… Credit: NASA"}', "ts": '"ts": "2025-01-08T17:04:11.284Z"', "": "a JSON array of records, e.g. []", } @@ -641,6 +676,11 @@ def check_hazard_vocabulary(rep: Report, hazards: list[dict[str, Any]]) -> None: ) +#: src/types.ts `AlgorithmStatus`. "external" = no algorithm here yet: the product is made outside +#: this catalog and only linked to its Disasters Portal card, so it has no DPS inputs to declare. +ALGORITHM_STATUSES = ("production", "prototype", "external") + + def check_algorithms(rep: Report, algorithms: list[dict[str, Any]], hazard_ids: set[str]) -> None: file = "data/algorithms.json" dup_check(rep, file, algorithms, "id", "Algorithm") @@ -648,6 +688,18 @@ def check_algorithms(rep: Report, algorithms: list[dict[str, Any]], hazard_ids: aid = _s(a, "id") where = f"[{i}] id={aid or '?'}" + status = _s(a, "status") + if status not in ALGORITHM_STATUSES: + rep.error( + file, where, "status", "vocabulary", + f'status "{status}" must be one of: {", ".join(ALGORITHM_STATUSES)}.', + ) + elif status == "external" and _dicts(a, "inputs"): + rep.error( + file, where, "inputs", "external-inputs", + 'An "external" algorithm creates no DPS job, so it must declare no inputs.', + ) + start = _s(a, "temporalStart") if not start: rep.error(file, where, "temporalStart", "required", "temporalStart is required (YYYY-MM-DD).") @@ -683,6 +735,17 @@ def check_algorithms(rep: Report, algorithms: list[dict[str, Any]], hazard_ids: if not pid: rep.error(file, pwhere, "products", "required", "Product is missing an id.", example=ALGORITHM_PRODUCT_EXAMPLE) + # stacAsset/stacCollections are GENERATED by scripts/sync_stac_assets.py from the live + # STAC. Only their shape is checked here (CI has no network); `sync_stac_assets.py + # --check` is what proves they still match the live STAC. + stac_asset, stac_cols = p.get("stacAsset"), p.get("stacCollections") + if (stac_asset is None) != (stac_cols is None): + rep.error(file, pwhere, "stacAsset", "pair", "stacAsset and stacCollections come together (re-run scripts/sync_stac_assets.py).") + elif stac_asset is not None and not ( + isinstance(stac_asset, str) and re.fullmatch(r"[a-z0-9_-]+", stac_asset) + and isinstance(stac_cols, list) and stac_cols and all(isinstance(c, str) and c for c in stac_cols) + ): + rep.error(file, pwhere, "stacAsset", "shape", "stacAsset must be a lowercase STAC asset key with a non-empty stacCollections list — re-run scripts/sync_stac_assets.py, never edit by hand.") portal_data = p.get("portalData") if portal_data is not None and not (isinstance(portal_data, str) and PORTAL_DATA_RE.match(portal_data)): rep.error( @@ -744,7 +807,7 @@ def check_events( rep.add(file, where, v) check_place_refs(rep, file, where, _strs(e, "places"), place_ids) if "page" in e: - page = {**_obj(e, "page"), "start": _s(e, "start"), "hazards": _strs(e, "hazards")} + page = {**_obj(e, "page"), "start": _s(e, "start"), "hazards": _strs(e, "hazards"), "name": _s(e, "name")} for v in check_page(page, hazards): rep.add(file, where, v) @@ -852,6 +915,7 @@ def check_requests( "govLink": _s(page, "govLink"), "needs": _strs(page, "needs"), "portalId": _s(page, "portalId"), + "image": _obj(page, "image"), } for v in validate_request(shape, hazards): rep.add(file, where, v) diff --git a/algorithm-catalog/src/catalog/AlgorithmCard.tsx b/algorithm-catalog/src/catalog/AlgorithmCard.tsx index 1a7393c9..4ccce5e5 100644 --- a/algorithm-catalog/src/catalog/AlgorithmCard.tsx +++ b/algorithm-catalog/src/catalog/AlgorithmCard.tsx @@ -77,6 +77,14 @@ export default function AlgorithmCard({ prototype )} + {a.status === "external" && ( + + no algorithm yet + + )}

{a.vendor}

diff --git a/algorithm-catalog/src/catalog/AlgorithmDetail.tsx b/algorithm-catalog/src/catalog/AlgorithmDetail.tsx index 198690d5..738cb83e 100644 --- a/algorithm-catalog/src/catalog/AlgorithmDetail.tsx +++ b/algorithm-catalog/src/catalog/AlgorithmDetail.tsx @@ -21,6 +21,8 @@ const SELECTOR_LABEL: Record = { export default function AlgorithmDetail({ algorithm: a, hazards, matchedIds, onClose }: Props) { const m = modalityStyle(a.modality); + // A product made outside this catalog has no DPS job: no config, inputs or resources to show. + const external = a.status === "external"; return (
@@ -40,6 +42,8 @@ export default function AlgorithmDetail({ algorithm: a, hazards, matchedIds, onC {a.status === "prototype" ? ( prototype — no DPS algorithm registered + ) : a.status === "external" ? ( + no algorithm yet — produced outside this catalog ) : ( production )} @@ -51,7 +55,13 @@ export default function AlgorithmDetail({ algorithm: a, hazards, matchedIds, onC
DPS algorithm
- {a.algorithmName} @ {a.version} + {external ? ( + none — produced outside this catalog, no DPS job is created + ) : ( + <> + {a.algorithmName} @ {a.version} + + )}
Resolution
{a.resolution}
@@ -63,13 +73,17 @@ export default function AlgorithmDetail({ algorithm: a, hazards, matchedIds, onC (mission launch — approximate record start) )} -
Spatial input
-
{SELECTOR_LABEL[a.spatialSelector] ?? a.spatialSelector}
-
Resources
-
- {a.resources.ramMin} GB RAM · {a.resources.coresMin} cores · {a.resources.outdirMax} GB - output -
+ {!external && ( + <> +
Spatial input
+
{SELECTOR_LABEL[a.spatialSelector] ?? a.spatialSelector}
+
Resources
+
+ {a.resources.ramMin} GB RAM · {a.resources.coresMin} cores · {a.resources.outdirMax} GB + output +
+ + )} {a.keywords.length > 0 && ( <>
Keywords
@@ -103,45 +117,56 @@ export default function AlgorithmDetail({ algorithm: a, hazards, matchedIds, onC ))} -

- Job inputs ({a.inputs.length}) -

-
- - - - - - - - - - - {a.inputs.map((i) => ( - - - - - - - ))} - -
NameTypeRequiredDefault
- {i.name} -
{i.label}
- {i.doc &&
{i.doc}
} -
{i.type}{i.required ? yes : "no"}{i.default ? {i.default} : —}
-
+ {!external && ( + <> +

+ Job inputs ({a.inputs.length}) +

+
+ + + + + + + + + + + {a.inputs.map((i) => ( + + + + + + + ))} + +
NameTypeRequiredDefault
+ {i.name} +
{i.label}
+ {i.doc &&
{i.doc}
} +
{i.type}{i.required ? yes : "no"}{i.default ? {i.default} : —}
+
+ + )}

Resources

-

- Thumbnail: {a.thumbCredit}. Every figure above mirrors the algorithm's upstream - algorithm_config.yaml — it is what DPS will actually enforce at run time. -

+ {external ? ( +

+ Thumbnail: {a.thumbCredit}. These products are made outside this catalog; their text comes + from their Disasters Portal data-gallery cards. +

+ ) : ( +

+ Thumbnail: {a.thumbCredit}. Every figure above mirrors the algorithm's upstream + algorithm_config.yaml — it is what DPS will actually enforce at run time. +

+ )}
- algorithm_config.yaml ↗ + {external ? "Disasters Portal data page ↗" : "algorithm_config.yaml ↗"}
{view === "page" ? ( - + + + ) : ( <>
diff --git a/algorithm-catalog/src/events/EventPage.tsx b/algorithm-catalog/src/events/EventPage.tsx deleted file mode 100644 index 46b5a534..00000000 --- a/algorithm-catalog/src/events/EventPage.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// What the event's page on science.data.nasa.gov will say, laid out in the portal's own section -// order. Read-only: the text is edited in the Submit form (Events → "Edit page"), and this same -// component previews it live there, so what you type is what the web team receives. - -import type { Algorithm, Hazard } from "../types"; -import { modality as modalityStyle } from "../colors"; -import { pageView, portalDate, type PageEvent } from "../pageExport"; - -interface Props { - event: PageEvent; - algorithms: Algorithm[]; - hazards: Hazard[]; -} - -export default function EventPage({ event, algorithms, hazards }: Props) { - const v = pageView(event, algorithms, hazards); - const missing = not set; - - return ( -
-
-
Event
-

{v.title || missing}

- {v.subtitle &&

{v.subtitle}

} -
- -
-
Type
-
{v.type}
-
Need
-
{v.needs.join(", ") || missing}
-
Hazard
-
{v.hazards.join(", ") || missing}
-
Region
-
{v.region.join("; ") || missing}
-
Start Date
-
{v.startDate ? portalDate(v.startDate) : missing}
-
- {v.govLink && ( -

- What the U.S. government is doing{" "} - - Learn more ↗ - -

- )} - -
-

Overview

- {v.paragraphs.length > 0 ? ( - v.paragraphs.map((p, i) =>

{p}

) - ) : ( -

No intro written yet — the Overview section will be empty.

- )} -
- -
-

- Event Data Gallery ({v.gallery.length}) -

-
- {v.gallery.map((g, i) => { - const m = g.modality ? modalityStyle(g.modality) : null; - return ( -
- {m && {m.icon} } - {g.title} -
- ); - })} - {v.gallery.length === 0 && No layers recorded.} -
-
-
- ); -} diff --git a/algorithm-catalog/src/events/PortalPage.tsx b/algorithm-catalog/src/events/PortalPage.tsx new file mode 100644 index 00000000..0be95438 --- /dev/null +++ b/algorithm-catalog/src/events/PortalPage.tsx @@ -0,0 +1,289 @@ +// The event page as it will look ON THE DISASTERS PORTAL (science.data.nasa.gov/disasters) — the +// portal's own header, hero masthead, sidebar and Event Data Gallery, with its fonts, colors and +// proportions (measured off the live California page), at the portal's 1280px desktop width. +// scales that to whatever space it is given, so it reads like a screenshot of the +// real site rather than a reflowed approximation. +// +// Everything shown is what the generated portal file (src/portal.ts) will produce: the masthead +// description is the sub-title, the tags are its theme ids and CONTENT_CATEGORIES, and the gallery +// is the REAL portal cards the file imports — snapshotted into data/portal-cards.json by +// scripts/sync_portal_cards.py, since this app never fetches at run time. + +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import "../portal.css"; +import type { Algorithm, Hazard, PageImage } from "../types"; +import { driveFileId } from "../rules"; +import { NEED_LABELS, PORTAL_PLACEHOLDER_IMAGE, typographic, type PortalInput } from "../portal"; +import { portalDate } from "../pageExport"; +import cardsRaw from "../../data/portal-cards.json"; + +interface PortalCard { + title: string; + description: string; + themes: string[]; + categories: string[]; + image: string; +} +const CARDS = cardsRaw as Record; + +/** The portal's card tag row: themes, then categories, then the content type — 5 shown. */ +const MAX_TAGS = 5; +const tagLabel = (t: string) => (NEED_LABELS[t as keyof typeof NEED_LABELS] ?? t).toUpperCase(); + +const today = () => new Date().toISOString().slice(0, 10); + +// Our copy of the portal's placeholder hero; the generated file points at the portal's own. +const HERO = `/portal/${PORTAL_PLACEHOLDER_IMAGE.split("/").pop()}`; + +/** The cards the generated page will show (in order), and the layers that have none on the portal. */ +export function portalGallery(ev: PortalInput, algorithms: Algorithm[]) { + const cards: PortalCard[] = []; + const seen = new Set(); + const unmapped: string[] = []; + for (const l of ev.layers) { + const a = algorithms.find((x) => x.id === l.algorithm); + const p = a?.products.find((x) => x.id === l.product); + const card = p?.portalData ? CARDS[p.portalData] : undefined; + if (p?.portalData && card) { + if (!seen.has(p.portalData)) cards.push(card); + seen.add(p.portalData); + } else { + unmapped.push(a && p ? `${a.title} ${p.label}` : `${l.algorithm} ${l.product}`); + } + } + return { cards, unmapped }; +} + +function Tags({ items }: { items: string[] }) { + if (items.length === 0) return null; + return ( +
+ {items.slice(0, MAX_TAGS).map((t) => ( + + {tagLabel(t)} + + ))} + {items.length > MAX_TAGS && + {items.length - MAX_TAGS} more ...} +
+ ); +} + +/** + * A Drive file as an , via Drive's thumbnail endpoint (works for "Anyone with the link" + * files). A file that is not shared fails to load — say so plainly instead of a broken icon. + */ +function DriveImage({ image, width = 1600 }: { image: PageImage; width?: number }) { + const id = driveFileId(image.url); + const [failed, setFailed] = useState(false); + if (!id || failed) { + return ( +
+ {id ? "Can't load this image from Google Drive — set its sharing to “Anyone with the link”." : "Not a Google Drive file link."} +
+ ); + } + return ( + {image.alt} setFailed(true)} + /> + ); +} + +const Arrow = () => ( + + → + +); + +interface Props { + event: PortalInput; + algorithms: Algorithm[]; + hazards: Hazard[]; + /** The page's datePublished; defaults to today, as the generator does. */ + published?: string; +} + +export default function PortalPage({ event: ev, algorithms, hazards, published }: Props) { + const page = ev.page; + const { cards, unmapped } = portalGallery(ev, algorithms); + const categories = [...new Set(ev.hazards.map((h) => hazards.find((v) => v.id === h)?.portal).filter(Boolean))] as string[]; + const region = page?.region?.trim() || ev.locations.join("; "); + const image = page?.image?.url.trim() ? page.image : undefined; + const paragraphs = (page?.intro ?? "") + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean); + + return ( +
+
+
+ + + DISASTERS +
+ PROGRAM +
+
+ +
+ +
+
+

{ev.name ? typographic(ev.name) : "Activation event name"}

+ {page?.subtitle?.trim() ? ( +

{typographic(page.subtitle.trim())}

+ ) : ( +

Your sub-title appears here, under the title.

+ )} +
PUBLISHED: {portalDate(published ?? today()).toUpperCase()}
+
+
+ +
+ + +
+ {page?.govLink?.trim() && ( +
+
What the U.S. government is doing
+ Learn more ↗ +
+ )} + +

Overview

+ {paragraphs.length > 0 ? ( + paragraphs.map((p, i) =>

{typographic(p)}

) + ) : ( +

Your intro paragraph appears here. Leave a blank line between paragraphs.

+ )} + {image && ( +
+ + {image.caption &&
{typographic(image.caption)}
} +
+ )} + + {cards.length + unmapped.length > 0 && ( + <> +
+

Event Data Gallery

+ + View all + +
+ {cards.map((c) => ( +
+ +
+

{c.title}

+

{c.description}

+ + + View Data + +
+
+ ))} + {/* Picked, but the portal has no Data Gallery card for it, so the published page will + not show it. Shown here as a draft card so nothing silently disappears. */} + {unmapped.map((title) => ( +
+ +
+

{title}

+

+ Not in the Disasters Portal Data Gallery yet, so the published page will not show + this layer until the portal adds a Data Gallery entry for it. +

+
+
+ ))} + + )} +
+
+
+ ); +} + +/** + * Renders `children` at the portal's desktop width and scales it down to fit this box, so the + * preview keeps the real site's proportions. A transform does not change layout size, so the + * outer height is set from the unscaled height × scale. + */ +export function PortalFrame({ children, width = 1280 }: { children: ReactNode; width?: number }) { + const outer = useRef(null); + const inner = useRef(null); + const [fit, setFit] = useState({ scale: 0.5, height: 0 }); + + useLayoutEffect(() => { + const measure = () => { + if (!outer.current || !inner.current) return; + const scale = outer.current.clientWidth / width; + setFit({ scale, height: inner.current.offsetHeight * scale }); + }; + measure(); + const ro = new ResizeObserver(measure); + if (outer.current) ro.observe(outer.current); + if (inner.current) ro.observe(inner.current); + return () => ro.disconnect(); + }, [width]); + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/algorithm-catalog/src/portal.css b/algorithm-catalog/src/portal.css new file mode 100644 index 00000000..98c7e1d9 --- /dev/null +++ b/algorithm-catalog/src/portal.css @@ -0,0 +1,126 @@ +/* ============================================================================================ + * The Disasters Portal's look, for src/events/PortalPage.tsx — NOT this app's chrome. + * + * Values are the portal's own, read off the live page (science.data.nasa.gov/disasters, its + * @teamimpact/veda-ui-blocks "disasters" theme): Inter for headings, Public Sans for body, its + * --color-* tokens, a 71px #17171b header, a 64px hero title, and a 240px sidebar beside a ~930px + * main column at 1280px. Everything is scoped under .pp, laid out at 1280px and scaled by + * . Fonts fall back to the platform UI font (this app loads no web fonts). + * ============================================================================================ */ + +.pp { + --pp-ink: #17171b; + --pp-base-dark: #58585b; + --pp-base: #77777a; + --pp-base-light: #b9b9bb; + --pp-base-lighter: #e3e3e3; + --pp-secondary: #f64137; + --pp-heading: "Inter Variable", Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + --pp-body: "Public Sans Web", "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: #fff; + color: var(--pp-ink); + font-family: var(--pp-body); + font-size: 16px; + line-height: 1.5; + text-align: left; +} + +.pp h1, .pp h2, .pp h3 { font-family: var(--pp-heading); letter-spacing: normal; color: inherit; } +.pp-frame { position: relative; overflow: hidden; } +.pp-scale { transform-origin: top left; position: absolute; top: 0; left: 0; } + +/* --- header ------------------------------------------------------------------------------- */ +.pp-nav { + height: 71px; background: var(--pp-ink); color: #fff; + display: flex; align-items: center; justify-content: space-between; padding: 0 32px 0 20px; +} +.pp-brand { display: flex; align-items: center; gap: 6px; font-size: 16px; line-height: 1.2; } +.pp-brand b { font-weight: 700; } +.pp-menu { display: flex; gap: 32px; font-weight: 700; font-size: 16px; } + +/* --- hero masthead ------------------------------------------------------------------------- */ +.pp-hero { + position: relative; min-height: 488px; background-color: #2e2e32; + background-size: cover; background-position: center; display: flex; align-items: center; +} +.pp-hero::before { + content: ""; position: absolute; inset: 0; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.45) 50%, rgba(0, 0, 0, 0.05) 100%); +} +.pp-hero-text { position: relative; max-width: 620px; padding: 36px 16px; color: #fff; } +.pp-hero h1 { font-size: 63.68px; font-weight: 700; line-height: 1; margin: 0 0 22px; } +.pp-hero p { font-family: var(--pp-heading); font-size: 17px; line-height: 1.6; color: #e3e3e3; margin: 0 0 22px; } +.pp-published { font-size: 12px; font-weight: 700; letter-spacing: 0.02em; } + +/* --- body: sidebar + main ----------------------------------------------------------------- */ +.pp-body { display: grid; grid-template-columns: 240px 1fr; gap: 64px; padding: 56px 16px 72px 32px; } +.pp-label { + font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--pp-base-dark); margin: 0 0 8px; +} +.pp-side > .pp-label { color: var(--pp-ink); font-size: 13px; letter-spacing: 0.06em; margin-bottom: 18px; } +.pp-toc { list-style: none; margin: 0 0 56px; padding: 0; border-left: 1px solid var(--pp-base-light); } +.pp-toc li { font-size: 13px; padding: 6px 16px; letter-spacing: 0.02em; } + +.pp-meta { margin: 0; padding: 0 16px; } +.pp-meta > div { border-bottom: 1px solid var(--pp-base-lighter); padding: 20px 0 18px; } +.pp-meta dt { + font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--pp-base-dark); margin-bottom: 8px; +} +.pp-meta dd { margin: 0; } +.pp-strong { font-family: var(--pp-heading); font-weight: 700; font-size: 16px; line-height: 1.2; } + +.pp-tags { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; } +.pp-tag { + border: 1px solid var(--pp-base); border-radius: 2px; padding: 2px 8px; + font-size: 12px; font-weight: 700; color: var(--pp-base-dark); letter-spacing: 0.02em; +} +.pp-more { font-size: 12px; color: var(--pp-base-dark); } + +.pp-main h2 { font-size: 35.84px; font-weight: 700; margin: 0 0 28px; line-height: 1.15; } +.pp-main > p { font-size: 16px; line-height: 1.62; margin: 0 0 22px; } +.pp-gov { + border-top: 1px solid var(--pp-base-lighter); border-bottom: 1px solid var(--pp-base-lighter); + padding: 26px 0 22px; margin-bottom: 30px; +} +.pp-learn { font-weight: 700; border-bottom: 1px dashed var(--pp-ink); } + +.pp-gallery-head { display: flex; justify-content: space-between; align-items: baseline; margin-top: 56px; } +.pp-viewall, .pp-viewdata { + font-family: var(--pp-heading); font-weight: 600; font-size: 16px; display: inline-flex; align-items: center; gap: 10px; +} +.pp-arrow { + width: 24px; height: 24px; border-radius: 50%; background: var(--pp-secondary); color: #fff; + display: inline-flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700; +} +.pp-card { display: grid; grid-template-columns: 200px 1fr; gap: 20px; margin-bottom: 32px; } +.pp-card img { width: 200px; height: 300px; object-fit: cover; display: block; background: var(--pp-base-lighter); } +.pp-card > div { display: flex; flex-direction: column; padding-top: 14px; } +.pp-card h3 { font-size: 21.92px; font-weight: 700; margin: 0 0 14px; } +.pp-card p { font-family: var(--pp-heading); font-size: 16px; line-height: 1.6; margin: 0 0 18px; } +.pp-card .pp-viewdata { margin-top: auto; padding-bottom: 20px; } + +/* What isn't written yet: shown faint, so the preview reads as a draft, not the published page. */ +.pp-placeholder { opacity: 0.45; font-style: italic; } +.pp-hero h1.pp-placeholder { font-style: normal; } + +/* --- page images (Google Drive) ------------------------------------------------------------- */ +/* Measured off the live Typhoon Sinlaku image block: full-width image, 12px caption, narrow measure. */ +.pp-figure { margin: 8px 0 32px; } +.pp-figure img { display: block; width: 100%; height: auto; } +.pp-figure figcaption { font-size: 12px; line-height: 1.4; color: var(--pp-base-dark); margin-top: 8px; max-width: 546px; } +.pp-img-missing { + border: 2px dashed var(--pp-base-light); background: #f6f6f6; color: var(--pp-base-dark); + padding: 28px 24px; font-size: 15px; text-align: center; +} + +/* A picked product with no Data Gallery card: a draft card, so it is visibly NOT on the real page. */ +.pp-card-ghost { opacity: 0.75; } +.pp-card-ghost-img { + width: 200px; height: 300px; border: 2px dashed var(--pp-base-light); background: #f6f6f6; + display: flex; align-items: center; justify-content: center; text-align: center; padding: 16px; + font-size: 14px; color: var(--pp-base-dark); +} +.pp-card-ghost h3 { color: var(--pp-base-dark); } +.pp-card-ghost p { font-style: italic; color: var(--pp-base-dark); } diff --git a/algorithm-catalog/src/portal.ts b/algorithm-catalog/src/portal.ts index 19ae4878..7eca9e88 100644 --- a/algorithm-catalog/src/portal.ts +++ b/algorithm-catalog/src/portal.ts @@ -14,11 +14,11 @@ // cards = existing DATA__* gallery cards, via data/algorithms.json `portalData` // format = Biome: 2-space, double quotes, trailing commas, 100 columns -import type { Algorithm, EventLayer, EventPage, Hazard, Need } from "./types"; +import type { Algorithm, EventLayer, EventPage, Hazard, Need, PageImage } from "./types"; export const PORTAL_REPO = "Disasters-Learning-Portal/disasters-portal"; export const PORTAL_BRANCH = "develop"; -/** Every new page starts on the portal's own placeholder hero; the web team swaps in a real one. */ +/** The portal's default event masthead — every page this generates uses it (hero + thumbnail). */ export const PORTAL_PLACEHOLDER_IMAGE = "/img/event/template-hero.webp"; /** First line of every generated file — how the workflow knows a page is safe to regenerate. */ export const PORTAL_MARKER = "// Generated by the VEDA Algorithm Catalog"; @@ -40,6 +40,14 @@ export interface PortalInput { page?: EventPage; } +/** A page image once scripts/portal_pr.mjs has downloaded it into the portal checkout. */ +export interface ResolvedImage extends PageImage { + /** Site path inside the portal, e.g. /img/event/__overview-1.jpg */ + src: string; + width: number; + height: number; +} + export interface PortalFile { id: string; constName: string; @@ -67,6 +75,7 @@ const MONTH_ABBR: Record = { export function derivePortalId(name: string): string { return name .toLowerCase() + .replace(/[.'’]/g, "") // "U.S." -> "us", not "u-s" (us-winter-storm-jan-2026) .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .split("-") @@ -85,18 +94,21 @@ export const portalDataModule = (constName: string): string => `@/app/site-config/data/data__${constName.replace(/^DATA__/, "").toLowerCase().replace(/_/g, "-")}`; /** - * A TS string literal in the portal's style. Straight double quotes in prose become curly ones - * (the portal already writes NASA’s, not NASA's) — otherwise Biome would flip the literal to single - * quotes and `biome ci` would fail the PR on formatting. + * Prose as the portal will print it: one line, straight double quotes made curly (the portal + * already writes NASA’s, not NASA's) — otherwise Biome would flip the literal to single quotes and + * `biome ci` would fail the PR on formatting. The live preview applies this too, so it shows + * exactly the text the generated file will carry. */ -function lit(text: string): string { - const typographic = text +export function typographic(text: string): string { + return text .replace(/\s*\n\s*/g, " ") .replace(/"([^"]*)"/g, "“$1”") .replace(/"/g, "”"); - return JSON.stringify(typographic); } +/** A TS string literal in the portal's style. */ +const lit = (text: string): string => JSON.stringify(typographic(text)); + /** `key: value,` at `indent`, broken onto two lines the way Biome does once it passes 100 columns. */ function prop(indent: string, key: string, value: string): string { const one = `${indent}${key}: ${value},`; @@ -110,11 +122,18 @@ function arr(indent: string, key: string, items: string[]): string { return [`${indent}${key}: [`, ...items.map((i) => `${indent} ${i},`), `${indent}],`].join("\n"); } +/** + * `resolved` = the page's Drive image after scripts/portal_pr.mjs downloaded it ([] if that failed). + * Omitted (the app's preview), the image is listed as a comment saying it will be downloaded; + * given, a downloaded image becomes an `image` block after the intro, and a failed one is listed + * for a human to add. + */ export function portalEventFile( ev: PortalInput, algorithms: Algorithm[], hazards: Hazard[], datePublished: string, + resolved?: ResolvedImage[], ): PortalFile { const id = portalIdOf(ev); const constName = portalConstName(id); @@ -140,7 +159,9 @@ export function portalEventFile( .split(/\n\s*\n/) .map((p) => p.trim()) .filter(Boolean); - const alt = lit(`Placeholder hero image for ${ev.name}`); + const heroSrc = PORTAL_PLACEHOLDER_IMAGE; + const alt = lit(`Masthead image for ${ev.name}`); + const pending = page?.image?.url.trim() && !resolved?.some((r) => r.url === page.image?.url) ? [page.image] : []; const imports = [ ...cards @@ -157,6 +178,18 @@ export function portalEventFile( arr(" ", "paragraphs", (paragraphs.length ? paragraphs : ["Overview to come."]).map(lit)), " },", ]; + for (const img of resolved ?? []) { + body.push( + " {", + ` type: "image",`, + prop(" ", "src", lit(img.src)), + prop(" ", "alt", lit(img.alt)), + ` width: ${img.width},`, + ` height: ${img.height},`, + ...(img.caption ? [prop(" ", "caption", lit(img.caption))] : []), + " },", + ); + } if (cards.length > 0) { body.push( " {", @@ -190,12 +223,12 @@ export function portalEventFile( prop(" ", "datePublished", lit(datePublished)), "", " thumbnailImage: {", - ` src: "${PORTAL_PLACEHOLDER_IMAGE}",`, + ` src: "${heroSrc}",`, prop(" ", "alt", alt), " },", "", " mastheadImage: {", - ` src: "${PORTAL_PLACEHOLDER_IMAGE}",`, + ` src: "${heroSrc}",`, prop(" ", "alt", alt), " },", "", @@ -214,6 +247,14 @@ export function portalEventFile( if (unmapped.length > 0) { lines.push(" // No portal data-gallery card yet for:", ...unmapped.map((u) => ` // - ${u}`)); } + if (pending.length > 0) { + lines.push( + resolved + ? " // Could not download this Google Drive image — check its sharing, then add it by hand:" + : " // Google Drive image — downloaded into public/img/event/ when the portal PR is opened:", + ...pending.map((i) => ` // - ${i.url}`), + ); + } lines.push(" body: [", ...body, " ],", "};", ""); return { id, constName, path: portalPath(id), source: lines.join("\n"), unmapped }; diff --git a/algorithm-catalog/src/request.css b/algorithm-catalog/src/request.css index 977f8018..cd99c48c 100644 --- a/algorithm-catalog/src/request.css +++ b/algorithm-catalog/src/request.css @@ -226,8 +226,8 @@ input[aria-invalid="true"] { } .req-prod-group { - border-left: 3px solid var(--base-lighter); - padding-left: 0.7rem; + border-left: 4px solid var(--base-lighter); + padding-left: 0.8rem; } /* Header row: algorithm name, count, then "Remove all" pushed to the right. Without the flex @@ -239,10 +239,19 @@ input[aria-invalid="true"] { margin-bottom: 0.4rem; } +/* The sensor heading must read as a heading, not as one more chip: display face, bigger, heavier, + ink — while the product chips under it step down (see .req-chip-prod). */ +.req-prod-group-icon { + font-size: 1rem; + line-height: 1; +} + .req-prod-group-name { - font-weight: 600; - font-size: 0.9rem; - color: var(--base-dark); + font-family: var(--font-display); + font-weight: 700; + font-size: 1.02rem; + color: var(--ink, #1b1b1b); + letter-spacing: -0.01em; } .req-prod-group-n { @@ -295,8 +304,9 @@ input[aria-invalid="true"] { } .req-chip-prod { - color: var(--ink, #1b1b1b); - font-weight: 600; + color: var(--base-dark, #3d4551); + font-weight: 500; + font-size: 0.8rem; } .req-badge { @@ -362,6 +372,18 @@ input[aria-invalid="true"] { box-shadow: var(--shadow-lg, 0 12px 40px rgba(27, 27, 27, 0.18)); } +/* The product picker (ProductPicker.tsx) is two columns: sensors on the left (a vertical tablist), + the picked sensor's products on the right. `.req-picker` stays the single-column base that + PlacePicker also uses. A FIXED height, not max-height, so the box doesn't jump between a + 10-product sensor and a 1-product one; each column scrolls on its own. The width is capped by + the viewport minus the anchor's own left offset (--pick-left, measured in ProductPicker), so + it never runs off the right edge of the page. */ +.req-picker.req-prodpick { + width: min(760px, calc(100vw - var(--pick-left, 0px) - 16px)); + height: min(540px, 70vh); + max-height: none; +} + .req-picker-head { display: flex; align-items: center; @@ -392,6 +414,123 @@ input[aria-invalid="true"] { padding: 0.4rem; } +.req-picker-cols { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(210px, 35%) minmax(0, 1fr); +} + +/* --- left column: sensors --- */ + +.req-picker-sensors { + overflow-y: auto; + padding: 0.35rem; + border-right: 1px solid var(--base-lighter, #dfe1e2); + background: var(--base-lightest, #f0f0f0); +} + +.req-picker-sensor { + display: grid; + grid-template-columns: 1.3rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.35rem; + width: 100%; + margin-bottom: 2px; + padding: 0.4rem 0.45rem 0.4rem 0.4rem; + border: 0; + border-left: 3px solid var(--acc, var(--base-light, #a9aeb1)); + border-radius: 5px; + background: transparent; + color: var(--ink, #1b1b1b); + font: inherit; + font-size: 0.82rem; + text-align: left; + cursor: pointer; +} + +.req-picker-sensor:hover { + background: var(--white, #ffffff); +} + +.req-picker-sensor.is-active { + background: var(--white, #ffffff); + box-shadow: 0 1px 2px rgba(27, 27, 27, 0.14); + font-weight: 600; +} + +.req-picker-sensor.is-all { + border-left-color: var(--primary, #005ea2); + margin-bottom: 0.35rem; +} + +.req-picker-sicon { + font-size: 0.95rem; + line-height: 1; + text-align: center; +} + +.req-picker-stitle { + min-width: 0; + line-height: 1.3; + overflow-wrap: break-word; +} + +.req-picker-count { + font-size: 0.7rem; + font-weight: 500; + color: var(--base, #565c65); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.req-picker-count.has-sel { + color: var(--primary, #005ea2); + font-weight: 700; +} + +.req-picker-count.is-match { + min-width: 1.4rem; + padding: 0.05rem 0.4rem; + border-radius: 999px; + text-align: center; + color: var(--primary-dark, #1a4480); + background: var(--primary-lighter, #d9e8f6); + font-weight: 700; +} + +/* Any status other than "production" (prototype, external, …). The text comes from + ProductPicker's STATUS_LABEL map, so a new status needs no CSS to show up. */ +.req-picker-status { + display: inline-block; + margin-left: 0.35rem; + padding: 0 0.35rem; + border: 1px solid var(--base-light, #a9aeb1); + border-radius: 999px; + font-size: 0.64rem; + font-weight: 600; + line-height: 1.5; + color: var(--base-dark, #3d4551); + background: var(--white, #ffffff); + white-space: nowrap; + vertical-align: 0.08em; +} + +/* --- right column: products --- */ + +.req-picker-products { + overflow-y: auto; + min-width: 0; + padding: 0 0.4rem 0.4rem; +} + +/* The panel only takes focus as a fallback (every product already added); the rows carry the + visible ring. */ +.req-picker-products:focus { + outline: 0; + box-shadow: none; +} + .req-picker-group { margin-bottom: 0.5rem; } @@ -399,13 +538,21 @@ input[aria-invalid="true"] { .req-picker-group-head { display: flex; align-items: baseline; - gap: 0.35rem; + flex-wrap: wrap; + gap: 0.15rem 0.4rem; font-size: 0.85rem; - padding: 0.25rem 0.3rem; + margin: 0 -0.4rem 0.2rem; + padding: 0.45rem 0.7rem 0.35rem; position: sticky; top: 0; + z-index: 1; background: var(--white, #ffffff); border-bottom: 1px dashed var(--base-lighter, #dfe1e2); + box-shadow: inset 3px 0 0 var(--acc, transparent); +} + +.req-picker-group-head .req-picker-status { + margin-left: 0; } .req-picker-meta { @@ -413,6 +560,10 @@ input[aria-invalid="true"] { color: var(--base-light, #a9aeb1); } +.req-picker-group-head .req-picker-meta { + color: var(--base, #565c65); +} + .req-picker-row { display: flex; align-items: baseline; @@ -433,10 +584,25 @@ input[aria-invalid="true"] { } .req-picker-row.is-added { - cursor: default; color: var(--base-light, #a9aeb1); } +/* An added row is a toggle: its ✓ turns into a red − on hover/focus, i.e. "click to remove". */ +.req-picker-off { + display: none; + color: var(--error, #d54309); +} + +.req-picker-row.is-added:hover .req-picker-on, +.req-picker-row.is-added:focus-visible .req-picker-on { + display: none; +} + +.req-picker-row.is-added:hover .req-picker-off, +.req-picker-row.is-added:focus-visible .req-picker-off { + display: inline; +} + .req-picker-plus { flex: 0 0 1rem; color: var(--primary, #005ea2); @@ -461,11 +627,11 @@ input[aria-invalid="true"] { .req-picker-haz { margin-left: auto; font-size: 0.68rem; - color: var(--base-light, #a9aeb1); + color: var(--base-mid, #71767a); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 40%; + max-width: 45%; } .req-picker-foot { @@ -483,6 +649,56 @@ input[aria-invalid="true"] { color: var(--base-light, #a9aeb1); } +.req-picker-none { + flex: 1; + margin: 0; + padding: 0.8rem; +} + +/* Narrow: stack. The sensors become one horizontal, scrollable strip above the products. */ +@media (max-width: 560px) { + .req-picker.req-prodpick { + height: min(560px, 75vh); + } + + .req-picker-cols { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + } + + .req-picker-sensors { + display: flex; + gap: 0.3rem; + overflow-x: auto; + overflow-y: hidden; + border-right: 0; + border-bottom: 1px solid var(--base-lighter, #dfe1e2); + } + + .req-picker-sensor, + .req-picker-sensor.is-all { + flex: 0 0 auto; + width: auto; + margin: 0; + } + + .req-picker-stitle { + white-space: nowrap; + } + + /* No room beside the label, so the hazards drop to their own line under it. */ + .req-prodpick .req-picker-row { + flex-wrap: wrap; + } + + .req-prodpick .req-picker-haz { + flex-basis: 100%; + max-width: none; + margin-left: 1.4rem; + white-space: normal; + } +} + /* --- "What this will run" -------------------------------------------------------------------- */ .req-whatruns { @@ -740,3 +956,99 @@ input[aria-invalid="true"] { background: var(--primary-lighter, #d9e8f6); color: var(--primary-dark, #061f4a); } + +/* --- live Disasters Portal preview, docked beside the form --------------------------------- + The form keeps its reading width; the preview takes the rest and stays in view (sticky) while + you type. Under 1100px there is no room for both, so it drops below the form. */ + +/* The app caps every tab at 1280px (.hub-main); with the preview docked, Submit needs the room. */ +.hub-main:has(.req-layout.is-live) { + max-width: 1920px; +} + +.req-layout.is-live { + display: grid; + grid-template-columns: minmax(0, 760px) minmax(0, 1fr); + gap: 1.25rem; + align-items: start; + max-width: 1880px; + margin: 0 auto; +} + +.req-layout.is-live .req-form { + margin: 0; + max-width: none; +} + +.req-live { + position: sticky; + top: 1rem; + max-height: calc(100vh - 56px - 2rem); /* 56px = the app header */ + overflow: auto; + background: var(--white, #ffffff); + border: 1px solid var(--base-lighter, #dfe1e2); + border-radius: var(--radius, 8px); + box-shadow: var(--shadow, 0 1px 2px rgba(27, 27, 27, 0.08)); +} + +.req-live-head { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.75rem; + background: var(--white, #ffffff); + border-bottom: 1px solid var(--base-lighter, #dfe1e2); + font-size: 0.85rem; +} + +.req-live-head code { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--base, #565c65); + font-size: 0.75rem; +} + +.req-live-toggle { + float: right; + font-size: 0.8rem; + padding: 0.2rem 0.6rem; +} + +@media (max-width: 1100px) { + .req-layout.is-live { + grid-template-columns: minmax(0, 1fr); + } + .req-live { + position: static; + max-height: none; + } +} + +/* --- the page's one image: alt + caption appear once a link is pasted ------------------------ */ + +.req-img-extra { + margin-top: 0.4rem; +} + +/* The derived (read-only) portal page address. */ +.req-pageaddr { + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + padding: 0.5rem 0.6rem; + border: 1px dashed var(--base-lighter, #dfe1e2); + border-radius: 4px; + background: var(--base-lightest, #f0f0f0); + overflow-wrap: anywhere; +} + +/* A product no live STAC collection carries yet: its own token, visibly not a STAC asset name. */ +.req-picker-token.is-nostac { + opacity: 0.55; + border: 1px dashed var(--base-light, #a9aeb1); +} diff --git a/algorithm-catalog/src/request/ProductPicker.tsx b/algorithm-catalog/src/request/ProductPicker.tsx index 2136f138..24c15d8a 100644 --- a/algorithm-catalog/src/request/ProductPicker.tsx +++ b/algorithm-catalog/src/request/ProductPicker.tsx @@ -2,33 +2,85 @@ // // The hazard -> product mapping in data/algorithms.json is hand-built and will never be complete: // a responder who knows they want VIIRS night lights on a landslide must be able to say so. This -// picker lists EVERY algorithm × product in the catalog, grouped and searchable, and anything -// added here is tagged via:"manual" so the request stays traceable. +// picker lists EVERY algorithm × product in the catalog, and anything added here is tagged +// via:"manual" so the request stays traceable. +// +// Two columns, because one long list stops being navigable once the catalog grows past a handful +// of sensors: LEFT is every sensor in catalog order (a vertical tablist), RIGHT is the products of +// the one you picked (its tabpanel). Search filters both; while searching, an "All matches" entry +// at the top of the left column lists every hit grouped by sensor. +// +// Keyboard: ↓ from the search box enters the sensor list; ↑/↓/Home/End move through sensors +// (selection follows focus); → or Enter jumps into that sensor's products; ↑/↓ move among +// products; ← goes back to the sensor; Esc closes. -import { useMemo, useState } from "react"; -import type { Algorithm } from "../types"; +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent } from "react"; +import type { Algorithm, Product } from "../types"; +import { modality } from "../colors"; import { productKey } from "./drafts"; interface Props { algorithms: Algorithm[]; - /** productKey()s already on the request — shown as "added" and not clickable. */ + /** productKey()s already on the request — shown ✓; clicking one again removes it. */ selected: Set; onAdd: (algorithm: string, product: string) => void; + onRemove: (algorithm: string, product: string) => void; onClose: () => void; } -const MODALITY_ICON: Record = { - optical: "🛰️", - sar: "📡", - nightlights: "🌃", - utility: "🧰", +/** + * Badge text for any status other than "production". Keyed by plain string (not the + * AlgorithmStatus union) so a status added to the data before it reaches this map still renders — + * as its raw value — instead of failing the typecheck. + */ +const STATUS_LABEL: Record = { + prototype: "prototype", + external: "no algorithm yet", }; -export default function ProductPicker({ algorithms, selected, onAdd, onClose }: Props) { +function statusLabel(status: string): string | null { + return status === "production" ? null : (STATUS_LABEL[status] ?? status); +} + +/** The left-column id of the "All matches" entry — not a valid algorithm id. */ +const ALL = "__all__"; + +interface Group { + alg: Algorithm; + products: Product[]; +} + +export default function ProductPicker({ algorithms, selected, onAdd, onRemove, onClose }: Props) { const [q, setQ] = useState(""); + const [picked, setPicked] = useState(algorithms[0]?.id ?? ALL); + const uid = useId(); + const listRef = useRef(null); + const panelRef = useRef(null); + const rootRef = useRef(null); + // The popover hangs off the "+ Add products" button at left:0, so its usable width is the + // viewport minus that anchor's x. Measured, because CSS alone can't know it (see .req-prodpick). + const [left, setLeft] = useState(0); - const groups = useMemo(() => { - const needle = q.trim().toLowerCase(); + useLayoutEffect(() => { + const measure = () => + setLeft(Math.max(0, rootRef.current?.parentElement?.getBoundingClientRect().left ?? 0)); + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, []); + + // Esc closes from anywhere while open — on the document, not the popover, because focus can + // legitimately be outside it (e.g. on after a click). + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + const needle = q.trim().toLowerCase(); + + const groups: Group[] = useMemo(() => { return algorithms .map((a) => { const algMatches = @@ -48,18 +100,140 @@ export default function ProductPicker({ algorithms, selected, onAdd, onClose }: return { alg: a, products }; }) .filter((g) => g.products.length > 0); - }, [algorithms, q]); + }, [algorithms, needle]); const total = groups.reduce((n, g) => n + g.products.length, 0); + // The "All" entry only earns its place while searching — with no query it would just be the old + // single long list again. + const ids = useMemo( + () => [...(needle ? [ALL] : []), ...groups.map((g) => g.alg.id)], + [needle, groups], + ); + + // What is actually shown: the picked sensor if it is still visible, else the first match. Derived + // rather than stored, so typing never leaves the right column pointing at a filtered-out sensor. + const active = ids.includes(picked) ? picked : (groups[0]?.alg.id ?? ALL); + const activeGroup = groups.find((g) => g.alg.id === active); + + useEffect(() => { + panelRef.current?.scrollTo?.({ top: 0 }); + }, [active, needle]); + + const tabId = (id: string) => `${uid}-tab-${id}`; + const panelId = `${uid}-panel`; + + const focusTab = (id: string) => + listRef.current?.querySelector(`[data-sensor="${CSS.escape(id)}"]`)?.focus(); + + const enabledRows = () => [ + ...(panelRef.current?.querySelectorAll(".req-picker-row:not(:disabled)") ?? []), + ]; + + const focusProducts = () => (enabledRows()[0] ?? panelRef.current)?.focus(); + + const onListKey = (e: KeyboardEvent) => { + const i = ids.indexOf(active); + let next: string | undefined; + if (e.key === "ArrowDown") next = ids[Math.min(i + 1, ids.length - 1)]; + else if (e.key === "ArrowUp") next = ids[Math.max(i - 1, 0)]; + else if (e.key === "Home") next = ids[0]; + else if (e.key === "End") next = ids[ids.length - 1]; + else if (e.key === "ArrowRight" || e.key === "Enter") { + e.preventDefault(); + focusProducts(); + return; + } + if (next === undefined) return; + e.preventDefault(); + setPicked(next); + focusTab(next); + }; + + const onPanelKey = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft") { + e.preventDefault(); + focusTab(active); + return; + } + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + const rows = enabledRows(); + if (rows.length === 0) return; + e.preventDefault(); + const i = rows.indexOf(document.activeElement as HTMLElement); + const j = e.key === "ArrowDown" ? Math.min(i + 1, rows.length - 1) : Math.max(i - 1, 0); + rows[j].focus(); + }; + + const row = (alg: Algorithm, p: Product) => { + const key = productKey({ algorithm: alg.id, product: p.id }); + const already = selected.has(key); + return ( + + ); + }; + + const badge = (alg: Algorithm) => { + const label = statusLabel(alg.status); + return label ? {label} : null; + }; + return ( -
e.stopPropagation()}> +
e.stopPropagation()} + >
setQ(e.target.value)} + onKeyDown={(e) => { + if (e.key === "ArrowDown" && ids.length > 0) { + e.preventDefault(); + focusTab(active); + } + }} placeholder="Search sensors, products, hazards…" aria-label="Search products" /> @@ -68,45 +242,127 @@ export default function ProductPicker({ algorithms, selected, onAdd, onClose }:
-
- {total === 0 &&

No products match “{q}”.

} - {groups.map((g) => ( -
-
- {MODALITY_ICON[g.alg.modality] ?? "🛰️"} - {g.alg.title} - - {g.alg.modality} - {g.alg.status === "prototype" ? " · prototype" : ""} - -
- {g.products.map((p) => { - const key = productKey({ algorithm: g.alg.id, product: p.id }); - const already = selected.has(key); + {total === 0 ? ( +

No products match “{q}”.

+ ) : ( +
+
+ {needle && ( + + )} + {groups.map(({ alg, products }) => { + const m = modality(alg.modality); + const isActive = active === alg.id; + const nSel = alg.products.filter((p) => + selected.has(productKey({ algorithm: alg.id, product: p.id })), + ).length; return ( ); })}
- ))} -
+ +
+ {activeGroup ? ( + <> +
+ {activeGroup.alg.title} + {badge(activeGroup.alg)} + + {modality(activeGroup.alg.modality).label} + {activeGroup.alg.resolution ? ` · ${activeGroup.alg.resolution}` : ""} + {needle + ? ` · ${activeGroup.products.length} of ${activeGroup.alg.products.length} match` + : ` · ${activeGroup.alg.products.length} products`} + +
+ {activeGroup.products.map((p) => row(activeGroup.alg, p))} + + ) : ( + groups.map((g) => ( +
+
+ + {g.alg.title} + {badge(g.alg)} + {g.products.length} +
+ {g.products.map((p) => row(g.alg, p))} +
+ )) + )} +
+
+ )}

Added here counts as manual — it stays on the request even if you change hazards. + Click a ✓ to remove it.

); diff --git a/algorithm-catalog/src/request/RequestForm.tsx b/algorithm-catalog/src/request/RequestForm.tsx index 1b600802..062b8728 100644 --- a/algorithm-catalog/src/request/RequestForm.tsx +++ b/algorithm-catalog/src/request/RequestForm.tsx @@ -15,7 +15,7 @@ // noise, not help. "Show all problems" reveals everything at once, which is the answer to "why is // the submit button greyed out?" (a disabled button can't tell you itself — it emits no click). -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import "../request.css"; import type { Algorithm, AlgorithmInput, Hazard, RequestProduct } from "../types"; import type { Violation } from "../rules"; @@ -34,10 +34,16 @@ import { import ProductPicker from "./ProductPicker"; import PlacePicker from "./PlacePicker"; import { unionBbox } from "../places"; +import { modality as modalityStyle } from "../colors"; import { takeNavIntent } from "../tabs"; import type { CatalogEvent } from "../data"; -import EventPage from "../events/EventPage"; -import { derivePortalId, NEED_LABELS, PORTAL_REPO, portalEventFile } from "../portal"; +import PortalPage, { PortalFrame } from "../events/PortalPage"; +import { NEED_LABELS, PORTAL_REPO, portalEventFile, portalIdOf } from "../portal"; + +/** Whether the live portal preview is docked beside the form — remembered per browser. */ +const LIVE_KEY = "algorithm-catalog:livePreview"; +/** Below this the form and preview can't both be readable side by side, so it starts closed. */ +const LIVE_MIN_WIDTH = 1200; /** Remembers the requester handle across visits — see the note where the draft is initialised. */ const REQUESTER_KEY = "algorithm-catalog:requester"; @@ -148,6 +154,22 @@ function WhatWillRun({ {rows.length === 0 &&

Pick some products first.

} {rows.map((a) => { const tokens = byAlg.get(a.id) ?? []; + if (a.status === "external") { + const labels = a.products.filter((p) => tokens.includes(p.id)).map((p) => p.label); + return ( +
+
+ {a.title} + no algorithm yet +
+

+ {labels.join(", ")}: produced outside this catalog and published on the Disasters + Portal. No DPS job is created; the request records that the event needs this layer, + and the event page links its data-gallery card. +

+
+ ); + } return (
@@ -222,6 +244,25 @@ export default function RequestForm({ algorithms, hazards, events }: Props) { // Set when the form was opened from an event's "Edit page" — names what is being edited. const [editing, setEditing] = useState(null); const [bboxNote, setBboxNote] = useState(""); + const [live, setLive] = useState(() => { + try { + const saved = localStorage.getItem(LIVE_KEY); + if (saved !== null) return saved === "1"; + } catch { + /* ignore */ + } + return window.innerWidth >= LIVE_MIN_WIDTH; + }); + function toggleLive() { + setLive((v) => { + try { + localStorage.setItem(LIVE_KEY, v ? "0" : "1"); + } catch { + /* ignore */ + } + return !v; + }); + } // Events → "Edit page" parks {tab:"submit", event} and switches here (src/tabs.ts navigate). useEffect(() => { @@ -239,13 +280,14 @@ export default function RequestForm({ algorithms, hazards, events }: Props) { // Labels for the product chips. const productLabel = useMemo(() => { - const m = new Map(); + const m = new Map(); for (const a of algorithms) { for (const p of a.products) { m.set(productKey({ algorithm: a.id, product: p.id }), { alg: a.title, product: p.label, modality: a.modality, + external: a.status === "external", }); } } @@ -353,6 +395,25 @@ export default function RequestForm({ algorithms, hazards, events }: Props) { })); } + // The selected products are listed ABOVE the "Add products" button, so every add or remove made + // from the open picker grows/shrinks the page above it and shoves the picker off screen. Record + // where the picker sits just before the change and scroll by exactly the difference after it, so + // it stays put under the cursor. Only these picker edits are compensated — a scroll the user + // makes themselves is never undone. + const addWrapRef = useRef(null); + const pickerTopBefore = useRef(null); + const holdPicker = () => { + pickerTopBefore.current = addWrapRef.current?.getBoundingClientRect().top ?? null; + }; + useLayoutEffect(() => { + const before = pickerTopBefore.current; + pickerTopBefore.current = null; + const el = addWrapRef.current; + if (before === null || !el) return; + const shift = el.getBoundingClientRect().top - before; + if (shift !== 0) window.scrollBy(0, shift); + }, [draft.products]); + function addProduct(algorithm: string, product: string) { touch("products"); const key = productKey({ algorithm, product }); @@ -389,6 +450,7 @@ export default function RequestForm({ algorithms, hazards, events }: Props) { const manualCount = draft.products.length - autoCount; return ( +
pickerOpen && setPickerOpen(false)}>

📝 Request an activation

@@ -519,6 +581,9 @@ export default function RequestForm({ algorithms, hazards, events }: Props) {

2 · Page content (optional — the event's page on the Disasters Portal) +

The text of the public page on science.data.nasa.gov. Title, hazards, locations and start @@ -574,6 +639,48 @@ export default function RequestForm({ algorithms, hazards, events }: Props) {
+
+ + patch({ imageUrl: e.target.value })} + onBlur={() => touch("image")} + placeholder="https://drive.google.com/file/d/…/view?usp=sharing" + /> +
+ One image shown right after the intro, like the Venezuela earthquake page. Paste the Drive{" "} + file link (Share → Copy link) with sharing set to Anyone with the link; it is + copied into the portal when the PR opens. +
+ {draft.imageUrl.trim() && ( + <> + patch({ imageAlt: e.target.value })} + onBlur={() => touch("image")} + placeholder="Alt text — what the image shows (required)" + aria-label="Image alt text" + /> +