diff --git a/package.json b/package.json index 217ced4f9..565e69fd9 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "embla-carousel-react": "^8.6.0", "fflate": "^0.8.2", "gsap": "^3.13.0", - "icechunk-js": "^0.4.0", + "icechunk-js": "^0.6.0", "input-otp": "^1.4.2", "js-colormaps-es": "^0.0.5", "lucide-react": "^0.562.0", diff --git a/src/GlobalStates/PlotStore.ts b/src/GlobalStates/PlotStore.ts index 5f0e34f71..39219a628 100644 --- a/src/GlobalStates/PlotStore.ts +++ b/src/GlobalStates/PlotStore.ts @@ -64,6 +64,7 @@ type PlotState ={ maskValue: number; cameraPosition: THREE.Vector3; disablePointScale: boolean; + camera: THREE.Camera | undefined; setQuality: (quality: number) => void; setTimeScale: (timeScale : number) =>void; @@ -119,8 +120,10 @@ type PlotState ={ setUseOrtho: (useOrtho: boolean) => void; setFillValue: (fillValue: number | undefined) => void; setCameraPosition: (cameraPosition: THREE.Vector3) => void; + } + export const usePlotStore = create((set, get) => ({ plotType: "volume", pointSize: 5, @@ -185,6 +188,7 @@ export const usePlotStore = create((set, get) => ({ borderWidth: 0.05, cameraPosition: new THREE.Vector3(0, 0, 5), disablePointScale: false, + camera: undefined, setVTransferRange: (vTransferRange) => set({ vTransferRange }), setVTransferScale: (vTransferScale) => set({ vTransferScale }), @@ -242,4 +246,5 @@ export const usePlotStore = create((set, get) => ({ setUseOrtho: (useOrtho) => set({ useOrtho }), setFillValue: (fillValue) => set({ fillValue }), setCameraPosition: (cameraPosition) => set({ cameraPosition }), + })) \ No newline at end of file diff --git a/src/GlobalStates/ZarrStore.ts b/src/GlobalStates/ZarrStore.ts index 00755ee3f..423796dfa 100644 --- a/src/GlobalStates/ZarrStore.ts +++ b/src/GlobalStates/ZarrStore.ts @@ -23,6 +23,7 @@ type ZarrState = { fetchOptions: FetchStoreOptions | null; abortController: AbortController | null; fetchKey: number; + blobKey: string | undefined; // The key for the stored File blob for a local NC setZSlice: (zSlice: [number , number | null]) => void; setYSlice: (ySlice: [number , number | null]) => void; @@ -63,6 +64,7 @@ export const useZarrStore = create((set, get) => ({ fetchOptions: null, abortController: null, fetchKey: 0, + blobKey: undefined, setZSlice: (zSlice) => set({ zSlice }), setYSlice: (ySlice) => set({ ySlice }), diff --git a/src/app/globals.css b/src/app/globals.css index 1c4604b88..dbc20fe07 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -455,4 +455,12 @@ a[href]:hover::after { .glow-effect { animation: glow-pulse 2s ease-in-out infinite; border-radius: 0.275rem; +} + +input[type=number].no-spinner { + -moz-appearance: textfield; +} + +input[type=number].no-spinner::-moz-number-spin-box { + display: none; } \ No newline at end of file diff --git a/src/assets/catalogs/IcechunkCatalog.ts b/src/assets/catalogs/IcechunkCatalog.ts new file mode 100644 index 000000000..5ba38e62a --- /dev/null +++ b/src/assets/catalogs/IcechunkCatalog.ts @@ -0,0 +1,128 @@ +export const ICECHUNK_CATALOG = [ + { + key: 'chirps-daily', + label: 'CHIRPS Daily Precipitation', + subtitle: '', + store: 'https://data.source.coop/e4drr-project/observations/chirps_daily_icechunk', + }, + { + key: 'earthmover-era5-surface', + label: 'Earthmover ERA5 Surface Reanalysis', + subtitle: 'Multi-Variable', + store: 'https://earthmover-icechunk-era5.s3.us-east-1.amazonaws.com/era5_surface_aws', + }, + { + key: 'noaa-gfs-forecast', + label: 'NOAA GFS', + subtitle: 'Global Weather Forecast', + store: 'https://dynamical-noaa-gfs.s3.us-west-2.amazonaws.com/noaa-gfs-forecast/v0.2.7.icechunk/', + }, + { + key: 'dwd-icon-eu-forecast', + label: 'DWD ICON-EU', + subtitle: 'Numerical Weather Forecast', + store: 'https://dynamical-dwd-icon-eu.s3.us-west-2.amazonaws.com/dwd-icon-eu-forecast-5-day/v0.2.0.icechunk/', + }, + { + key: 'ecmwf-aifs-single-forecast', + label: 'ECMWF AIFS Single Forecast', + subtitle: 'Artificial Intelligence Forecasting System', + store: 'https://dynamical-ecmwf-aifs-single.s3.us-west-2.amazonaws.com/ecmwf-aifs-single-forecast/v0.1.0.icechunk/', + }, + { + key: 'metoffice-global-wave', + label: 'UK Met Office Global Wave Forecast', + subtitle: '', + store: 'https://data.source.coop/bkr/metoffice/metoffice_global_wave.icechunk', + }, + { + key: 'metoffice-global-deterministic-6h', + label: 'UK Met Office Global Deterministic Atmosphere Forecast', + subtitle: '6-hourly 24hr', + store: 'https://data.source.coop/bkr/metoffice/metoffice_global_deterministic_10km_6hourly_24hr.icechunk', + }, + { + key: 'nasa-geos-15min', + label: 'NASA GEOS Atmospheric Reanalysis', + subtitle: '15-minute', + store: 'https://data.source.coop/bkr/geos/geos_15min.icechunk', + }, + { + key: 'metoffice-global-deterministic-11h', + label: 'UK Met Office Global Deterministic Atmosphere Forecast', + subtitle: '11-hour', + store: 'https://data.source.coop/bkr/metoffice/metoffice_global_deterministic_10km_11hour.icechunk', + }, + { + key: 'metoffice-global-deterministic', + label: 'UK Met Office Global Deterministic Atmosphere Forecast', + subtitle: '', + store: 'https://data.source.coop/bkr/metoffice/metoffice_global_deterministic_10km.icechunk', + }, + { + key: 'noaa-hrrr-analysis', + label: 'NOAA HRRR', + subtitle: 'High-Resolution Rapid Refresh Analysis', + store: 'https://dynamical-noaa-hrrr.s3.us-west-2.amazonaws.com/noaa-hrrr-analysis/v0.2.0.icechunk/', + }, + { + key: 'noaa-mrms-conus-hourly', + label: 'NOAA MRMS CONUS', + subtitle: 'Hourly Radar-Based Precipitation Analysis', + store: 'https://dynamical-noaa-mrms.s3.amazonaws.com/noaa-mrms-conus-analysis-hourly/v0.3.0.icechunk/', + }, + { + key: 'ecmwf-aifs-ens-forecast', + label: 'ECMWF AIFS Ensemble Forecast', + subtitle: '', + store: 'https://dynamical-ecmwf-aifs-ens.s3.us-west-2.amazonaws.com/ecmwf-aifs-ens-forecast/v0.1.0.icechunk/', + }, + { + key: 'ecmwf-ifs-ens-15day', + label: 'ECMWF IFS Ensemble 15-day Weather Forecast', + subtitle: '0.25°', + store: 'https://dynamical-ecmwf-ifs-ens.s3.us-west-2.amazonaws.com/ecmwf-ifs-ens-forecast-15-day-0-25-degree/v0.1.0.icechunk/', + }, + { + key: 'noaa-gefs-35day-forecast', + label: 'NOAA GEFS 35-day Ensemble Forecast', + subtitle: '', + store: 'https://dynamical-noaa-gefs.s3.us-west-2.amazonaws.com/noaa-gefs-forecast-35-day/v0.2.0.icechunk/', + }, + { + key: 'silam-aerosol', + label: 'SILAM Global Dust Model Forecasts', + subtitle: 'Aerosol', + store: 'https://data.source.coop/bkr/silam-dust/silam_aerosol.icechunk', + }, + { + key: 'cams-aerosol-analysis', + label: 'CAMS Aerosol Analysis', + subtitle: 'Copernicus CAMS Operational Archive', + store: 'https://data.source.coop/bkr/cams/cams.icechunk', + }, + { + key: 'cams-aerosol-forecast', + label: 'CAMS Aerosol Forecast', + subtitle: 'Copernicus CAMS Operational Archive', + store: 'https://data.source.coop/bkr/cams/cams_analysis_and_forecast.icechunk', + }, + { + key: 'nasa-rasi', + label: 'NASA RASI', + subtitle: 'Risk Analysis and Solutions Innovators', + store: 'https://nasa-waterinsight.s3.us-west-2.amazonaws.com/virtual-zarr-store/icechunk/RASI/HISTORICAL/', + }, + { + key: 'alaska-hrrr', + label: 'Alaska HRRR', + subtitle: '', + store: 'https://data.source.coop/bkr/dmi/alaska_hrrr.icechunk', + }, + { + key: 'ndvi-test', + label: 'NDVI', + subtitle: 'Normalized Difference Vegetation Index', + store: 'https://data.source.coop/eeholmes/chlaz/icechunk', + }, +]; \ No newline at end of file diff --git a/src/assets/catalogs/ZarrCatalog.ts b/src/assets/catalogs/ZarrCatalog.ts new file mode 100644 index 000000000..350d82d72 --- /dev/null +++ b/src/assets/catalogs/ZarrCatalog.ts @@ -0,0 +1,92 @@ +export const ZARR_CATALOG = [ + { + key: 'seasfire', + label: 'SeasFire Cube', + subtitle: 'A Global Dataset for Seasonal Fire Modeling in the Earth System', + store: 'https://s3.bgc-jena.mpg.de:9000/misc/seasfire_rechunked.zarr', + }, + { + key: 'ESDC', + label: 'ESDC', + subtitle: 'Earth System Data Cube v3.0.2', + store: 'https://s3.bgc-jena.mpg.de:9000/esdl-esdc-v3.0.2/esdc-16d-2.5deg-46x72x1440-3.0.2.zarr', + }, + { + key: 'precipitation', + label: 'Precipitation (EC-Earth3P-HR)', + subtitle: 'Precipitation data from EC-Earth3P-HR highresSST-present', + store: 'https://storage.googleapis.com/cmip6/CMIP6/HighResMIP/EC-Earth-Consortium/EC-Earth3P-HR/highresSST-present/r1i1p1f1/Amon/pr/gr/v20170811/', + }, + { + key: 'arco-ocean', + label: 'ARCO-OCEAN', + subtitle: 'Global Ocean Reanalysis Daily', + store: 'https://ogs-arco-ocean.s3.eu-south-1.amazonaws.com/dataset/tres=1d/res=0p25/levels=10/', + }, + { + key: 'cmip6-mpi-esm1-2-hr-tas', + label: 'CMIP6 MPI-ESM1-2-HR', + subtitle: 'Historical Near-Surface Air Temperature', + store: 'https://storage.googleapis.com/cmip6/CMIP6/CMIP/MPI-M/MPI-ESM1-2-HR/historical/r1i1p1f1/Amon/tas/gn/v20190710/', + }, + { + key: 'cmip6-pmip-ipsl-cm6a-lr-tas', + label: 'CMIP6 PMIP IPSL-CM6A-LR', + subtitle: 'Mid-Holocene Near-Surface Air Temperature', + store: 'https://storage.googleapis.com/cmip6/CMIP6/PMIP/IPSL/IPSL-CM6A-LR/midHolocene/r1i1p1f3/Amon/tas/gr/v20180926/', + }, + { + key: 'cmip6-hadgem3-gc31-hm-tas', + label: 'CMIP6 HighResMIP HadGEM3-GC31-HM', + subtitle: 'Near-Surface Air Temperature', + store: 'https://storage.googleapis.com/cmip6/CMIP6/HighResMIP/MOHC/HadGEM3-GC31-HM/highresSST-present/r1i1p1f1/Amon/tas/gn/v20170831/', + }, + { + key: 'indian-ocean-chl', + label: 'Indian Ocean Physical and Biological Variables', + subtitle: '', + store: 'https://storage.googleapis.com/nmfs_odp_nwfsc/CB/mind_the_chl_gap/IO.zarr', + }, + { + key: 'nasa-mur-sst', + label: 'NASA MUR Sea Surface Temperature', + subtitle: 'Multi-scale Ultra-high Resolution SST', + store: 'https://mur-sst.s3.us-west-2.amazonaws.com/zarr-v1/', + }, + { + key: 'cmip6-gfdl-esm4-tos', + label: 'CMIP6 NOAA GFDL-ESM4', + subtitle: 'Historical Sea Surface Temperature', + store: 'https://storage.googleapis.com/cmip6/CMIP6/CMIP/NOAA-GFDL/GFDL-ESM4/historical/r2i1p1f1/Omon/tos/gn/v20180701/', + }, + { + key: 'cmip6-awi-cm-1-1-mr-tos', + label: 'CMIP6 AWI-CM-1-1-MR', + subtitle: 'Historical Daily Sea Surface Temperature', + store: 'https://cmip6-pds.s3.amazonaws.com/CMIP6/CMIP/AWI/AWI-CM-1-1-MR/historical/r1i1p1f1/Oday/tos/gn/v20181218/', + }, + { + key: 'carbonplan-antarctic-era5', + label: 'CarbonPlan Antarctic ERA5 Reanalysis', + subtitle: '', + store: 'https://carbonplan-share.s3.us-west-2.amazonaws.com/zarr-layer-examples/antarctic_era5.zarr', + }, + { + key: 'silam-dust', + label: 'SILAM Global Dust Model Forecasts', + subtitle: 'Dust', + store: 'https://data.source.coop/bkr/silam-dust/silam_global_dust_v3.zarr', + }, + { + key: 'silam-dust-v2', + label: 'SILAM Global Dust Model Forecasts (v2)', + subtitle: 'Dust', + store: 'https://data.source.coop/bkr/silam-dust/data.zarr', + }, + { + key: 'gefs-35day-direct', + label: 'NOAA GEFS 35-day Ensemble Forecast', + subtitle: '', + store: 'https://data.dynamical.org/noaa/gefs/forecast-35-day/latest.zarr', + }, +]; \ No newline at end of file diff --git a/src/assets/SeasFire-logo.png b/src/assets/imgs/SeasFire-logo.png similarity index 100% rename from src/assets/SeasFire-logo.png rename to src/assets/imgs/SeasFire-logo.png diff --git a/src/assets/line-graph.svg b/src/assets/imgs/line-graph.svg similarity index 100% rename from src/assets/line-graph.svg rename to src/assets/imgs/line-graph.svg diff --git a/src/assets/logo.png b/src/assets/imgs/logo.png similarity index 100% rename from src/assets/logo.png rename to src/assets/imgs/logo.png diff --git a/src/assets/logoS.png b/src/assets/imgs/logoS.png similarity index 100% rename from src/assets/logoS.png rename to src/assets/imgs/logoS.png diff --git a/src/assets/logo_bgc.png b/src/assets/imgs/logo_bgc.png similarity index 100% rename from src/assets/logo_bgc.png rename to src/assets/imgs/logo_bgc.png diff --git a/src/assets/logo_bgc_mpi.png b/src/assets/imgs/logo_bgc_mpi.png similarity index 100% rename from src/assets/logo_bgc_mpi.png rename to src/assets/imgs/logo_bgc_mpi.png diff --git a/src/assets/logo_mpi.png b/src/assets/imgs/logo_mpi.png similarity index 100% rename from src/assets/logo_mpi.png rename to src/assets/imgs/logo_mpi.png diff --git a/src/assets/logocut.png b/src/assets/imgs/logocut.png similarity index 100% rename from src/assets/logocut.png rename to src/assets/imgs/logocut.png diff --git a/src/assets/seasfire_logo.png b/src/assets/imgs/seasfire_logo.png similarity index 100% rename from src/assets/seasfire_logo.png rename to src/assets/imgs/seasfire_logo.png diff --git a/src/assets/settings.svg b/src/assets/imgs/settings.svg similarity index 100% rename from src/assets/settings.svg rename to src/assets/imgs/settings.svg diff --git a/src/assets/index.ts b/src/assets/index.ts index fb9ba350c..4c1130b4e 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -1,9 +1,11 @@ -import logoSeasFire from "./logoS.png"; -import logoSF from "./SeasFire-logo.png"; -import logo from "./logo.png"; -import logoBGC_MPI from "./logo_bgc_mpi.png" -import logoBGC from "./logo_bgc.png" -import logoMPI from "./logo_mpi.png" +import logoSeasFire from "./imgs/logoS.png"; +import logoSF from "./imgs/SeasFire-logo.png"; +import logo from "./imgs/logo.png"; +import logoBGC_MPI from "./imgs/logo_bgc_mpi.png" +import logoBGC from "./imgs/logo_bgc.png" +import logoMPI from "./imgs/logo_mpi.png" +import { ZARR_CATALOG } from "./catalogs/ZarrCatalog" +import { ICECHUNK_CATALOG } from "./catalogs/IcechunkCatalog" export { logoSeasFire, @@ -11,5 +13,7 @@ export { logoSF, logoBGC_MPI, logoBGC, - logoMPI + logoMPI, + ZARR_CATALOG, + ICECHUNK_CATALOG, }; \ No newline at end of file diff --git a/src/components/StoreInitializer.tsx b/src/components/StoreInitializer.tsx index 199308a45..3cf178fb7 100644 --- a/src/components/StoreInitializer.tsx +++ b/src/components/StoreInitializer.tsx @@ -3,8 +3,12 @@ import { useEffect, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import { useGlobalStore } from "@/GlobalStates/GlobalStore"; import { useZarrStore } from '@/GlobalStates/ZarrStore'; +import { usePlotStore } from "@/GlobalStates/PlotStore"; import { useShallow } from 'zustand/shallow'; import { isRemoteStore } from '@/utils/isRemoteStore'; +import { loadNetCDF } from "@/utils/loadNetCDF"; +import { loadFile } from "@/utils/IndexDB"; +import { LoadLocalZarr } from "./ui/MainPanel/LocalZarr"; function StoreInitializerInner() { const searchParams = useSearchParams(); @@ -17,11 +21,40 @@ function StoreInitializerInner() { useEffect(() => { const store = searchParams.get("store"); + let data = searchParams.get("data") + if (data){ + try{ + const fullObj = JSON.parse(data); + if (fullObj.zarrState.blobKey){ // If NC local must load file beforehand + const blobKey = fullObj.zarrState.blobKey + const isNC = fullObj.zarrState.useNC + loadFile(blobKey).then(cache =>{ + if (!isNC){ + LoadLocalZarr(cache?.blob as File[]) + } else { + //@ts-ignore cache is what we want + const file = cache.blob as File + loadNetCDF(file, file.name).then(() => { + useZarrStore.setState(fullObj.zarrState); + useGlobalStore.setState(fullObj.globalState); + usePlotStore.setState(fullObj.plotState); + }) + } + }) + } else { + useZarrStore.setState(fullObj.zarrState) + useGlobalStore.setState(fullObj.globalState) + usePlotStore.setState(fullObj.plotState) + } + } catch { + console.error('Something Failed :/') + } + } if (!store) { setStoreFromURL(false); return; } - + const isNC = searchParams.get("format") === "nc"; setUseNC(isNC); setFetchNC(isNC); diff --git a/src/components/computation/WGSLShaders.ts b/src/components/computation/WGSLShaders.ts index 42339bf65..c890fcaf9 100644 --- a/src/components/computation/WGSLShaders.ts +++ b/src/components/computation/WGSLShaders.ts @@ -492,6 +492,7 @@ export const createShaders = (precision: Precision) => { @group(0) @binding(2) var outputData: array<${precision}>; @group(0) @binding(3) var params: Params; + ${isNaNFunc} @compute @workgroup_size(16, 16, 1) fn main(@builtin(global_invocation_id) global_id: vec3) { let zStride = params.zStride; @@ -512,6 +513,8 @@ export const createShaders = (precision: Precision) => { var ySum: f32 = 0; var xSum: f32 = 0.0; + var nanCount: u32 = 0; + // Iterate along the dimension we're averaging if (reduceDim == 0u) { let cCoord = outX * xStride + outY * yStride; @@ -520,6 +523,7 @@ export const createShaders = (precision: Precision) => { let xi: f32 = f32(firstData[inputIndex]); let yi: f32 = f32(secondData[inputIndex]); if (isNaN(xi) || isNaN(yi)){ + nanCount++; continue; } xSum += f32(firstData[inputIndex]); @@ -532,6 +536,7 @@ export const createShaders = (precision: Precision) => { let xi: f32 = f32(firstData[inputIndex]); let yi: f32 = f32(secondData[inputIndex]); if (isNaN(xi) || isNaN(yi)){ + nanCount++; continue; } xSum += f32(firstData[inputIndex]); @@ -544,6 +549,7 @@ export const createShaders = (precision: Precision) => { let xi: f32 = f32(firstData[inputIndex]); let yi: f32 = f32(secondData[inputIndex]); if (isNaN(xi) || isNaN(yi)){ + nanCount++; continue; } xSum += f32(firstData[inputIndex]); @@ -551,11 +557,12 @@ export const createShaders = (precision: Precision) => { } } - let xMean: f32 = xSum / f32(dimLength); - let yMean: f32 = ySum / f32(dimLength); + let xMean: f32 = xSum / f32(dimLength - nanCount); + let yMean: f32 = ySum / f32(dimLength - nanCount); var numSum: f32 = 0; var denomSum: f32 = 0; + if (reduceDim == 0u) { // Average along Z let cCoord = outX * xStride + outY * yStride; for (var z: u32 = 0u; z < dimLength; z++) { @@ -615,6 +622,7 @@ export const createShaders = (precision: Precision) => { @group(0) @binding(2) var outputData: array<${precision}>; @group(0) @binding(3) var params: Params; + ${isNaNFunc} @compute @workgroup_size(16, 16, 1) fn main(@builtin(global_invocation_id) global_id: vec3) { let zStride = params.zStride; @@ -650,20 +658,25 @@ export const createShaders = (precision: Precision) => { iterStride = xStride; } + var nanCount: u32 = 0; + // Single pass: calculate sums, means, and covariance for (var i: u32 = 0u; i < dimLength; i++) { let inputIndex = baseCoord + (i * iterStride); let xi: f32 = f32(firstData[inputIndex]); let yi: f32 = f32(secondData[inputIndex]); if (isNaN(xi) || isNaN(yi)){ + nanCount++; continue; } xSum += xi; ySum += yi; } - let xMean: f32 = xSum / f32(dimLength); - let yMean: f32 = ySum / f32(dimLength); + var N: f32 = f32(dimLength - nanCount); + + let xMean: f32 = xSum / N; + let yMean: f32 = ySum / N; // Second pass for covariance calculation for (var i: u32 = 0u; i < dimLength; i++) { @@ -677,7 +690,7 @@ export const createShaders = (precision: Precision) => { } let outputIndex = outY * xSize + outX; - outputData[outputIndex] = ${precision}(numSum / (f32(dimLength) - 1)); + outputData[outputIndex] = ${precision}(numSum / (N - 1)); } `, @@ -697,6 +710,8 @@ export const createShaders = (precision: Precision) => { @group(0) @binding(2) var outputData: array<${precision}>; @group(0) @binding(3) var params: Params; + ${isNaNFunc} + @compute @workgroup_size(16, 16, 1) fn main(@builtin(global_invocation_id) global_id: vec3) { let zStride = params.zStride; @@ -719,6 +734,8 @@ export const createShaders = (precision: Precision) => { var ySum: f32 = 0.0; var yySum: f32 = 0.0; var xySum: f32 = 0.0; + + var nanCount: u32 = 0; // Iterate along the dimension we're averaging if (reduceDim == 0u) { // Average along Z let cCoord = outX * xStride + outY * yStride; @@ -727,6 +744,7 @@ export const createShaders = (precision: Precision) => { let xI = f32(firstData[inputIndex]); let yI = f32(secondData[inputIndex]); if (isNaN(xI) || isNaN(yI)){ + nanCount++; continue; } xSum += xI; @@ -742,6 +760,7 @@ export const createShaders = (precision: Precision) => { let xI = f32(firstData[inputIndex]); let yI = f32(secondData[inputIndex]); if (isNaN(xI) || isNaN(yI)){ + nanCount++; continue; } xSum += xI; @@ -757,6 +776,7 @@ export const createShaders = (precision: Precision) => { let xI = f32(firstData[inputIndex]); let yI = f32(secondData[inputIndex]); if (isNaN(xI) || isNaN(yI)){ + nanCount++; continue; } xSum += xI; @@ -767,7 +787,7 @@ export const createShaders = (precision: Precision) => { } } - let N: f32 = f32(dimLength); + let N: f32 = f32(dimLength - nanCount); let meanX = xSum / N; let meanY = ySum / N; let varX = (xxSum / N) - (meanX * meanX); @@ -950,6 +970,7 @@ export const createShaders = (precision: Precision) => { @group(0) @binding(2) var outputData: array<${precision}>; @group(0) @binding(3) var params: Params; + ${isNaNFunc} @compute @workgroup_size(4, 4, 4) fn main(@builtin(global_invocation_id) global_id: vec3) { let zStride = params.zStride; @@ -1055,7 +1076,7 @@ export const createShaders = (precision: Precision) => { @group(0) @binding(1) var secondData: array<${precision}>; @group(0) @binding(2) var outputData: array<${precision}>; @group(0) @binding(3) var params: Params; - + ${isNaNFunc} @compute @workgroup_size(4, 4, 4) fn main(@builtin(global_invocation_id) global_id: vec3) { let zStride = params.zStride; @@ -1169,7 +1190,7 @@ export const createShaders = (precision: Precision) => { @group(0) @binding(1) var secondData: array<${precision}>; @group(0) @binding(2) var outputData: array<${precision}>; @group(0) @binding(3) var params: Params; - + ${isNaNFunc} @compute @workgroup_size(4, 4, 4) fn main(@builtin(global_invocation_id) global_id: vec3) { let zStride = params.zStride; @@ -1411,7 +1432,7 @@ export const createShaders = (precision: Precision) => { @group(0) @binding(0) var inputData: array<${precision}>; @group(0) @binding(1) var outputData: array; @group(0) @binding(2) var params: Params; - + ${isNaNFunc} @compute @workgroup_size(4, 4, 4) fn main(@builtin(global_invocation_id) global_id: vec3) { let zStride = params.zStride; diff --git a/src/components/plots/AxisLines.tsx b/src/components/plots/AxisLines.tsx index b21196bb7..86d9a67d6 100644 --- a/src/components/plots/AxisLines.tsx +++ b/src/components/plots/AxisLines.tsx @@ -114,9 +114,9 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli const zDimScale = zResolution/(zResolution-1) const zValDelta = 1/(zResolution-1) - const xTitleOffset = useMemo(() => (dimNames[shapeLength - 1].length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); - const yTitleOffset = useMemo(() => (dimNames[shapeLength - 2].length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); - const zTitleOffset = useMemo(() => (dimNames[shapeLength - 3].length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); + const xTitleOffset = useMemo(() => (dimNames[shapeLength - 1]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); + const yTitleOffset = useMemo(() => (dimNames[shapeLength - 2]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); + const zTitleOffset = useMemo(() => (dimNames[shapeLength - 3]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); return ( diff --git a/src/components/plots/Plot.tsx b/src/components/plots/Plot.tsx index d54e6ab31..344d8ef94 100644 --- a/src/components/plots/Plot.tsx +++ b/src/components/plots/Plot.tsx @@ -39,6 +39,7 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ const hasMounted = useRef(false); const cameraRef = useRef(null) const {set, camera, size} = useThree() + // Reset Camera Position and Target useEffect(()=>{ if (!hasMounted.current) { @@ -81,6 +82,7 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ } },[resetCamera, isFlat]) + // ---- Switch from Perspective to Orthographic ---- // useEffect(()=>{ if (hasMounted.current){ let newCamera; @@ -117,6 +119,7 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ } },[useOrtho]) + // ---- Move camera to position ---- // useEffect(()=>{ const cam = cameraRef.current const controls = orbitRef.current @@ -136,6 +139,14 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ } },[cameraPosition]) + // ---- Camera Ref for state saves ---- // + useEffect(()=>{ + usePlotStore.setState({camera}) + return () => { + usePlotStore.setState({camera: undefined}) + } + },[camera]) + return ( = { + scalar: 'border-l-teal-700', + slice: 'border-l-[#644FF0]', +}; + +export interface DimOption { + name: string; + size: number; + values?: number[]; + formatValue?: (value: number) => string; +} + +export interface DimSlicerProps { + availableDims: DimOption[]; + dimName: string; + onDimChange: (dimName: string) => void; + onRemove?: () => void; + dimSize: number; + selection: SliceSelectionState; + onChange: (next: SliceSelectionState) => void; + step?: number; + axis?: Axis; + onAxisChange?: (axis: Axis) => void; + values?: number[]; + formatValue?: (value: number) => string; + /** If set, locks the mode and hides the mode toggle */ + lockMode?: SelectionMode; + /** If set, restricts which axes are shown in the axis toggle */ + allowedAxes?: Axis[]; +} + +const DimSlicer: React.FC = ({ + availableDims, + dimName, + onDimChange, + onRemove, + dimSize, + selection, + onChange, + step = 1, + axis: propAxis = 'x', + onAxisChange, + values, + formatValue, + lockMode, + allowedAxes, +}) => { + // const [currentAxis, setCurrentAxis] = useState(propAxis); + const effectiveDimSize = values ? values.length : dimSize; + const rawSel = selection ?? defaultSelection(effectiveDimSize); + const sel = lockMode ? { ...rawSel, mode: lockMode } : rawSel; + + const getIndexFromValue = (val: number): number => { + if (!values) return val; + let closestIndex = 0; + let minDiff = Math.abs(values[0] - val); + for (let i = 1; i < values.length; i++) { + const diff = Math.abs(values[i] - val); + if (diff < minDiff) { + minDiff = diff; + closestIndex = i; + } + } + return closestIndex; + }; + + const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max)); + + const parseOr = (v: string, fallback: number) => { + const n = parseInt(v, 10); + return Number.isNaN(n) ? fallback : n; + }; + + const maxIndex = Math.max(effectiveDimSize - 1, 0); + + const changeScalarBy = (delta: number) => { + let val = parseOr(sel.scalar, 0) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, scalar: String(val) }); + }; + + const changeStartBy = (delta: number) => { + let val = parseOr(sel.start, 0) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, start: String(val) }); + }; + + const changeStopBy = (delta: number) => { + let val = parseOr(sel.stop, maxIndex) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, stop: String(val) }); + }; + + const updateSelection = (patch: Partial) => { + const next = { ...sel, ...patch }; + if (lockMode) next.mode = lockMode; + onChange(next); + }; + + const startIndex = clamp(parseOr(sel.start, 0), 0, maxIndex); + const stopIndex = clamp(parseOr(sel.stop, maxIndex), 0, maxIndex); + const scalarIndex = clamp(parseOr(sel.scalar, 0), 0, maxIndex); + + const startValue = values && effectiveDimSize > 0 && startIndex < values.length ? String(values[startIndex]) : sel.start; + const stopValue = values && effectiveDimSize > 0 && stopIndex < values.length ? String(values[stopIndex]) : sel.stop; + const scalarValue = values && effectiveDimSize > 0 && scalarIndex < values.length ? String(values[scalarIndex]) : sel.scalar; + + const formattedValue = (index: number) => + values && effectiveDimSize > 0 && index < values.length + ? String(formatValue ? formatValue(values[index]) : values[index].toString()) + : String(index); + + const isTimeDimension = dimName.toLowerCase().includes('time'); + const isDateDimension = isTimeDimension || dimName.toLowerCase().includes('date'); + const showTimeControls = Boolean(values && isTimeDimension); + + return ( +
+ + {onRemove && ( + + )} + + {/* Top row: dim select + mode toggle + axis toggle */} +
+ + +
+ {!lockMode && ( + updateSelection({ mode })} + /> + )} + {sel.mode === 'slice' && ( + + {propAxis} + + )} +
+
+ + {/* Slider */} + {sel.mode === 'slice' && ( +
+ + updateSelection({ start: String(newStart), stop: String(newStop) }) + } + className="w-full cursor-pointer [&_[data-slot=slider-track]]:h-0.5 [&_[data-slot=slider-thumb]]:h-3 [&_[data-slot=slider-thumb]]:w-3" + /> +
+ )} + + {sel.mode === 'scalar' && ( +
+ updateSelection({ scalar: String(val) })} + className="w-full cursor-pointer [&_[data-slot=slider-range]]:bg-transparent [&_[data-slot=slider-track]]:h-0.5 [&_[data-slot=slider-thumb]]:h-3 [&_[data-slot=slider-thumb]]:w-3" + /> +
+ )} + + {/* Bottom controls */} + {isDateDimension ? ( + sel.mode === 'scalar' ? ( +
+ updateSelection({ scalar: String(newScalar) })} + value={scalarValue} + placeholder={formattedValue(0)} + ariaLabel="Scalar value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + /> +
+ ) : ( +
+ updateSelection({ start: String(newStart) })} + value={startValue} + placeholder={formattedValue(0)} + ariaLabel="Start value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + /> +
+ updateSelection({ stop: String(newStop) })} + value={stopValue} + placeholder={formattedValue(Math.max(effectiveDimSize - 1, 0))} + ariaLabel="Stop value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + includeEnd + /> +
+
+ ) + ) : ( +
+ {sel.mode === 'slice' ? ( + showTimeControls ? ( + updateSelection({ start: String(newStart) })} + value={startValue} + placeholder={formattedValue(0)} + ariaLabel="Start value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + ariaLabel="Start value" + showInput={!isDateDimension} + /> + ) + ) : ( +
+ )} + + {sel.mode === 'slice' ? ( + showTimeControls ? ( + updateSelection({ stop: String(newStop) })} + value={stopValue} + placeholder={formattedValue(Math.max(effectiveDimSize - 1, 0))} + ariaLabel="Stop value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + includeEnd + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + ariaLabel="Stop value" + showInput={!isDateDimension} + /> + ) + ) : showTimeControls ? ( + updateSelection({ scalar: String(newScalar) })} + value={scalarValue} + placeholder={formattedValue(0)} + ariaLabel="Scalar value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + ariaLabel="Scalar value" + showInput={!isDateDimension} + /> + )} +
+ )} +
+ ); +}; + +export { DimSlicer }; +export default DimSlicer; \ No newline at end of file diff --git a/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx new file mode 100644 index 000000000..a58adac83 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx @@ -0,0 +1,73 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; + +export type Axis = 'x' | 'y' | 'z' | 'c'; + +const AXIS_CLASS: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', +}; + +interface DimSlicerAxisToggleProps { + axis: Axis; + onAxisChange?: (axis: Axis) => void; + /** If provided, only these axes are shown. Defaults to all four. */ + allowedAxes?: Axis[]; +} + +export const DimSlicerAxisToggle: React.FC = ({ + axis, + onAxisChange, + allowedAxes, +}) => { + const [expanded, setExpanded] = useState(false); + const rootRef = useRef(null); + + const axisOptions: Axis[] = allowedAxes ?? ['x', 'y', 'z', 'c']; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ {expanded ? ( + + {axisOptions.map(a => ( + + ))} + + ) : ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx new file mode 100644 index 000000000..30a15f4a0 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx @@ -0,0 +1,67 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; + +export type SelectionMode = 'scalar' | 'slice'; + +interface DimSlicerModeToggleProps { + mode: SelectionMode; + onModeChange: (nextMode: SelectionMode) => void; +} + +export const DimSlicerModeToggle: React.FC = ({ mode, onModeChange }) => { + const [expanded, setExpanded] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ {expanded ? ( + + + + + ) : ( + + )} +
+ ); +}; diff --git a/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx b/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx new file mode 100644 index 000000000..2a9494b65 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx @@ -0,0 +1,36 @@ +'use client' + +import React from 'react' +import { DimSlicerNumericInputWithStepper } from './DimSlicerNumericInputWithStepper' + +interface DimSlicerNumericControlProps { + value: string + placeholder: string + ariaLabel: string + onValueChange: (value: string) => void + onIncrement: () => void + onDecrement: () => void + showInput: boolean +} + +export function DimSlicerNumericControl({ + value, + placeholder, + ariaLabel, + onValueChange, + onIncrement, + onDecrement, + showInput, +}: DimSlicerNumericControlProps) { + return ( + + ) +} diff --git a/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx new file mode 100644 index 000000000..5adab2b9a --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx @@ -0,0 +1,78 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; +import { MinusIcon, PlusIcon } from 'lucide-react'; + +interface DimSlicerNumericInputWithStepperProps { + value: string; + placeholder: string; + onValueChange: (value: string) => void; + onIncrement: () => void; + onDecrement: () => void; + ariaLabel: string; + showInput?: boolean; +} + +export const DimSlicerNumericInputWithStepper: React.FC = ({ + value, + placeholder, + onValueChange, + onIncrement, + onDecrement, + ariaLabel, + showInput = true, +}) => { + const [expanded, setExpanded] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + {showInput && ( + onValueChange(e.target.value)} + onClick={() => setExpanded(false)} + className="no-spinner h-7 text-xs w-16 text-center appearance-none" + placeholder={placeholder} + aria-label={ariaLabel} + /> + )} + {expanded ? ( + + + + + ) : ( + + )} + +
+ ); +}; diff --git a/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx b/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx new file mode 100644 index 000000000..af9e62854 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx @@ -0,0 +1,65 @@ +'use client' + +import React from 'react' +import TimeCombobox from './TimeCombobox' +import { DimSlicerNumericInputWithStepper } from './DimSlicerNumericInputWithStepper' + +interface DimSlicerTimeControlProps { + currentIndex: number + onIndexChange: (index: number) => void + value: string + placeholder: string + ariaLabel: string + values: number[] + effectiveDimSize: number + formattedValue: (index: number) => string + onValueChange: (value: string) => void + onIncrement: () => void + onDecrement: () => void + includeEnd?: boolean + layout?: 'row' | 'column' + showInput?: boolean +} + +export function DimSlicerTimeControl({ + currentIndex, + onIndexChange, + value, + placeholder, + ariaLabel, + values, + effectiveDimSize, + formattedValue, + onValueChange, + onIncrement, + onDecrement, + includeEnd = false, + layout = 'column', + showInput = true, +}: DimSlicerTimeControlProps) { + return ( +
+
+ +
+ +
+ ) +} diff --git a/src/components/ui/DimSlicer/TimeCombobox.tsx b/src/components/ui/DimSlicer/TimeCombobox.tsx new file mode 100644 index 000000000..a878913e9 --- /dev/null +++ b/src/components/ui/DimSlicer/TimeCombobox.tsx @@ -0,0 +1,98 @@ +'use client' +import React, { useState } from 'react' +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '@/components/ui/combobox' + +interface TimeComboboxProps { + currentIndex: number + onIndexChange: (index: number) => void + ariaLabel: string + placeholder: string + values: number[] + effectiveDimSize: number + formattedValue: (index: number) => string + includeEnd?: boolean +} + +export default function TimeCombobox({ + currentIndex, + onIndexChange, + ariaLabel, + placeholder, + values, + effectiveDimSize, + formattedValue, + includeEnd = false, +}: TimeComboboxProps) { + const selectedLabel = + includeEnd && currentIndex === effectiveDimSize + ? formattedValue(Math.max(effectiveDimSize - 1, 0)) + : formattedValue(currentIndex) + + const [inputQuery, setInputQuery] = useState('') + const inputValue = inputQuery === '' ? selectedLabel : inputQuery + + const handleValueChange = (value: unknown) => { + const label = typeof value === 'string' ? value : '' + if (label === '') { + setInputQuery('') + onIndexChange(includeEnd ? effectiveDimSize : 0) + return + } + const item = labeledValues.find(({ label: l }) => l === label) + if (item) { + setInputQuery('') + onIndexChange(item.index) + } + } + + const labeledValues = values.map((_, i) => ({ label: formattedValue(i), index: i })) + + const normalizedInput = inputValue.trim().toLowerCase() + const selectedQuery = selectedLabel.trim().toLowerCase() + + const filtered = + normalizedInput === '' || normalizedInput === selectedQuery + ? labeledValues + : labeledValues.filter(({ label }) => label.toLowerCase().includes(normalizedInput)) + + const targetWidth = Math.min( + Math.max(Math.max(selectedLabel.length, placeholder.length) + 2, 12), + 20 + ) + + return ( + + setInputQuery(typeof value === 'string' ? value : String(value)) + } + autoHighlight + > + + + {filtered.length === 0 ? No items found. : null} + + {filtered.map(({ label, index }) => ( + + {label} + + ))} + + + + ) +} \ No newline at end of file diff --git a/src/components/ui/DimSlicer/index.ts b/src/components/ui/DimSlicer/index.ts new file mode 100644 index 000000000..0622ada38 --- /dev/null +++ b/src/components/ui/DimSlicer/index.ts @@ -0,0 +1,2 @@ +export { default } from './DimSlicer'; +export * from './DimSlicer'; diff --git a/src/components/ui/Elements/HomeButton.tsx b/src/components/ui/Elements/HomeButton.tsx index 2cb5cf1d6..4900c46f6 100644 --- a/src/components/ui/Elements/HomeButton.tsx +++ b/src/components/ui/Elements/HomeButton.tsx @@ -2,7 +2,6 @@ import Image from "next/image"; import Link from "next/link"; -import logoHome from "public/logo-light.svg"; import { Button } from "@/components/ui/button-enhanced"; import { Tooltip, @@ -10,6 +9,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; +const logoHome = "/logo-light.svg"; + export default function HomeButton() { return ( @@ -20,7 +21,7 @@ export default function HomeButton() { size="icon" className="cursor-pointer hover:scale-90 transition-transform duration-100 ease-out" > - logoMPI + logoMPI diff --git a/src/components/ui/MainPanel/CuratedDatasets.tsx b/src/components/ui/MainPanel/CuratedDatasets.tsx deleted file mode 100644 index 3db1c35f4..000000000 --- a/src/components/ui/MainPanel/CuratedDatasets.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; - -import React, { useState } from 'react'; -import { DATASETS_COLLECTION } from './DatasetCollection'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { ChevronDown } from 'lucide-react'; - -const PAGE_SIZE = 5; - -type Props = { - activeOption: string; - setActiveOption: (key: string) => void; - setInitStore: (v: string) => void; - onOpenDescription: () => void; -}; - -const CuratedDatasets = ({ - activeOption, - setActiveOption, - setInitStore, - onOpenDescription, -}: Props) => { - const [search, setSearch] = useState(''); - const [showAll, setShowAll] = useState(false); - - const filtered = DATASETS_COLLECTION.filter(ds => - ds.label.toLowerCase().includes(search.toLowerCase()) || - ds.subtitle.toLowerCase().includes(search.toLowerCase()) - ); - - const visible = showAll ? filtered : filtered.slice(0, PAGE_SIZE); - const hiddenCount = filtered.length - PAGE_SIZE; - const hasMore = hiddenCount > 0; - - return ( - - { - setSearch(v); - setShowAll(false); - }} - /> - - No datasets found. - - {visible.map(ds => ( - { - setActiveOption(ds.key); - setInitStore(ds.store); - onOpenDescription(); - }} - className={`flex flex-col items-start gap-0.5 mb-2 cursor-pointer ${ - activeOption === ds.key ? 'bg-accent' : '' - }`} - > - {ds.label} - - {ds.subtitle} - - - ))} - - {!showAll && hasMore && ( - setShowAll(true)} - className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground cursor-pointer py-2 border-t border-dashed border-border mt-1 hover:text-foreground" - > - - {hiddenCount} more dataset{hiddenCount > 1 ? 's' : ''} available - - )} - - {showAll && hasMore && ( - setShowAll(false)} - className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground cursor-pointer py-2 border-t border-dashed border-border mt-1 hover:text-foreground" - > - - Show less - - )} - - - - ); -}; - -export default CuratedDatasets; \ No newline at end of file diff --git a/src/components/ui/MainPanel/DataSetsModal.tsx b/src/components/ui/MainPanel/DataSetsModal.tsx index 4a70513ba..4dab59bf4 100644 --- a/src/components/ui/MainPanel/DataSetsModal.tsx +++ b/src/components/ui/MainPanel/DataSetsModal.tsx @@ -5,27 +5,33 @@ import { useShallow } from 'zustand/shallow'; import { Dialog, DialogContent, + DialogHeader, + DialogDescription, DialogTitle, } from "@/components/ui/dialog"; -import { - ButtonGroup, -} from "@/components/ui/button-group"; +import { ButtonGroup } from "@/components/ui/button-group"; import { Button } from "@/components/ui/button-enhanced"; import { DescriptionContent } from './DescriptionContent'; -import CuratedDatasets from './CuratedDatasets'; +import StoreCatalog from './StoreCatalog'; +import { ZARR_CATALOG, ICECHUNK_CATALOG } from "@/assets/index"; import RemoteZarr from './RemoteZarr'; import LocalContent from './LocalContent'; import RemoteIcechunk from './RemoteIcechunk'; -type Tab = 'curated' | 'remote' | 'local' | 'icechunk'; +type Tab = 'remote' | 'local' | 'icechunk'; const TABS: { value: Tab; label: string }[] = [ - { value: 'curated', label: 'Curated' }, - { value: 'remote', label: 'Remote Zarr' }, - { value: 'icechunk', label: 'Icechunk' }, - { value: 'local', label: 'Local' }, + { value: 'remote', label: 'Remote Zarr' }, + { value: 'icechunk', label: 'Icechunk' }, + { value: 'local', label: 'Local' }, ]; +const TAB_DESCRIPTIONS: Record = { + remote: 'Browse and open remote Zarr stores via URL or from the catalog.', + icechunk: 'Connect to versioned Icechunk stores for transactional data access.', + local: 'Open a Zarr dataset stored on your local filesystem.', +}; + type Props = { open: boolean; onOpenChange: (v: boolean) => void; @@ -34,8 +40,10 @@ type Props = { const DatasetsModal = ({ open, onOpenChange, isSafari }: Props) => { const [activeOption, setActiveOption] = useState(''); + const [selectedUrl, setSelectedUrl] = useState(''); + const [selectedIcechunkUrl, setSelectedIcechunkUrl] = useState(''); const [hasFetched, setHasFetched] = useState(false); - const [activeTab, setActiveTab] = useState('curated'); + const [activeTab, setActiveTab] = useState('remote'); const { initStore, setInitStore, setOpenVariables, status } = useGlobalStore( useShallow(state => ({ @@ -47,25 +55,46 @@ const DatasetsModal = ({ open, onOpenChange, isSafari }: Props) => { ); const showDescription = hasFetched && status === null; - const openDescription = () => setHasFetched(true); const handleTabChange = (tab: Tab) => { setActiveTab(tab); setHasFetched(false); + setSelectedUrl(''); + setSelectedIcechunkUrl(''); + setActiveOption(''); + }; + + const handleOpenChange = (v: boolean) => { + if (!v) { + setSelectedUrl(''); + setSelectedIcechunkUrl(''); + setActiveOption(''); + setHasFetched(false); + } + onOpenChange(v); }; return ( - + - Open Dataset + + Open dataset + {TAB_DESCRIPTIONS[activeTab]} + - {/* Header */}
-

- Open dataset -

- + {!showDescription && ( + <> +

+ Open dataset +

+

+ {TAB_DESCRIPTIONS[activeTab]} +

+ + )} + {TABS.map(({ value, label }) => (
-
- {activeTab === 'curated' && ( - - )} +
{activeTab === 'remote' && ( - + <> + + + )} {activeTab === 'local' && ( { /> )} {activeTab === 'icechunk' && ( - + <> + + + )} {showDescription && ( { const [showStoreInput, setShowStoreInput] = useState(false); @@ -26,6 +29,7 @@ const Dataset = () => { const [popoverOpen, setPopoverOpen] = useState(false); const [isSafari, setIsSafari] = useState(false); const [openDatasetsModal, setOpenDatasetsModal] = useState(false); + const [useIcechunk, setUseIcechunk] = useState(false); const { initStore, setInitStore, setOpenVariables } = useGlobalStore( useShallow(state => ({ @@ -102,36 +106,51 @@ const Dataset = () => { className="cursor-pointer w-full justify-between gap-2 mb-1" onClick={() => setOpenDatasetsModal(true)} > - {popoverSide !== 'top' && } + {popoverSide !== 'top' && } Explore Datasets - {popoverSide === 'top' && } + {popoverSide === 'top' && } -
-
{ - setShowStoreInput(prev => !prev); + const opening = !showStoreInput || activeOption !== 'remote'; + setShowStoreInput(opening); setShowLocalInput(false); setActiveOption('remote'); setShowDescription(false); + if (opening) setUseIcechunk(false); }} > - Remote Zarr + Remote {showStoreInput && (
- setShowDescription(true)} + setUseIcechunk(prev => !prev)} /> + {useIcechunk ? ( + setShowDescription(true)} + /> + ) : ( + setShowDescription(true)} + /> + )}
)}
+ +
{ setShowStoreInput(false); setActiveOption('local'); setShowDescription(false); + setUseIcechunk(false); }} > Local @@ -166,7 +186,6 @@ const Dataset = () => { />
)} -
diff --git a/src/components/ui/MainPanel/DatasetCollection.ts b/src/components/ui/MainPanel/DatasetCollection.ts deleted file mode 100644 index fb17ba2d9..000000000 --- a/src/components/ui/MainPanel/DatasetCollection.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const ZARR_STORES = { - ESDC: 'https://s3.bgc-jena.mpg.de:9000/esdl-esdc-v3.0.2/esdc-16d-2.5deg-46x72x1440-3.0.2.zarr', - SEASFIRE: 'https://s3.bgc-jena.mpg.de:9000/misc/seasfire_rechunked.zarr', - PRECIPITATION: 'https://storage.googleapis.com/cmip6/CMIP6/HighResMIP/EC-Earth-Consortium/EC-Earth3P-HR/highresSST-present/r1i1p1f1/Amon/pr/gr/v20170811/', -}; - -export const DATASETS_COLLECTION = [ - { - key: 'seasfire', - label: 'SeasFire Cube', - subtitle: 'A Global Dataset for Seasonal Fire Modeling in the Earth System', - store: ZARR_STORES.SEASFIRE, - }, - { - key: 'ESDC', - label: 'ESDC', - subtitle: 'Earth System Data Cube v3.0.2', - store: ZARR_STORES.ESDC, - }, - { - key: 'precipitation', - label: 'Precipitation (EC-Earth3P-HR)', - subtitle: 'Precipitation data from EC-Earth3P-HR highresSST-present', - store: ZARR_STORES.PRECIPITATION, - }, -]; \ No newline at end of file diff --git a/src/components/ui/MainPanel/LocalContent.tsx b/src/components/ui/MainPanel/LocalContent.tsx index 0c494420e..3d518c201 100644 --- a/src/components/ui/MainPanel/LocalContent.tsx +++ b/src/components/ui/MainPanel/LocalContent.tsx @@ -40,7 +40,6 @@ const LocalContent = ({ {})} setOpenVariables={onOpenDescription} - setInitStore={setInitStore} /> )}
diff --git a/src/components/ui/MainPanel/LocalNetCDF.tsx b/src/components/ui/MainPanel/LocalNetCDF.tsx index fe16b3cc2..fa9ee7d9e 100644 --- a/src/components/ui/MainPanel/LocalNetCDF.tsx +++ b/src/components/ui/MainPanel/LocalNetCDF.tsx @@ -3,13 +3,14 @@ import React, {ChangeEvent, useState} from 'react' import { Input } from '../input' import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { loadNetCDF, NETCDF_EXT_REGEX } from '@/utils/loadNetCDF'; - +import { saveFile } from '@/utils/IndexDB'; import { Alert, AlertDescription, AlertTitle, } from '@/components/ui/alert'; import { isMobile } from '../MobileUIHider'; +import { useZarrStore } from '@/GlobalStates/ZarrStore'; interface LocalNCType { setOpenVariables: (open: boolean) => void; @@ -20,23 +21,26 @@ const LocalNetCDF = ({ setOpenVariables}:LocalNCType) => { // const {ncModule} = useZarrStore.getState() const [ncError, setError] = useState(null); - const handleFileSelect = async (event: ChangeEvent) => { - setError(null); - const files = event.target.files; - if (!files || files.length === 0) { setStatus(null); return; } - const file = files[0]; - if (!NETCDF_EXT_REGEX.test(file.name)) { - setError('Please select a valid NetCDF (.nc, .netcdf, .nc3, .nc4) file.'); - return; - } - try { - await loadNetCDF(file, file.name); - setOpenVariables(true) - - } catch (e) { - setError(`Failed to load file: ${e instanceof Error ? e.message : String(e)}`); - } - }; + const handleFileSelect = async (event: ChangeEvent) => { + setError(null); + const files = event.target.files; + if (!files || files.length === 0) { setStatus(null); return; } + const file = files[0]; + if (!NETCDF_EXT_REGEX.test(file.name)) { + setError('Please select a valid NetCDF (.nc, .netcdf, .nc3, .nc4) file.'); + return; + } + try { + await loadNetCDF(file, file.name); + const blobKey = `local_${file.name}` + await saveFile(file, blobKey) + useZarrStore.setState({blobKey}) + console.log('Set Key?') + setOpenVariables(true) + } catch (e) { + setError(`Failed to load file: ${e instanceof Error ? e.message : String(e)}`); + } + }; return (
diff --git a/src/components/ui/MainPanel/LocalZarr.tsx b/src/components/ui/MainPanel/LocalZarr.tsx index f54040467..216ecbd90 100644 --- a/src/components/ui/MainPanel/LocalZarr.tsx +++ b/src/components/ui/MainPanel/LocalZarr.tsx @@ -5,25 +5,16 @@ import { useZarrStore } from '@/GlobalStates/ZarrStore'; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { Input } from '../input'; import ZarrParser from '@/components/zarr/ZarrParser'; +import { saveFile } from '@/utils/IndexDB'; interface LocalZarrType { setShowLocal: React.Dispatch>; setOpenVariables: (open: boolean) => void; - setInitStore: (store: string) => void; } -const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) => { - const setCurrentStore = useZarrStore(state => state.setCurrentStore) - const {setStatus} = useGlobalStore.getState() - const handleFileSelect = async (event: ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) { - setStatus(null) - return; - } - // Create a Map to hold the Zarr store data +export async function LoadLocalZarr(files:File[]){ + // Create a Map to hold the Zarr store data const fileMap = new Map(); - // The base directory name will be the first part of the relative path const baseDir = files[0].webkitRelativePath.split('/')[0]; @@ -35,7 +26,6 @@ const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) fileMap.set('/' + relativePath, file); // Zarrita looks for a leading slash before variables. Need to add it back } } - // Create a custom zarrita store from the Map const customStore: zarr.AsyncReadable = { async get(key: string): Promise { @@ -48,37 +38,52 @@ const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) // Open the Zarr store using the custom store let store = await zarr.withMaybeConsolidatedMetadata(customStore); if (!('contents' in store)){ + console.log("Trying to parse?") // Metadata is missing. We will need to parse variables here. store = await ZarrParser(files, customStore) } const gs = await zarr.open(store, {kind: 'group'}); - setCurrentStore(gs); - setShowLocal(false); - setOpenVariables(true); - setInitStore(`local_${baseDir}`) - setStatus(null) - useZarrStore.setState({ useNC: false}) + const blobKey = `local_${baseDir}` + useGlobalStore.setState({initStore:blobKey}) + useZarrStore.setState({ useNC: false, blobKey, currentStore:gs}) } catch (error) { - setStatus(null) + if (error instanceof Error) { console.log(`Error opening Zarr store: ${error.message}`); } else { console.log('An unknown error occurred when opening the Zarr store.'); } } - }; - return ( -
- -
- ) +} + +const LocalZarr = ({setShowLocal, setOpenVariables}:LocalZarrType) => { + const {setStatus} = useGlobalStore.getState() + const handleFileSelect = async (event: ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) { + setStatus(null) + return; + } + const baseDir = files[0].webkitRelativePath.split('/')[0]; + LoadLocalZarr(Array.from(files)).then(()=>{ + saveFile(Array.from(files), `local_${baseDir}`) + setShowLocal(false); + setOpenVariables(true); + setStatus(null) + }) + }; + return ( +
+ +
+ ) } export default LocalZarr diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx new file mode 100644 index 000000000..3f74a99c7 --- /dev/null +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -0,0 +1,355 @@ +"use client"; + +import React, { useMemo, useState } from 'react'; +import DimSlicer, { Axis, defaultSelection, DimOption, SliceSelectionState } from '@/components/ui/DimSlicer'; +import { defaultAttributes, renderAttributes } from "@/components/ui/MetaData"; +import { Button } from '@/components/ui/button'; +import { useGlobalStore } from '@/GlobalStates/GlobalStore'; +import { useShallow } from 'zustand/shallow'; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; +import { Badge } from "@/components/ui"; +import { parseLoc } from '@/utils/HelperFuncs'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const MAX_ACTIVE_DIMS = 3; +const DEFAULT_AXES: Axis[] = ['z', 'y', 'x']; + +const formatArray = (value: string | number[]): string => { + if (typeof value === 'string') return value; + return Array.isArray(value) ? value.join(', ') : String(value); +}; + +interface DimInfo { + dimArrays: ArrayLike[]; + dimNames: string[]; + dimUnits: (string | null)[]; +} + +type Props = { + meta: { + name?: string; + shape?: number[]; + chunks?: number[]; + long_name?: string; + dimInfo?: DimInfo; + [key: string]: unknown; + }; + metadata?: Record; + onApply?: (sels: SliceSelectionState[], axes: Axis[], dimNames: string[]) => void; +}; + +const AXIS_COLOR: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', +}; + +function axisForIndex(idx: number): Axis { + return DEFAULT_AXES[idx] ?? DEFAULT_AXES[DEFAULT_AXES.length - 1]; +} + +function selectionSummary( + availableDims: DimOption[], + activeRows: SlicerRow[], + collapsedSels: Record, +): string { + const parts = availableDims.map((dim) => { + const activeRow = activeRows.find((r) => r.dimName === dim.name); + const sel = activeRow ? activeRow.sel : collapsedSels[dim.name]; + if (!sel) return `${dim.name}=?`; + const range = + sel.mode === 'scalar' + ? sel.scalar || '0' + : `${sel.start !== '' ? sel.start : '0'}:${sel.stop !== '' ? sel.stop : ':'}`; + return `${dim.name}=${range}`; + }); + return `[ ${parts.join(', ')} ]`; +} + +interface SlicerRow { + id: number; + dimName: string; + sel: SliceSelectionState; + axis: Axis; +} + +let _nextId = 0; +const nextId = () => ++_nextId; + +export default function MetaDimSelector({ meta, metadata, onApply }: Props) { + const rawDimArrays = meta?.dimInfo?.dimArrays ?? []; + const rawDimNames = meta?.dimInfo?.dimNames ?? []; + const rawDimUnits = meta?.dimInfo?.dimUnits ?? []; + const dataShape = meta?.shape; + const chunkShape = meta?.chunks; + + const dimArrays: number[][] = useMemo( + () => rawDimArrays.map((a) => Array.from(a)), + [rawDimArrays], + ); + const dimUnits: string[] = useMemo( + () => rawDimUnits.map((u) => u ?? ''), + [rawDimUnits], + ); + const dimNames: string[] = rawDimNames; + + const { setDimArrays, setDimNames, setDimUnits } = useGlobalStore( + useShallow((state) => ({ + setDimArrays: state.setDimArrays, + setDimNames: state.setDimNames, + setDimUnits: state.setDimUnits, + })), + ); + + React.useEffect(() => { + setDimArrays(dimArrays); + setDimNames(dimNames); + setDimUnits(dimUnits); + }, [dimArrays, dimNames, dimUnits, setDimArrays, setDimNames, setDimUnits]); + + const availableDims: DimOption[] = useMemo( + () => + dimArrays.map((values, idx) => ({ + name: dimNames[idx] ?? `dim${idx}`, + size: values.length, + values, + formatValue: (v: number): string => + String(parseLoc(values[v] ?? v, dimUnits[idx] || undefined)), + })), + [dimArrays, dimNames, dimUnits], + ); + + const dimsKey = availableDims.map((d) => `${d.name}:${d.size}`).join('|'); + + const makeInitialCollapsedSels = (dims: DimOption[]): Record => + Object.fromEntries(dims.map((d) => [d.name, { ...defaultSelection(d.size), mode: 'scalar' as const }])); + + /** Default to first MIN(3, availableDims.length) dims as active rows */ + const makeInitialRows = (dims: DimOption[]): SlicerRow[] => + dims.slice(0, MAX_ACTIVE_DIMS).map((d, i) => ({ + id: nextId(), + dimName: d.name, + sel: { ...defaultSelection(d.size), mode: 'slice' }, + axis: axisForIndex(i), + })); + + const [rows, setRows] = useState(() => makeInitialRows(availableDims)); + const [collapsedSels, setCollapsedSels] = useState>( + () => makeInitialCollapsedSels(availableDims), + ); + const [lastKey, setLastKey] = useState(dimsKey); + const [collapsedOpen, setCollapsedOpen] = useState(false); + + if (dimsKey !== lastKey) { + setLastKey(dimsKey); + setRows(makeInitialRows(availableDims)); + setCollapsedSels(makeInitialCollapsedSels(availableDims)); + } + + const firstUnusedDim = (currentRows: SlicerRow[]): string => { + const usedNames = new Set(currentRows.map((r) => r.dimName)); + return availableDims.find((d) => !usedNames.has(d.name))?.name ?? ''; + }; + + const addRow = () => { + setRows((prev) => { + if (prev.length >= MAX_ACTIVE_DIMS) return prev; + const dimName = firstUnusedDim(prev); + if (!dimName) return prev; + const dim = availableDims.find((d) => d.name === dimName)!; + const newRow: SlicerRow = { + id: nextId(), + dimName, + sel: { ...defaultSelection(dim.size), mode: 'slice' }, + axis: axisForIndex(prev.length), + }; + return [...prev, newRow]; + }); + }; + + const removeLastRow = () => + setRows((prev) => prev.slice(0, -1)); + + const updateDimName = (id: number, dimName: string) => { + setRows((prev) => + prev.map((r) => { + if (r.id !== id) return r; + const dim = availableDims.find((d) => d.name === dimName); + return { ...r, dimName, sel: { ...defaultSelection(dim?.size), mode: 'slice' } }; + }), + ); + }; + + const updateSel = (id: number, sel: SliceSelectionState) => + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, sel: { ...sel, mode: 'slice' } } : r))); + + // const updateAxis = (id: number, axis: Axis) => + // setRows((prev) => prev.map((r) => (r.id === id ? { ...r, axis } : r))); + + const updateCollapsedSel = (dimName: string, sel: SliceSelectionState) => + setCollapsedSels((prev) => ({ ...prev, [dimName]: { ...sel, mode: 'scalar' } })); + + const summary = useMemo( + () => selectionSummary(availableDims, rows, collapsedSels), + [availableDims, rows, collapsedSels], + ); + + const activeDimNames = new Set(rows.map((r) => r.dimName)); + const collapsedDims = availableDims.filter((d) => !activeDimNames.has(d.name)); + + const atMax = rows.length >= MAX_ACTIVE_DIMS; + const noUnused = firstUnusedDim(rows) === ''; + const canAdd = !atMax && !noUnused; + + const addTooltip = atMax + ? `Maximum of ${MAX_ACTIVE_DIMS} dimensions, remove one before adding another.` + : noUnused + ? 'All dimensions are already active.' + : undefined; + + return ( + <> + {`${meta.long_name} `} + + + Attributes + + + {renderAttributes(metadata, defaultAttributes)} + + +
+ +
+ {'selection'} {summary} +
+ +
+ {rows.map((row) => { + const originalIndex = availableDims.findIndex( + (d) => d.name === row.dimName + ); + + return ( + + ( + {row.dimName} + ,{' '} + {row.axis} + ,{' '} + + {originalIndex} + + ) + + ); + })} +
+ +
+
+ Data Shape + {`[${formatArray(dataShape ?? [])}]`} +
+
+ Chunk Shape + {`[${formatArray(chunkShape ?? [])}]`} +
+
+ +
+ + + {addTooltip && ( +
+
+ {addTooltip} +
+
+ )} +
+ + {/* Active slicers — locked to slice, z/y/x axes only, trash only on last */} +
+ {rows.map((row, i) => { + const dim = availableDims.find((d) => d.name === row.dimName); + const isLast = i === rows.length - 1; + return ( + updateDimName(row.id, name)} + onRemove={isLast && rows.length > 1 ? removeLastRow : undefined} + dimSize={dim?.size ?? 0} + selection={row.sel} + axis={row.axis} + onChange={(sel) => updateSel(row.id, sel)} + // onAxisChange={(axis) => updateAxis(row.id, axis)} + values={dim?.values} + formatValue={dim?.formatValue} + lockMode="slice" + allowedAxes={['z', 'y', 'x']} + /> + ); + })} +
+ + {/* Collapsed dimensions */} + {collapsedDims.length > 0 && ( +
+ + + {collapsedOpen && ( +
+ {collapsedDims.map((dim) => ( + {}} + dimSize={dim.size} + selection={collapsedSels[dim.name] ?? { ...defaultSelection(dim.size), mode: 'scalar' }} + axis="c" + onChange={(sel) => updateCollapsedSel(dim.name, sel)} + values={dim.values} + formatValue={dim.formatValue} + lockMode="scalar" + /> + ))} +
+ )} +
+ )} + +
+ +
+ + ); +} \ No newline at end of file diff --git a/src/components/ui/MainPanel/RemoteIcechunk.tsx b/src/components/ui/MainPanel/RemoteIcechunk.tsx index 0497f1120..1abbe6157 100644 --- a/src/components/ui/MainPanel/RemoteIcechunk.tsx +++ b/src/components/ui/MainPanel/RemoteIcechunk.tsx @@ -93,10 +93,14 @@ const HeaderRows = ({ rows, set }: HeaderRowsProps) => { type Props = { setInitStore: (v: string) => void; onOpenDescription: () => void; + selectedUrl?: string }; -const RemoteIcechunk = ({ setInitStore, onOpenDescription }: Props) => { - const [url, setUrl] = useState(''); +const RemoteIcechunk = ({ setInitStore, onOpenDescription, selectedUrl = '' }: Props) => { + const [url, setUrl] = useState(selectedUrl); + + const [showSettings, setShowSettings] = useState(false); + const [refType, setRefType] = useState('branch'); const [refValue, setRefValue] = useState('main'); @@ -152,7 +156,7 @@ const RemoteIcechunk = ({ setInitStore, onOpenDescription }: Props) => {
{/* URL + Fetch */} -
+
{
- {/* Branch / Tag / Snapshot */} -
-
- {REF_TABS.map(({ value, label }) => ( - - ))} -
- setRefValue(e.target.value)} - /> -
- - {/* Storage options */} + {/* Settings toggle */}
- {showStorage && ( -
-
- {/* Credentials Select */} -
- Credentials - -
+ {showSettings && ( +
- {/* Cache Select */} -
- Cache - -
- + {label} + + ))}
- + setRefValue(e.target.value)} + />
- )} -
- {/* fetchClient headers */} -
- - {showFetchClientHeaders && ( - - )} -
+ {/* Storage options */} +
+ + {showStorage && ( +
+
- {/* Advanced */} -
- - {showAdvanced && ( -
-
- - setMaxRetries(Number(e.target.value))} - /> -
-
- - setRetryDelay(Number(e.target.value))} - /> -
+ {/* Credentials Select */} +
+ Credentials + +
+ + {/* Cache Select */} +
+ Cache + +
+ +
+ +
+ )} +
+ + {/* fetchClient headers */} +
+ + {showFetchClientHeaders && ( + + )} +
+ + {/* Advanced */} +
+ + {showAdvanced && ( +
+
+ + setMaxRetries(Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ + setRetryDelay(Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ )}
+
)}
-
); }; diff --git a/src/components/ui/MainPanel/RemoteZarr.tsx b/src/components/ui/MainPanel/RemoteZarr.tsx index bc891947e..05e93b5e1 100644 --- a/src/components/ui/MainPanel/RemoteZarr.tsx +++ b/src/components/ui/MainPanel/RemoteZarr.tsx @@ -3,7 +3,6 @@ import React, { useState } from 'react'; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { Input } from '@/components/ui/'; import { Button } from '@/components/ui/button-enhanced'; - import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'; import { useZarrStore } from '@/GlobalStates/ZarrStore'; @@ -11,10 +10,10 @@ type HeaderRow = { key: string; value: string }; type AuthPreset = 'none' | 'bearer' | 'basic' | 'apikey'; const PRESETS: { value: AuthPreset; label: string }[] = [ - { value: 'none', label: 'None' }, - { value: 'bearer', label: 'Bearer' }, - { value: 'basic', label: 'Basic' }, - { value: 'apikey', label: 'API Key' }, + { value: 'none', label: 'None' }, + { value: 'bearer', label: 'Bearer' }, + { value: 'basic', label: 'Basic' }, + { value: 'apikey', label: 'API Key' }, ]; const PRESET_KEYS: Record, string> = { @@ -33,14 +32,16 @@ type Props = { initStore: string; setInitStore: (v: string) => void; onOpenDescription: () => void; + selectedUrl?: string; }; -const RemoteZarr = ({ initStore, setInitStore, onOpenDescription }: Props) => { +const RemoteZarr = ({ initStore, setInitStore, onOpenDescription, selectedUrl = '' }: Props) => { const [showFetchOptions, setShowFetchOptions] = useState(false); const [preset, setPreset] = useState('none'); const [presetValue, setPresetValue] = useState(''); const [headers, setHeaders] = useState([{ key: '', value: '' }]); const [showCustom, setShowCustom] = useState(false); + const [urlValue, setUrlValue] = useState(selectedUrl); const addHeaderRow = () => setHeaders(h => [...h, { key: '', value: '' }]); const removeHeaderRow = (i: number) => setHeaders(h => h.filter((_, idx) => idx !== i)); @@ -73,17 +74,16 @@ const RemoteZarr = ({ initStore, setInitStore, onOpenDescription }: Props) => { className="flex flex-col gap-3" onSubmit={(e: React.FormEvent) => { e.preventDefault(); - const input = e.currentTarget.elements[0] as HTMLInputElement; - const url = input.value; - if (!url) return; + if (!urlValue) return; const fetchHandler = buildFetchHandler(); - if (fetchHandler && url.startsWith('http://')) { + if (fetchHandler && urlValue.startsWith('http://')) { useGlobalStore.getState().setStatus('Error: Cannot send auth headers over plain HTTP — use HTTPS.'); return; } + const fetchOptions = { - ...(fetchHandler && { fetch: fetchHandler}), + ...(fetchHandler && { fetch: fetchHandler }), }; useZarrStore.getState().setIcechunkOptions(null); @@ -93,19 +93,24 @@ const RemoteZarr = ({ initStore, setInitStore, onOpenDescription }: Props) => { useGlobalStore.getState().setStatus('Fetching...'); useZarrStore.getState().bumpFetchKey(); - setInitStore(url); + setInitStore(urlValue); onOpenDescription(); }} > {/* URL + Fetch */} -
- +
+ setUrlValue(e.target.value)} + />
- {/* FetchOptions */} + {/* fetchOptions toggle */}
+
diff --git a/src/components/ui/NavBar/ParameterExport.tsx b/src/components/ui/NavBar/ParameterExport.tsx new file mode 100644 index 000000000..446206fce --- /dev/null +++ b/src/components/ui/NavBar/ParameterExport.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react' +import { useGlobalStore } from '@/GlobalStates/GlobalStore' +import { usePlotStore } from '@/GlobalStates/PlotStore' +import { useZarrStore } from '@/GlobalStates/ZarrStore' +import { BiExport } from "react-icons/bi"; +import { FiCopy } from "react-icons/fi"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Button } from '../button'; + +function pick(obj: Record, keys: string[]) { + return Object.fromEntries( + keys.map((k) => [k, obj[k]]) + ) +} +const globalValues = [ + 'initStore', + 'storeFromURL', + 'variable', +] + +const plotValues = [ + 'plotType', + 'pointSize', + 'scalePoints', + 'scaleIntensity', + 'timeScale', + 'valueRange', + 'xRange', + 'yRange', + 'zRange', + 'showPoints', + 'linePointSize', + 'lineWidth', + 'lineColor', + 'pointColor', + 'useLineColor', + 'lineResolution', + 'cOffset', + 'cScale', + 'useFragOpt', + 'useCustomColor', + 'useCustomPointColor', + 'transparency', + 'nanTransparency', + 'nanColor', + 'showBorders', + 'borderColor', + 'lonExtent', + 'latExtent', + 'originalExtent', + 'lonResolution', + 'latResolution', + 'colorIdx', + 'vTransferRange', + 'vTransferScale', + 'sphereResolution', + 'displacement', + 'displaceSurface', + 'offsetNegatives', + 'zSlice', + 'ySlice', + 'xSlice', + 'interpPixels', + 'useOrtho', + 'rotateFlat', + 'fillValue', + 'coarsen', + 'kernel', + 'useBorderTexture', + 'maskValue', + 'borderWidth', + 'cameraPosition', + 'disablePointScale', +] + +const zarrValues = [ + 'zSlice', + 'ySlice', + 'xSlice', + 'compress', + 'useNC', // This one is more static and so toggling switch doesn't break all other logic + 'fetchNC', + 'coarsen', + 'kernelSize', + 'kernelDepth', + 'icechunkOptions', + 'fetchOptions', + 'fetchKey', + 'blobKey' +] + + +export const ParameterExport = () => { + const [copied, setCopied] = useState(false); + + function generateURL(){ + const {camera} = usePlotStore.getState() + usePlotStore.setState({cameraPosition:camera?.position}) // Set Camera position first to copy visual state + const fullObj = { + globalState: pick(useGlobalStore.getState(), globalValues), + plotState: pick(usePlotStore.getState(), plotValues), + zarrState: pick(useZarrStore.getState(), zarrValues), + } + const jString = JSON.stringify(fullObj, (_, v) => typeof v === 'bigint' ? v.toString() : v) + const params = `https://browzarr.io/latest/?data=${encodeURIComponent(jString)}` + return params + } + + const copyToClipboard = async () => { + const url = generateURL() + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); //Use for a pop-up that fades away + }; + + return ( + + + + + +
+ +
+ Copied! +
+
+
+
+ ) +} + + diff --git a/src/components/ui/Widgets/Switcher.tsx b/src/components/ui/Widgets/Switcher.tsx index e5c1caee1..b066e0aba 100644 --- a/src/components/ui/Widgets/Switcher.tsx +++ b/src/components/ui/Widgets/Switcher.tsx @@ -4,7 +4,7 @@ export const Switcher = ({leftText, rightText, state, onClick, className} : {lef return (
@@ -14,7 +14,7 @@ export const Switcher = ({leftText, rightText, state, onClick, className} : {lef {rightText}
diff --git a/src/components/zarr/ZarrParser.ts b/src/components/zarr/ZarrParser.ts index 74b474750..61f8d8fd9 100644 --- a/src/components/zarr/ZarrParser.ts +++ b/src/components/zarr/ZarrParser.ts @@ -1,6 +1,33 @@ import * as zarr from 'zarrita' -import { jsonDecodeObject, jsonEncodeObject } from 'node_modules/zarrita/dist/src/util'; +import { InvalidMetadataError } from 'zarrita'; +// jsonEncodeObject and jsonDecodeObject +// From zarrita/src/util.ts, better this, than rely on internals +export function jsonEncodeObject(o: Record): Uint8Array { + const str = JSON.stringify( + o, + (_key, value) => { + // JSON.stringify converts NaN/Infinity/-Infinity to null. + // Zarr v3 spec requires these as string representations. + if (typeof value === "number") { + if (Number.isNaN(value)) return "NaN"; + if (value === Infinity) return "Infinity"; + if (value === -Infinity) return "-Infinity"; + } + return value; + }, + 2, + ); + return new TextEncoder().encode(str); +} +export function jsonDecodeObject(bytes: Uint8Array) { + const str = new TextDecoder().decode(bytes); + try { + return JSON.parse(str); + } catch (cause) { + throw new InvalidMetadataError("Failed to decode JSON", { cause }); + } +} function is_meta_key(key: string) { return (key.endsWith(".zarray") || key.endsWith(".zgroup") || @@ -11,6 +38,7 @@ function is_v3(meta: any) { return "zarr_format" in meta && meta.zarr_format === 3; } async function ZarrParser(files: any, store: any){ + console.log(store) const fileCount = files.length; const vars = [] const metadata: { [key: string]: any } = {} @@ -26,6 +54,7 @@ async function ZarrParser(files: any, store: any){ } for (const variable of vars){ const decoded = await store.get(variable) + console.log(decoded) metadata[variable.slice(1)] = jsonDecodeObject(decoded) } const v2_meta = {metadata, zarr_consolidated_format: 1} diff --git a/src/utils/IndexDB.ts b/src/utils/IndexDB.ts new file mode 100644 index 000000000..e48ad0caa --- /dev/null +++ b/src/utils/IndexDB.ts @@ -0,0 +1,32 @@ +const DB_NAME = 'browzarr-files'; +const STORE = 'blobs'; + + +export function openDB(): Promise { + return new Promise((res, rej) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => res(req.result); + req.onerror = () => rej(req.error); + }); +} + +export async function saveFile(blob: any, key: string): Promise { + const db = await openDB(); + return new Promise((res, rej) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put({ blob, key }, key); + tx.oncomplete = () => res(key); + tx.onerror = () => rej(tx.error); + }); +} + +export async function loadFile(key: string): Promise<{ blob: any; name: string } | null> { + const db = await openDB(); + return new Promise((res, rej) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(key); + req.onsuccess = () => res(req.result ?? null); + req.onerror = () => rej(req.error); + }); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index faf7c6190..8cc17e88a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,13 +22,12 @@ "name": "next" } ], - "baseUrl": ".", "paths": { "@/*": [ - "src/*" + "./src/*" ], "@/components/*": [ - "src/components/*" + "./src/components/*" ] } },