terriajs
Version:
Geospatial data visualization platform.
500 lines (437 loc) • 17 kB
text/typescript
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 */
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.
*/
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;
}