terriajs
Version:
Geospatial data visualization platform.
270 lines (247 loc) • 8.27 kB
text/typescript
import i18next from "i18next";
import { action } from "mobx";
import loadCsv from "../../Core/loadCsv";
import loadJson5 from "../../Core/loadJson5";
import { SearchParameterTraits } from "../../Traits/TraitsClasses/SearchableItemTraits";
import ItemSearchProvider, {
ItemSearchParameter,
ItemSearchResult
} from "./ItemSearchProvider";
import { Index, IndexRoot, IndexType, parseIndexRoot } from "./Index";
import joinUrl from "./joinUrl";
const t = i18next.t.bind(i18next);
type Parameter = ItemSearchParameter & { index: Index };
/**
* An ItemSearchProvider for searching an item using a pre-generated static index set.
*
*/
export default class IndexedItemSearchProvider extends ItemSearchProvider {
indexRootUrl: string;
indexRoot?: IndexRoot;
resultsData?: Promise<Record<string, string>[]>;
/**
* Construct a IndexedItemSearchProvider.
*
* Throws an error if indexRootUrl option is not defined.
*
* @param options An object containing {indexRootUrl: string}
*/
constructor(options: any, parameterOptions: SearchParameterTraits[]) {
super(options, parameterOptions);
const indexRootUrl = options?.indexRootUrl;
if (typeof indexRootUrl !== "string")
throw new Error(t("indexedItemSearchProvider.missingOptionIndexRootUrl"));
this.indexRootUrl = indexRootUrl;
}
/**
* Returns a Map of searchable parameters indexed by the parameter id.
*/
get parameters(): Map<string, Parameter> {
const indexes = this.indexRoot?.indexes;
if (!indexes) return new Map();
return new Map(
Object.entries(indexes).map(([propertyId, index]) => [
propertyId,
{ ...this.buildParameterForIndex(propertyId, index), index }
])
);
}
/**
* Returns a parameter object for the specified property.
*
* @param propertyId ID of the property
* @param index The index definition of the property
* @return The parameter object
*/
private buildParameterForIndex(
propertyId: string,
index: Index
): ItemSearchParameter {
const parameterOptions = this.parameterOptions.find(
({ id }) => id === propertyId
);
switch (index.type) {
case IndexType.numeric:
return {
type: "numeric",
id: propertyId,
name: parameterOptions?.name ?? propertyId,
range: index.range
};
case IndexType.enum:
return {
type: "enum",
id: propertyId,
name: parameterOptions?.name ?? propertyId,
values: Object.entries(index.values).map(([id, { count }]) => ({
id,
count
}))
};
case IndexType.text:
return {
type: "text",
id: propertyId,
name: parameterOptions?.name ?? propertyId,
queryOptions: parameterOptions?.queryOptions
};
}
}
/**
* Fetches & parses the indexRoot file and then triggers fetching of the data
* file but does not wait for it to complete.
*/
async initialize() {
const indexRootUrl = this.indexRootUrl;
const json = await loadJson5(indexRootUrl);
try {
this.indexRoot = parseIndexRoot(json);
this.getOrLoadResultsData();
} catch (parseError) {
console.warn(parseError);
throw new Error(
t("indexedItemSearchProvider.errorParsingIndexRoot", { indexRootUrl })
);
}
}
getIdPropertyName(): string {
const idPropertyName = this.indexRoot?.idProperty;
if (!idPropertyName) throw new Error(`indexRoot is not loaded`);
return idPropertyName;
}
/**
* @returns A promise that resolves the search parameters.
*/
async describeParameters(): Promise<ItemSearchParameter[]> {
return [...this.parameters.values()].map(({ index, ...rest }) => rest);
}
/**
* Pre-emptively load the index while the user is entering input for the
* parameter.
*/
loadParameterHint = (parameterId: string, valueHint: any) => {
const parameter = this.parameters.get(parameterId);
if (parameter) {
parameter.index.load(this.indexRootUrl, valueHint);
}
};
/**
* Search the indexes for all the given parameter values and return the results.
*
* @param parameterValues A Map from parameterId to a search query
* @returns A promise that resolves to the search results.
*/
async search(parameterValues: Map<string, any>): Promise<ItemSearchResult[]> {
// This is roughly what happens in this function
// 1) For each parameter search the corresponding index
// 2) This gives a set of matching IDs
// 3) Lookup the data corresponding to each matching ID from resultsData
// 4) Use the data to build the search results.
// Iterate each parameter and search its index.
const search = Promise.all(
Array.from(parameterValues).map(async ([parameterId, value]) => {
const parameter = this.parameters.get(parameterId);
if (!parameter) throw new Error(`Unexpected parameter ${parameterId}`);
const ids = await this.searchParameter(parameter, value);
return ids;
})
);
// Meanwhile, load resultsData
const data = await this.getOrLoadResultsData();
// Merge the IDs from the search into a single set
const idSets = await search;
const matchingIds = intersectSets(idSets);
// Map the IDs to data and build the search result.
const results = [...matchingIds].map((id) =>
this.buildResult(this.lookupDataForId(data, id), id)
);
return results;
}
searchParameter(parameter: Parameter, value: any): Promise<Set<number>> {
const index = parameter.index;
return index
.load(this.indexRootUrl, value)
.then(() => index.search(value, parameter.queryOptions));
}
/**
* Fetch resultsData URL and return it.
*
* Caches the data so that subsequent calls do not result in a network request & parsing.
*
* @return A promise that resolves to resultsData
*/
async getOrLoadResultsData() {
if (this.resultsData) {
return this.resultsData;
}
if (!this.indexRoot?.resultsDataUrl) {
throw new Error(`indexRoot is not loaded`);
}
const resultsDataUrl = joinUrl(
this.indexRootUrl,
this.indexRoot.resultsDataUrl
);
const promise = loadCsv(resultsDataUrl, {
dynamicTyping: true,
header: true
});
this.resultsData = promise;
return promise;
}
lookupDataForId(
rows: Record<string, string>[],
id: number
): Record<string, string> {
const row = rows[id];
if (!row) throw new Error(`No data record found for item id: ${id}`);
return row;
}
/**
* Build search result from raw data.
*
* @param record The resultData for the result item
* @param dataIdx The index of the record in resultsData
*/
buildResult(
record: Record<string, string>,
dataIdx: number
): ItemSearchResult {
if (!this.indexRoot) throw new Error(`indexRoot is not loaded`);
const id = record[this.indexRoot.idProperty];
if (id === undefined) {
throw new Error(
`ID property not defined for data record at index ${dataIdx}`
);
}
// The record can have a bunch of arbitrary properties and a few known
// properties. We use the latitude, longitude, height & radius for
// constructing a zoom target for the search result.
let { latitude, longitude, height, ...properties } = record;
const _latitude = parseFloat(latitude);
const _longitude = parseFloat(longitude);
const _featureHeight = parseFloat(height);
if (isNaN(_latitude) || isNaN(_longitude) || isNaN(_featureHeight)) {
throw new Error(
`No valid zoom point defined for data record at index ${dataIdx}`
);
}
const featureCoordinate = {
latitudeDegrees: _latitude,
longitudeDegrees: _longitude,
featureHeight: _featureHeight
};
return {
id,
idPropertyName: this.indexRoot.idProperty,
featureCoordinate,
properties
};
}
}
export function intersectSets<T>(sets: Set<T>[]): Set<T> {
if (sets.length === 0) return new Set();
return sets
.sort((a, b) => a.size - b.size)
.reduce((a, b) => new Set([...a].filter((x) => b.has(x))));
}