Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Deploy to GitHub Pages

on:
push:
branches: [healpix-geo]
branches: [healpix-geo, multiscale-eopf]
workflow_dispatch:

permissions:
Expand Down
23 changes: 5 additions & 18 deletions src/lib/data/authStore.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,14 @@
import * as zarr from "zarrita";

let authToken: string | null = null;

export function setAuthToken(token: string | null) {
authToken = token;
}

export function getAuthToken(): string | null {
return authToken;
}

/**
* Create a FetchStore with optional auth token injection.
* If an auth token is set, it is passed as an X-Auth-Token header
* via the FetchStore overrides mechanism.
* Create a FetchStore for public, CORS-enabled Zarr datasets.
*
* (EGI DataHub / token-authenticated store support has been removed in this
* build — datasets are expected to be publicly accessible.)
*/
export function createFetchStore(
url: string | URL,
options: { useSuffixRequest?: boolean } = {}
): zarr.FetchStore {
const overrides: RequestInit = {};
if (authToken) {
overrides.headers = { "X-Auth-Token": authToken };
}
return new zarr.FetchStore(url, { ...options, overrides });
return new zarr.FetchStore(url, options);
}
24 changes: 24 additions & 0 deletions src/lib/data/gridTypeDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ function checkHealpixGrid(crs: zarr.Array<zarr.DataType, zarr.FetchStore>) {
return crs.attrs["grid_mapping_name"] === "healpix";
}

// DGGS convention (e.g. EOPF HEALPix): the grid group carries a `dggs` attribute
// instead of a CF `grid_mapping` CRS variable.
async function checkDggsHealpix(
datasources: TSources,
varname: string
): Promise<boolean> {
try {
const group = await ZarrDataManager.getDatasetGroup(
ZarrDataManager.getDatasetSource(datasources, varname)
);
const dggs = group.attrs?.dggs as { name?: string } | undefined;
return (
typeof dggs?.name === "string" && dggs.name.toLowerCase() === "healpix"
);
} catch {
return false;
}
}

