Skip to content
Merged
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
22 changes: 22 additions & 0 deletions ui-react/src/graphql/generated/modeling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,25 @@ export type Thread = {
response_variable_id?: Maybe<string>;
driving_variable?: Maybe<VariableRef>;
response_variable?: Maybe<VariableRef>;
/**
* The thread's region, with the geometries the Datasets step narrows on.
* `region_id` alone cannot do that job — it names the region without saying
* where it is.
*/
region?: Maybe<ThreadRegion>;
events: ThreadProvenance[];
permissions: ThreadPermission[];
thread_models?: ThreadModel[];
};

export type ThreadRegion = {
__typename?: 'region';
id: string;
name?: Maybe<string>;
/** Hasura returns the jsonb `geometry` column already parsed. */
geometries: Array<{ id: number; geometry?: Maybe<unknown> }>;
};

export type Task = {
__typename?: 'task';
id: string;
Expand Down Expand Up @@ -290,6 +304,14 @@ const THREAD_INFO = gql`
id
name
}
region {
id
name
geometries {
id
geometry
}
}
thread_models {
id
thread_id
Expand Down
136 changes: 134 additions & 2 deletions ui-react/src/lib/__tests__/data-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,92 @@ const PROSE_MATCH: CkanPackage = {
resources: [{ id: 'r4', format: 'pdf', mint_standard_variables: '' }],
};

// ─── Region fixtures (issue #97) ──────────────────────────────────────────────
//
// Every geometry shape below is one TACC actually holds in `spatial`. The old
// extractor read `coordinates[0]` and understood a bare Polygon only, so a
// client-side region filter built on it would have dropped the rest in silence.

/** Texas, near enough — the bounding box measured off the region's geometry. */
const TEXAS = { xmin: -106.64, xmax: -93.52, ymin: 25.84, ymax: 36.5 };

const TEXAS_POLYGON = JSON.stringify({
type: 'Polygon',
coordinates: [
[
[-106.64, 25.84],
[-93.52, 25.84],
[-93.52, 36.5],
[-106.64, 36.5],
[-106.64, 25.84],
],
],
});

function annotated(name: string, spatial?: string): CkanPackage {
return {
id: `uuid-${name}`,
name,
title: name,
...(spatial ? { spatial } : {}),
resources: [{ id: `r-${name}`, format: 'csv', mint_standard_variables: 'a' }],
};
}

const IN_TEXAS = annotated('in-texas', TEXAS_POLYGON);
const IN_ALASKA = annotated(
'in-alaska',
JSON.stringify({
type: 'Polygon',
coordinates: [
[
[-150, 60],
[-149, 60],
[-149, 61],
[-150, 61],
[-150, 60],
],
],
}),
);
const NO_LOCATION = annotated('no-location');
const FEATURE_IN_TEXAS = annotated(
'feature-in-texas',
JSON.stringify({
type: 'Feature',
properties: {},
geometry: JSON.parse(TEXAS_POLYGON) as unknown,
}),
);
const FEATURE_COLLECTION_IN_TEXAS = annotated(
'feature-collection-in-texas',
JSON.stringify({
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: {}, geometry: JSON.parse(TEXAS_POLYGON) as unknown }],
}),
);
const POINT_IN_TEXAS = annotated(
'point-in-texas',
JSON.stringify({ type: 'Point', coordinates: [-97.74, 30.27] }),
);
const MULTI_IN_ALASKA = annotated(
'multipolygon-in-alaska',
JSON.stringify({
type: 'MultiPolygon',
coordinates: [
[
[
[-150, 60],
[-149, 60],
[-149, 61],
[-150, 61],
[-150, 60],
],
],
],
}),
);

beforeEach(() => {
searchRequests = [];
window.__MINT_CONFIG__ = { DATA_CATALOG_API: CKAN_HOST } as never;
Expand Down Expand Up @@ -120,13 +206,13 @@ describe('findDatasets', () => {
expect(found).toHaveLength(1);
});

it('keeps the bounding box on the request', async () => {
it('never sends ext_bbox: the region is applied here, so nothing is dropped for it', async () => {
stubSearch([]);
await findDatasets({
standard_variable_names__in: ['a'],
spatial_coverage__intersects: { xmin: -100, xmax: -97, ymin: 29, ymax: 31 },
});
expect(searchRequests[0]?.searchParams.get('ext_bbox')).toBe('-100,29,-97,31');
expect(searchRequests[0]?.searchParams.has('ext_bbox')).toBe(false);
});

it('still narrows by date range', async () => {
Expand All @@ -148,6 +234,52 @@ describe('findDatasets', () => {
expect(found).toHaveLength(2);
});

it('labels a dataset inside the region, and one outside it, without dropping either', async () => {
stubSearch([IN_TEXAS, IN_ALASKA]);
const found = await findDatasets({ spatial_coverage__intersects: TEXAS });
expect(found.map((d) => [d.id, d.region_match])).toEqual([
[IN_TEXAS.name, 'inside'],
[IN_ALASKA.name, 'outside'],
]);
});

it('labels a dataset with no spatial field unknown, not outside', async () => {
// The defect this guards: ext_bbox filtered on *having* a location, so
// these never reached the client — 11 of TACC's 33 annotated packages.
stubSearch([NO_LOCATION]);
const found = await findDatasets({ spatial_coverage__intersects: TEXAS });
expect(found).toHaveLength(1);
expect(found[0]?.region_match).toBe('unknown');
});

it('places a Feature and a FeatureCollection, which ext_bbox drops entirely', async () => {
stubSearch([FEATURE_IN_TEXAS, FEATURE_COLLECTION_IN_TEXAS, POINT_IN_TEXAS, MULTI_IN_ALASKA]);
const found = await findDatasets({ spatial_coverage__intersects: TEXAS });
expect(found.map((d) => d.region_match)).toEqual(['inside', 'inside', 'inside', 'outside']);
});

it('calls everything inside when no region is asked for', async () => {
stubSearch([IN_TEXAS, IN_ALASKA, NO_LOCATION]);
const found = await findDatasets({});
expect(found.every((d) => d.region_match === 'inside')).toBe(true);
});

it('accepts a regions list of geometries as the region', async () => {
stubSearch([IN_TEXAS, IN_ALASKA]);
const found = await findDatasets({
spatial_coverage__intersects: [JSON.parse(TEXAS_POLYGON) as unknown],
});
expect(found.map((d) => d.region_match)).toEqual(['inside', 'outside']);
});

it('applies no region filter when the region carries no usable extent', async () => {
// An empty geometry list must not read as an empty box, which would call
// the whole catalog "elsewhere".
stubSearch([IN_TEXAS, IN_ALASKA]);
const found = await findDatasets({ spatial_coverage__intersects: [] });
expect(found.map((d) => d.region_match)).toEqual(['inside', 'inside']);
});

it('caps the datasets returned by limit, without capping what it fetches', async () => {
stubSearch([CARRIER, { ...CARRIER, id: 'uuid-3', name: 'second-carrier' }]);
const found = await findDatasets({
Expand Down
64 changes: 47 additions & 17 deletions ui-react/src/lib/data-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import {
cleanString,
overlapsDateRange,
packageRegionMatch,
packagesMatchingVariables,
packageTags,
packageTimePeriod,
Expand All @@ -23,7 +24,9 @@ import {
showPackage,
type CkanPackage,
type CkanResource,
type RegionMatch,
} from './datasets/ckan';
import { geoJsonBoundingBox, unionBoundingBox, type BoundingBox } from './geo/bbox';

// ─── Domain types ─────────────────────────────────────────────────────────────

Expand All @@ -50,6 +53,12 @@ export interface DataCatalogDataset {
id: string;
name: string;
region: string;
/**
* Where this dataset sits relative to the region asked for. `unknown` means
* the dataset declares no location at all — it is not a claim that the
* dataset is elsewhere, and the UI must not hide it as though it were.
*/
region_match: RegionMatch;
variables: string[];
datatype: string;
time_period: DataCatalogTimePeriod | null;
Expand Down Expand Up @@ -84,13 +93,18 @@ export interface DatasetQueryParams {

// ─── Internal shape helpers ───────────────────────────────────────────────────

function datasetFromCkanPackage(pkg: CkanPackage, variables: string[]): DataCatalogDataset {
function datasetFromCkanPackage(
pkg: CkanPackage,
variables: string[],
regionMatch: RegionMatch,
): DataCatalogDataset {
const resources = pkg.resources ?? [];
return {
// Prefer the name slug: it is what CKAN URLs use and package_show accepts.
id: cleanString(pkg.name) || cleanString(pkg.id),
name: cleanString(pkg.title) || cleanString(pkg.name),
region: '',
region_match: regionMatch,
variables,
// CKAN has no datatype field; resource formats are the closest analogue.
datatype: cleanString(resources[0]?.format),
Expand Down Expand Up @@ -134,16 +148,21 @@ function resourceFromCkanResource(row: CkanResource, parent: CkanPackage): DataC
*
* The variable names are NOT sent as a CKAN `q`. They live on each resource in
* `mint_standard_variables`, which Solr neither indexes nor tokenises usefully,
* so a free-text query matches prose instead of the annotation. Fetch the
* bbox-filtered catalog and match the field here, as the legacy Lit client does.
* so a free-text query matches prose instead of the annotation. Fetch the whole
* catalog and match the field here, as the legacy Lit client does.
*
* The region is applied here too, and **nothing is dropped for it**. Every
* dataset comes back carrying `region_match`, so the caller can show what has
* no location and count what is elsewhere. `ext_bbox` cannot express that: it
* filters on *having* a location, so a dataset with no `spatial` field never
* reaches the client to be reasoned about — 11 of TACC's 33 annotated packages,
* for every region.
*/
export async function findDatasets(params: DatasetQueryParams): Promise<DataCatalogDataset[]> {
const variables = params.standard_variable_names__in ?? [];
const boundingBox = toBoundingBox(params.spatial_coverage__intersects);
const region = toBoundingBox(params.spatial_coverage__intersects);

const packages = await searchAllPackages({
...(boundingBox ? { boundingBox } : {}),
});
const packages = await searchAllPackages({});

// CKAN cannot filter reliably on temporal extras, so narrow the window here.
const start = params.end_time__lte ? new Date(params.end_time__lte) : null;
Expand All @@ -157,27 +176,38 @@ export async function findDatasets(params: DatasetQueryParams): Promise<DataCata
// `limit` caps the datasets handed back, not the pages fetched: the whole
// catalog has to be read before the variable filter can be applied.
const capped = params.limit ? matched.slice(0, params.limit) : matched;
return capped.map((pkg) => datasetFromCkanPackage(pkg, variables));
return capped.map((pkg) =>
datasetFromCkanPackage(pkg, variables, packageRegionMatch(pkg, region)),
);
}

/**
* Coax the legacy `spatial_coverage__intersects` payload into a bounding box.
* Callers pass either a bare {xmin,xmax,ymin,ymax} or the MINT
* `{ type: 'BoundingBox', value: {...} }` envelope; anything else is ignored.
* Coax the `spatial_coverage__intersects` payload into a bounding box.
*
* Callers pass a region's GeoJSON geometries, a bare {xmin,xmax,ymin,ymax}, or
* the MINT `{ type: 'BoundingBox', value: {...} }` envelope. Anything with no
* usable extent yields undefined, which means "no region filter" — never an
* empty box, which would classify the whole catalog as elsewhere.
*/
function toBoundingBox(
raw: unknown,
): { xmin: number; xmax: number; ymin: number; ymax: number } | undefined {
function toBoundingBox(raw: unknown): BoundingBox | undefined {
if (!raw || typeof raw !== 'object') return undefined;

// A region carries a list of geometries; take the box that encloses them all.
if (Array.isArray(raw)) return unionBoundingBox(raw) ?? undefined;

const obj = raw as Record<string, unknown>;
const candidate = (
obj['value'] && typeof obj['value'] === 'object' ? obj['value'] : obj
) as Record<string, unknown>;

const coords = ['xmin', 'xmax', 'ymin', 'ymax'].map((k) => Number(candidate[k]));
if (coords.some((n) => !isFinite(n))) return undefined;
const [xmin, xmax, ymin, ymax] = coords as [number, number, number, number];
return { xmin, xmax, ymin, ymax };
if (!coords.some((n) => !isFinite(n))) {
const [xmin, xmax, ymin, ymax] = coords as [number, number, number, number];
return { xmin, xmax, ymin, ymax };
}

// Not a bounding box — try to read it as GeoJSON.
return geoJsonBoundingBox(raw) ?? undefined;
}

/**
Expand Down
48 changes: 41 additions & 7 deletions ui-react/src/lib/datasets/ckan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
*/

import { getDataCatalogApiUrl } from '../config';
import { boundingBoxesOverlap, geoJsonBoundingBox, type BoundingBox } from '../geo/bbox';

import type { DateRange, SpatialCoverage } from './types';

Expand Down Expand Up @@ -78,13 +79,7 @@ interface CkanSearchResult {
results?: CkanPackage[];
}

/** Bounding box in the order CKAN's `ext_bbox` expects. */
export interface BoundingBox {
xmin: number;
xmax: number;
ymin: number;
ymax: number;
}
export type { BoundingBox };

// ─── Transport ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -245,6 +240,45 @@ export function packageSpatialCoverage(pkg: CkanPackage): SpatialCoverage | unde
}
}

/**
* Where a package sits relative to the region asked for.
*
* Three answers, not two: "no location declared" and "a location, elsewhere"
* are different claims about the data and the Datasets step treats them
* differently — the first is shown and badged, the second hidden and counted.
* Collapsing them is what made a third of TACC's annotated catalog invisible.
*/
export type RegionMatch = 'inside' | 'outside' | 'unknown';

/**
* A package's bounding box, from ckanext-spatial's stringified GeoJSON.
*
* Handles every shape TACC actually holds — Polygon, MultiPolygon, Point,
* Feature and FeatureCollection — unlike `packageSpatialCoverage`, which reads
* `coordinates[0]` and understands a bare Polygon only.
*/
export function packageBoundingBox(pkg: CkanPackage): BoundingBox | null {
return geoJsonBoundingBox(pkg.spatial);
}

/**
* Classify a package against a region's bounding box.
*
* With no region asked for, everything is `inside`: there is no claim to fail.
*
* This is deliberately more forgiving than CKAN's own `ext_bbox`, which is not
* merely a server-side version of the same test. ckanext-spatial indexes bare
* geometries only, so `ext_bbox` also drops packages whose `spatial` is a
* `Feature` or a `FeatureCollection` — at TACC, two annotated packages that are
* squarely inside Texas.
*/
export function packageRegionMatch(pkg: CkanPackage, region?: BoundingBox | null): RegionMatch {
if (!region) return 'inside';
const box = packageBoundingBox(pkg);
if (!box) return 'unknown';
return boundingBoxesOverlap(box, region) ? 'inside' : 'outside';
}

/** CKAN's `version` is often the literal string "None". */
export function cleanString(val: unknown): string {
if (val === undefined || val === null || val === 'None') return '';
Expand Down
Loading
Loading