UNPKG

terriajs

Version:

Geospatial data visualization platform.

500 lines (437 loc) 17 kB
import { action, observable, runInAction, makeObservable } from "mobx"; import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError"; import CorsProxy from "../../Core/CorsProxy"; import isDefined from "../../Core/isDefined"; import loadJson from "../../Core/loadJson"; /* Encapsulates one entry in regionMapping.json Responsibilities: - communicate with MVT server - provide region IDs for a given region type - determine whether a given column name matches - identify region and disambiguation columns - provide a lookup function for a given column of data */ type ReplacementVar = | "dataReplacements" | "serverReplacements" | "disambigDataReplacements" | "disambigServerReplacements"; export interface RegionProvierOptions { /** * Feature attribute whose value will correspond to each region's code. */ regionProp: string; /** * Feature attribute whose value can be used as a user-facing name for the region. If this property is undefined, the regions * do not have names. */ nameProp: string; /** * A text description of this region type, which may feature in the user interface. */ description: string; /** * Name of the MVT layer where these regions are found. */ layerName: string; /** * URL of the MVT server */ server: string; /** * List of subdomains for requests to be sent to (only defined for MVT providers) */ serverSubdomains: string[] | undefined; /** * Minimum zoom which the server serves tiles at */ serverMinZoom: number; /** * Maximum zoom which the maximum native zoom tiles can be rendered at */ serverMaxZoom: number; /** * Maximum zoom which the server serves tiles at */ serverMaxNativeZoom: number; /** * Bounding box of vector geometry [w,s,e,n] (only defined for MVT providers) */ bbox: number[] | undefined; /** * List of aliases which will be matched against if found as column headings. */ aliases: string[]; /** * Array of [regex, replacement] arrays which will be applied to each ID element on the server side before matching * is attempted. For example, [ [ ' \(.\)$' ], '' ] will convert 'Baw Baw (S)' to 'Baw Baw' */ serverReplacements: [string, string][]; /** * Array of [regex, replacement] arrays which will be applied to each user-provided ID element before matching * is attempted. For example, [ [ ' \(.\)$' ], '' ] will convert 'Baw Baw (S)' to 'Baw Baw' */ dataReplacements: [string, string][]; /** The property within the same WFS region that can be used for disambiguation. */ disambigProp: string | undefined; /** * Returns the name of a field which uniquely identifies each region. This field is not necessarily used for matching, or * of interest to the user, but is needed for reverse lookups. This field must count from zero, and features must be * returned in sorted order. */ uniqueIdProp: string; /** * Whether this region type uses text codes, rather than numeric. It matters because numeric codes are treated differently by the * CSV handling models. */ textCodes: boolean; /** * The URL of a pre-generated JSON file containing just a long list of IDs for a given * layer attribute, in the order of ascending feature IDs (fids). If defined, it will * be used in preference to requesting those attributes from the WFS server. */ regionIdsFile: string; /** * JSON file for disambiguation attribute, as per regionIdsFile. */ regionDisambigIdsFile: string; } interface Region { fid?: number; regionProp?: string | number | undefined; regionPropWithServerReplacement?: string | number | undefined; disambigProp?: string | number | undefined; disambigPropWithServerReplacement?: string | number | undefined; } interface RegionIndex { [key: string]: number | number[]; } export default class RegionProvider { readonly corsProxy: CorsProxy; readonly regionType: string; readonly regionProp: string; readonly nameProp: string; readonly description: string; readonly layerName: string; readonly server: string; readonly serverSubdomains: string[] | undefined; readonly serverMinZoom: number; readonly serverMaxZoom: number; readonly serverMaxNativeZoom: number; readonly bbox: number[] | undefined; readonly aliases: string[]; readonly serverReplacements: [string, string, RegExp][]; readonly dataReplacements: [string, string, RegExp][]; readonly disambigProp: string | undefined; readonly uniqueIdProp: string; readonly textCodes: boolean; readonly regionIdsFile: string; readonly regionDisambigIdsFile: string; private disambigDataReplacements: [string, string, RegExp][] | undefined; private disambigServerReplacements: [string, string, RegExp][] | undefined; private disambigAliases: string[] | undefined; private _appliedReplacements = { serverReplacements: {} as any, disambigServerReplacements: {} as any, dataReplacements: {} as any, disambigDataReplacements: {} as any }; /** * Array of attributes of each region, once retrieved from the server. */ private _regions: Region[] = []; get regions() { return this._regions; } /** * Look-up table of attributes, for speed. */ private _idIndex: RegionIndex = {}; /** Cache the loadRegionID promises so they are not regenerated each time until this._regions is defined. */ private _loadRegionIDsPromises: Promise<any>[] | undefined = undefined; /** Flag to indicate if loadRegionID has finished */ @observable private _loaded = false; get loaded() { return this._loaded; } constructor( regionType: string, properties: RegionProvierOptions, corsProxy: CorsProxy ) { makeObservable(this); this.regionType = regionType; this.corsProxy = corsProxy; this.regionProp = properties.regionProp; this.nameProp = properties.nameProp; this.description = properties.description; this.layerName = properties.layerName; this.server = properties.server; this.serverSubdomains = properties.serverSubdomains; this.serverMinZoom = properties.serverMinZoom ?? 0; this.serverMaxZoom = properties.serverMaxZoom ?? Infinity; this.serverMaxNativeZoom = properties.serverMaxNativeZoom ?? this.serverMaxZoom; this.bbox = properties.bbox; this.aliases = properties.aliases ?? [this.regionType]; this.serverReplacements = properties.serverReplacements instanceof Array ? properties.serverReplacements.map(function (r) { return [ r[0], r[1].toLowerCase(), new RegExp(r[0].toLowerCase(), "gi") ]; }) : []; this.dataReplacements = properties.dataReplacements instanceof Array ? properties.dataReplacements.map(function (r) { return [ r[0], r[1].toLowerCase(), new RegExp(r[0].toLowerCase(), "gi") ]; }) : []; this.disambigProp = properties.disambigProp; this.uniqueIdProp = properties.uniqueIdProp ?? "FID"; this.textCodes = properties.textCodes ?? false; // yes, it's singular... this.regionIdsFile = properties.regionIdsFile; this.regionDisambigIdsFile = properties.regionDisambigIdsFile; } setDisambigProperties(dp: RegionProvider | undefined) { this.disambigDataReplacements = dp?.dataReplacements; this.disambigServerReplacements = dp?.serverReplacements; this.disambigAliases = dp?.aliases; } /** * Given an entry from the region mapping config, load the IDs that correspond to it, and possibly the disambiguation properties. */ @action async loadRegionIDs() { try { if (this._regions.length > 0) { return; // already loaded, so return insta-promise. } if (this.server === undefined) { // technically this may not be a problem yet, but it will be when we want to actually fetch tiles. throw new DeveloperError( "No server for region mapping defined: " + this.regionType ); } // Check for a pre-calculated promise (which may not have resolved yet), and returned that if it exists. if (!isDefined(this._loadRegionIDsPromises)) { const fetchAndProcess = async ( idListFile: string, disambig: boolean ) => { if (!isDefined(idListFile)) { return; } this.processRegionIds((await loadJson(idListFile)).values, disambig); }; this._loadRegionIDsPromises = [ fetchAndProcess(this.regionIdsFile, false), fetchAndProcess(this.regionDisambigIdsFile, true) ]; } await Promise.all(this._loadRegionIDsPromises); } catch (_e) { console.log(`Failed to load region IDS for ${this.regionType}`); } finally { runInAction(() => (this._loaded = true)); } } /** * Returns the region variable of the given name, matching against the aliases provided. * * @param {string[]} varNames Array of variable names. * @returns {string} The name of the first column that matches any of the given aliases. */ findRegionVariable(varNames: string[]) { return findVariableForAliases(varNames, [this.regionType, ...this.aliases]); } /** * If a disambiguation column is known for this provider, return a column matching its description. * * @param {string[]} varNames Array of variable names. * @returns {string} The name of the first column that matches any of the given disambiguation aliases. */ findDisambigVariable(varNames: string[]) { if (!isDefined(this.disambigAliases) || this.disambigAliases.length === 0) { return undefined; } return findVariableForAliases(varNames, this.disambigAliases); } /** * Given a list of region IDs in feature ID order, apply server replacements if needed, and build the this._regions array. * If no propertyName is supplied, also builds this._idIndex (a lookup by attribute for performance). * @param {Array} values An array of string or numeric region IDs, eg. [10050, 10110, 10150, ...] or ['2060', '2061', '2062', ...] * @param {boolean} disambig True if processing region IDs for disambiguation */ processRegionIds(values: number[] | string[], disambig: boolean) { // There is also generally a `layer` and `property` property in this file, which we ignore for now. values.forEach((value: string | number | undefined, index: number) => { if (!isDefined(this._regions[index])) { this._regions[index] = {}; } let valueAfterReplacement = value; if (typeof valueAfterReplacement === "string") { // we apply server-side replacements while loading. If it ever turns out we need // to store the un-regexed version, we should add a line here. valueAfterReplacement = this.applyReplacements( valueAfterReplacement.toLowerCase(), disambig ? "disambigServerReplacements" : "serverReplacements" ); } // If disambig IDS - only set this._regions properties - not this._index properties if (disambig) { this._regions[index].disambigProp = value; this._regions[index].disambigPropWithServerReplacement = valueAfterReplacement; } else { this._regions[index].regionProp = value; this._regions[index].regionPropWithServerReplacement = valueAfterReplacement; // store a lookup by attribute, for performance. // This is only used for region prop (not disambig prop) if (isDefined(value) && isDefined(valueAfterReplacement)) { // If value is different after replacement, then also add original value for _index if (value !== valueAfterReplacement) { this._idIndex[value] = index; } if (!isDefined(this._idIndex[valueAfterReplacement])) { this._idIndex[valueAfterReplacement] = index; } else { // if we have already seen this value before, store an array of values, not one value. if (Array.isArray(this._idIndex[valueAfterReplacement])) { (this._idIndex[valueAfterReplacement] as number[]).push(index); } else { this._idIndex[valueAfterReplacement] = [ this._idIndex[valueAfterReplacement] as number, index ]; } } // Here we make a big assumption that every region has a unique identifier (probably called FID), that it counts from zero, // and that regions are provided in sorted order from FID 0. We do this to avoid having to explicitly request // the FID column, which would double the amount of traffic per region dataset. // It is needed to simplify reverse lookups from complex matches (regexes and disambigs) this._regions[index].fid = index; } } }); } /** * Apply an array of regular expression replacements to a string. Also caches the applied replacements in regionProvider._appliedReplacements. * @param {String} s The string. * @param {String} replacementsProp Name of a property containing [ [ regex, replacement], ... ], where replacement is a string which can contain '$1' etc. */ applyReplacements( s: string | number, replacementsProp: ReplacementVar ): string { let r: string; if (typeof s === "number") { r = String(s); } else { r = s.toLowerCase().trim(); } const replacements = this[replacementsProp]; if (replacements === undefined || replacements.length === 0) { return r; } if (this._appliedReplacements[replacementsProp][r] !== undefined) { return this._appliedReplacements[replacementsProp][r]; } replacements.forEach(function (rep: any) { r = r.replace(rep[2], rep[1]); }); this._appliedReplacements[replacementsProp][s] = r; return r; } /** * Given a region code, try to find a region that matches it, using replacements, disambiguation, indexes and other wizardry. * @param {string | number} code Code to search for. Falsy codes return -1. * @param {string | number | undefined} disambigCode Code to use if disambiguation is necessary * @returns {Number} Zero-based index in list of regions if successful, or -1. */ findRegionIndex( code: string | number, disambigCode: string | number | undefined ): number { if (!isDefined(code) || code === "") { // Note a code of 0 is ok return -1; } const codeAfterReplacement = this.applyReplacements( code, "dataReplacements" ); const id = this._idIndex[code]; const idAfterReplacement = this._idIndex[codeAfterReplacement]; if (!isDefined(id) && !isDefined(idAfterReplacement)) { return -1; } if (typeof id === "number") { // found an unambiguous match (without replacement) return id; } else if (typeof idAfterReplacement === "number") { // found an unambiguous match (with replacement) return idAfterReplacement; } else { const ids = id ?? idAfterReplacement; // found an ambiguous match if (!isDefined(disambigCode)) { // we have an ambiguous value, but nothing with which to disambiguate. We pick the first, warn. console.warn( "Ambiguous value found in region mapping: " + (codeAfterReplacement || code) ); return ids[0]; } if (this.disambigProp) { const processedDisambigCode = this.applyReplacements( disambigCode, "disambigDataReplacements" ); // Check out each of the matching IDs to see if the disambiguation field matches the one we have. for (let i = 0; i < ids.length; i++) { if ( this._regions[ids[i]].disambigProp === processedDisambigCode || this._regions[ids[i]].disambigPropWithServerReplacement === processedDisambigCode ) { return ids[i]; } } } } return -1; } } function findVariableForAliases(varNames: string[], aliases: string[]) { // Try first with no transformation (but case-insensitive) for (let j = 0; j < aliases.length; j++) { const re = new RegExp("^" + aliases[j] + "$", "i"); for (let i = 0; i < varNames.length; i++) { if (re.test(varNames[i])) { return varNames[i]; } } } // Now try without whitespace, hyphens and underscores for (let j = 0; j < aliases.length; j++) { const aliasNoWhiteSpace = aliases[j].replace(/[-_\s]/g, ""); const re = new RegExp("^" + aliasNoWhiteSpace + "$", "i"); for (let i = 0; i < varNames.length; i++) { const varNameNoWhiteSpace = varNames[i].replace(/[-_\s]/g, ""); if (re.test(varNameNoWhiteSpace)) { return varNames[i]; } } } return undefined; }