function checkRegularRotatedGrid(
crs: zarr.Array<zarr.DataType, zarr.FetchStore>
) {
Expand Down Expand Up @@ -162,6 +181,11 @@ export async function getGridType(
varnameSelector
);

// DGGS-convention HEALPix (e.g. EOPF)
if (await checkDggsHealpix(datasources!, varnameSelector)) {
return GRID_TYPES.HEALPIX;
}

// Check CRS-based grid types
const crsGridType = await determineGridTypeFromCRS(
datasources!,
Expand Down
109 changes: 104 additions & 5 deletions src/lib/data/sourceIndexing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,90 @@ function createIndex(
};
}

/** The Zarr store root of a URL — everything up to and including ".zarr". */
function zarrRoot(url: string): string {
const marker = ".zarr";
const i = url.indexOf(marker);
return i >= 0 ? url.slice(0, i + marker.length) : url.replace(/\/+$/, "");
}

/**
* Build an index for an EOPF-style store, where consolidated metadata lives only
* at the Zarr root, a subgroup carries a `multiscales` layout (with root-relative
* `asset` paths), and each level group holds the variables + `cell_ids` but has no
* consolidated metadata of its own. We therefore drive everything from the root
* consolidated metadata instead of recursing into the (metadata-less) level groups.
*/
type TLayout = Array<{ asset: string; [key: string]: unknown }>;

/** Find a `multiscales.layout` carried by any group node in the metadata tree. */
function findMultiscalesLayout(
metadata: Record<string, zarr.ArrayMetadata | zarr.GroupMetadata>
): TLayout | undefined {
for (const node of Object.values(metadata)) {
const attrs = (node as { attributes?: Record<string, unknown> }).attributes;
const ms = attrs?.multiscales as { layout?: TLayout } | undefined;
if (node.node_type === "group" && ms?.layout?.length) {
return ms.layout;
}
}
return undefined;
}

function buildEopfMultiscalesIndex(
rootSrc: string,
metadata: Record<string, zarr.ArrayMetadata | zarr.GroupMetadata>
): TSources | null {
const layout = findMultiscalesLayout(metadata);
if (!layout?.length) {
return null;
}

const finest = layout[0].asset.replace(/^\/+|\/+$/g, ""); // e.g. measurements/reflectance/20
const prefix = finest + "/";
const root = zarrRoot(rootSrc);
// store points directly at the (finest) level group; the multiscales LOD swaps
// this store for other levels via rewriteDatasourcesUrl, so `dataset` stays "".
const finestUrl = root + "/" + finest;
const datasources: Record<string, TDataSource> = {};
for (const [key, node] of Object.entries(metadata)) {
if (node.node_type !== "array" || !key.startsWith(prefix)) {
continue;
}
const varname = key.slice(prefix.length);
if (varname.includes("/")) {
continue; // only direct children of the finest level group
}
const arrayNode = node as zarr.ArrayMetadata;
datasources[varname] = {
store: finestUrl,
dataset: "",
hidden:
varname === "cell_ids" ||
!isValidVariable(varname, arrayNode.shape, arrayNode.dimension_names),
attrs: {
...node.attributes,
dimensionNames: arrayNode.dimension_names,
} as Record<string, unknown>,
};
}
if (Object.keys(datasources).length === 0) {
return null;
}

return {
zarr_format: ZARR_FORMAT.V3, // eslint-disable-line camelcase
multiscales: { baseUrl: root, layout },
levels: [
{
grid: { store: finestUrl, dataset: "" },
time: { store: finestUrl, dataset: "" },
datasources,
},
],
};
}

export async function indexFromZarr(src: string): Promise<TSources> {
try {
const store = await zarr.withConsolidated(lru(createFetchStore(src)));
Expand All @@ -207,20 +291,35 @@ export async function indexFromZarr(src: string): Promise<TSources> {
createFetchStore(src)
);

// Detect multiscales metadata: redirect to the finest level subgroup
// Note: openZarrV3Metadata puts the full zarr.json as group.attrs,
// so actual group attributes are nested under group.attrs.attributes
const rootAttrs = group.attrs as TZarrV3RootMetadata;
const groupAttrs = (rootAttrs?.attributes ?? {}) as Record<string, unknown>;

// EOPF-style store: consolidated metadata only at the root, with a multiscales
// group whose level groups carry the variables. Drive the index from the root
// consolidated metadata (the level groups have no metadata to recurse into).
const consolidated = rootAttrs?.consolidated_metadata?.metadata;
if (consolidated) {
const eopf = buildEopfMultiscalesIndex(
src.replace(/\/+$/, ""),
consolidated
);
if (eopf) {
return eopf;
}
}

// multiscales attr directly on this group: redirect to the finest level,
// resolving the (root-relative) asset path against the Zarr root so the
// path is not doubled.
if (groupAttrs?.multiscales) {
const ms = groupAttrs.multiscales as {
layout: Array<{ asset: string; [key: string]: unknown }>;
};
if (ms.layout?.length > 0) {
const baseUrl = src.replace(/\/$/, "");
const finestAsset = ms.layout[0].asset;
const levelUrl = baseUrl + "/" + finestAsset;
// Recursively index the subgroup (it's a regular zarr group)
const baseUrl = zarrRoot(src);
const levelUrl = baseUrl + "/" + ms.layout[0].asset.replace(/^\/+/, "");
const index = await indexFromZarr(levelUrl);
index.multiscales = { baseUrl, layout: ms.layout };
return index;
Expand Down
55 changes: 40 additions & 15 deletions src/ui/grids/Healpix.vue
Original file line number Diff line number Diff line change
Expand Up @@ -277,31 +277,51 @@ function fetchGrid() {
}
}

async function getHealpixCRSInfo() {
const crs = await ZarrDataManager.getCRSInfo(
activeDatasources.value!,
varnameSelector.value
);
// FIXME: could probably have other names
const nside = crs.attrs["healpix_nside"] as number;
// Check DGGS convention for ellipsoid, then CRS attrs, then cell coordinate attrs
let ellipsoid: string | undefined;
// Read nside (from dggs.refinement_level) and ellipsoid from the group's `dggs`.
async function readDggsCrsInfo(): Promise<{
nside?: number;
ellipsoid?: string;
}> {
try {
const source = ZarrDataManager.getDatasetSource(
activeDatasources.value!,
varnameSelector.value
);
const group = await ZarrDataManager.getDatasetGroup(source);
const dggs = group.attrs?.dggs as Record<string, unknown> | undefined;
if (dggs?.ellipsoid) {
const ell = dggs.ellipsoid as Record<string, unknown>;
ellipsoid = (ell.name as string)?.toUpperCase() || undefined;
if (!dggs) {
return {};
}
const nside =
typeof dggs.refinement_level === "number"
? 2 ** dggs.refinement_level
: undefined;
const ell = dggs.ellipsoid as Record<string, unknown> | undefined;
const ellipsoid = ell?.name
? (ell.name as string).toUpperCase()
: undefined;
return { nside, ellipsoid };
} catch {
// Fall through
return {};
}
if (!ellipsoid) {
ellipsoid = (crs.attrs["healpix_ellipsoid"] as string) || undefined;
}

async function getHealpixCRSInfo() {
// DGGS convention first (e.g. EOPF): dggs.refinement_level -> nside.
const dggsInfo = await readDggsCrsInfo();
let nside: number | undefined = dggsInfo.nside;
let ellipsoid: string | undefined = dggsInfo.ellipsoid;

// CF crs-variable convention (grid_mapping_name=healpix, healpix_nside)
if (nside === undefined) {
const crs = await ZarrDataManager.getCRSInfo(
activeDatasources.value!,
varnameSelector.value
);
nside = crs.attrs["healpix_nside"] as number;
if (!ellipsoid) {
ellipsoid = (crs.attrs["healpix_ellipsoid"] as string) || undefined;
}
}
if (!ellipsoid) {
try {
Expand All @@ -325,6 +345,11 @@ async function getHealpixCRSInfo() {
console.log("Could not read cell coordinate ellipsoid:", e);
}
}
if (nside === undefined) {
throw new Error(
"Could not determine HEALPix nside (no dggs or crs metadata)"
);
}
console.log("HEALPix CRS info: nside=", nside, "ellipsoid=", ellipsoid);
return { nside, ellipsoid };
}
Expand Down
8 changes: 0 additions & 8 deletions src/views/HashGlobeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ref, onBeforeMount, type Ref } from "vue";

import GlobeView from "./GlobeView.vue";

import { setAuthToken } from "@/lib/data/authStore";
import { GRID_TYPES, type T_GRID_TYPES } from "@/lib/data/gridTypeDetector";
import { STORE_PARAM_MAPPING, useUrlParameterStore } from "@/store/paramStore";
import { useGlobeControlStore } from "@/store/store";
Expand Down Expand Up @@ -36,13 +35,6 @@ const onHashChange = () => {

params.value = Object.fromEntries(new URLSearchParams(paramString));

// Set auth token for authenticated Zarr stores (e.g. EGI DataHub)
if (params.value.token) {
setAuthToken(params.value.token as string);
} else {
setAuthToken(null);
}

if (
params.value.boundlow !== undefined &&
params.value.boundhigh !== undefined
Expand Down
Loading