UNPKG

@seasketch/geoprocessing

Version:

Geoprocessing and reporting framework for SeaSketch 2.0

720 lines (654 loc) • 21.9 kB
import { Sketch, SketchCollection, NullSketch, NullSketchCollection, Metric, MetricPack, MetricDimension, MetricProperty, DataClass, MetricIdTypes, GroupMetricSketchAgg, MetricDimensions, SketchProperties, } from "../types/index.js"; import { groupBy, keyBy, isSketch, isSketchCollection, isNullSketch, isNullSketchCollection, hasOwnProperty, } from "../helpers/index.js"; import reduce from "lodash/reduce.js"; import cloneDeep from "lodash/cloneDeep.js"; /** Properties used in Metric */ export const MetricProperties = [ "geographyId", "metricId", "classId", "sketchId", "groupId", "value", "extra", ] as const; /** * Creates a new metric. Defaults to ID values of null and then copies in passed metric properties * @param metric - partial metric * @returns metric */ export const createMetric = (metric: Partial<Metric>): Metric => { return { metricId: "metric", value: 0, classId: null, groupId: null, geographyId: null, sketchId: null, ...cloneDeep(metric), }; }; /** * Creates fully defined metrics from partial. Metric values not provided are initialized to null * @param metric - partial metrics * @returns metrics */ export const createMetrics = (metrics: Partial<Metric>[]): Metric[] => metrics.map((m) => createMetric(m)); /** * Reorders metrics (by mutation) to a consistent key order for readability */ export const rekeyMetrics = ( metrics: Metric[], idOrder: MetricProperty[] = [...MetricProperties], ) => { return metrics.map((curMetric) => { const newMetric: Record<string, any> = {}; for (const id of idOrder) { if (hasOwnProperty(curMetric, id)) newMetric[id] = curMetric[id]; } return newMetric; }) as Metric[]; }; /** * Converts Metric array to a new MetricPack. * Assumes metric dimensions are consistent for each element in the array, and null values are used */ export const packMetrics = (inMetrics: Metric[]): MetricPack => { const metrics = cloneDeep(inMetrics); const pack: MetricPack = { dimensions: [], data: [] }; if (metrics.length === 0) return pack; const keys = Object.keys(metrics[0]).sort(); pack.dimensions = keys; const packData: MetricPack["data"] = []; // Pack data values, for-loop for speed for (let a = 0, ml = metrics.length; a < ml; ++a) { const curMetric = metrics[a]; const curRow: MetricPack["data"][0] = []; for (let b = 0, kl = keys.length; b < kl; ++b) { const curKey = keys[b]; curRow.push(curMetric[curKey]); } packData.push(curRow); } pack.data = packData; return pack; }; /** * Converts MetricPack to a new Metric array. * @param metricPack * @returns */ export const unpackMetrics = (inMetricPack: MetricPack): Metric[] => { const metricPack = cloneDeep(inMetricPack); const metrics: Metric[] = []; for (let a = 0, ml = metricPack.data.length; a < ml; ++a) { const curRow = metricPack.data[a]; const curMetric = createMetric({}); for (let b = 0, kl = metricPack.dimensions.length; b < kl; ++b) { const curDimension = metricPack.dimensions[b]; curMetric[curDimension] = curRow[b]; } metrics.push(curMetric); } return metrics; }; /** * Checks if object is a MetricPack. Any code inside a block guarded by a conditional call to this function will have type narrowed to MetricPack */ export const isMetricPack = (json: any): json is MetricPack => { return ( json && hasOwnProperty(json, "dimensions") && Array.isArray(json.dimensions) && hasOwnProperty(json, "data") && Array.isArray(json.data) ); }; /** * Checks if object is a Metric array and returns narrowed type */ export const isMetricArray = (metrics: any): metrics is Metric[] => { return ( metrics && Array.isArray(metrics) && metrics.length > 0 && isMetric(metrics[0]) ); }; /** * Checks if object is a Metric and returns narrowed type */ export const isMetric = (metric: any): metric is Metric => { return ( metric && MetricDimensions.reduce( (soFar, curDim) => soFar && hasOwnProperty(metric, curDim) && (metric[curDim] === null || !!metric[curDim]), true, ) && hasOwnProperty(metric, "value") ); }; /** * Sorts metrics to a consistent order for readability * Defaults to [metricId, classId, sketchId] */ export const sortMetrics = ( metrics: Metric[], sortIds: MetricDimension[] = [ "geographyId", "metricId", "classId", "sketchId", ], ) => { return metrics.sort((a, b) => { return sortIds.reduce((sortResult, idName) => { // if sort result alread found then skip if (sortResult !== 0) return sortResult; const aVal = a[idName]; const bVal = b[idName]; if (aVal && bVal) return aVal.localeCompare(bVal); return 0; }, 0); }); }; /** * Sorts metrics by ID given a user-defined metric dimension (sortId) and array of ID * values in the order they should be sorted * Useful for applying a "display order" to metrics * Example - sortId = classId, displayOrder = ['sand','gravel','coral'] * @param metrics * @param sortId * @param displayOrder * @returns new array of sorted metrics */ export const sortMetricsDisplayOrder = ( metrics: Metric[], sortId: MetricDimension = "classId", displayOrder: string[], ) => { return metrics.sort((a, b) => { const aVal = a[sortId]; const bVal = b[sortId]; if (!aVal || !bVal) return 0; const aOrder = displayOrder.indexOf(aVal); const bOrder = displayOrder.indexOf(bVal); if (aOrder >= 0 && bOrder >= 0) return aOrder - bOrder; return 0; }); }; /** * Returns new sketchMetrics array with first sketchMetric matched set with new value. * If no match, returns copy of sketchMetrics. Does not mutate array in place. */ export const findAndUpdateMetricValue = <T extends Metric>( sketchMetrics: T[], matcher: (sk: T) => boolean, value: number, ) => { const index = sketchMetrics.findIndex(matcher); if (index === -1) { return [...sketchMetrics]; } else { return [ ...sketchMetrics.slice(0, index), { ...sketchMetrics[index], value, }, ...sketchMetrics.slice(index + 1), ]; } }; /** * Returns the first metric that returns true for metricFilter * @returns */ export const firstMatchingMetric = <M extends Metric>( metrics: M[], metricFilter: (metric: M) => boolean, ) => { const metric = metrics.find((m) => metricFilter(m)); if (!metric) throw new Error(`firstMatchingMetrics: metric not found`); return metric; }; /** * Given sketch(es), returns ID(s) */ export const sketchToId = ( sketch: Sketch | NullSketch | Sketch[] | NullSketch[], ) => Array.isArray(sketch) ? sketch.map((sk) => sk.properties.id) : sketch.properties.id; /** * Returns metrics with matching sketchId (can be an array of sketchids) */ export const metricsSketchIds = <M extends Metric>(metrics: M[]) => Object.keys(groupBy(metrics, (m) => m.sketchId || "missing")); /** * Returns metrics with matching sketchId (can be an array of sketchids) */ export const metricsWithSketchId = <M extends Metric>( metrics: M[], sketchId: string | string[], ) => metrics.filter((m) => Array.isArray(sketchId) ? m.sketchId && sketchId.includes(m.sketchId) : sketchId === m.sketchId, ); /** * Returns metrics with matching sketchId (can be an array of sketchids) */ export const metricsWithClassId = <M extends Metric>( metrics: M[], classId: string | string[], ) => metrics.filter((m) => Array.isArray(classId) ? classId.includes(m.classId || "undefined") : classId === m.classId, ); /** * Returns metrics for given sketch (can be an array of sketches) */ export const metricsForSketch = <M extends Metric>( metrics: M[], sketch: Sketch | NullSketch | Sketch[] | NullSketch[], ) => metricsWithSketchId(metrics, sketchToId(sketch)); /** * Sort function to sort report data classes alphabetically by display name * @param a * @param b * @returns */ const classSortAlphaDisplay = (a: DataClass, b: DataClass) => { const aName = a.display; const bName = b.display; return aName.localeCompare(bName); }; /** * Matches numerator metrics with denominator metrics and divides their value, * returning a new array of percent metrics. * Matches on the optional idProperty given, otherwise defaulting to classId * Deep copies and maintains all other properties from the numerator metric * If denominator metric has value of 0, returns NaN * NaN allows downstream consumers to understand this isn't just any 0. * It's an opportunity to tell the user that no matter where they put their sketch, there is no way for the value to be more than zero. * For example, the ClassTable component looks for `NaN` metric values and will automatically display 0%, * along with an informative popover explaining that no data class features are within the current geography. * @param numerators array of metrics, to be used as numerators (often sketch metrics) * @param denominators array of metrics, to be used as denominators (often planning region metrics) * @param metricIdOverride optional metricId value to assign to outputted metrics * @param idProperty optional id property to match metric with total metric, defaults to classId * @returns Metric[] of percent values or NaN if denominator was 0 */ export const toPercentMetric = ( numerators: Metric[], denominators: Metric[], options: { metricIdOverride?: string; idProperty?: string; debug?: boolean; } = {}, ): Metric[] => { const { metricIdOverride, idProperty = "classId", debug = true } = options; // Index denominators into precalc totals using idProperty const totalsByKey = (() => { return keyBy(denominators, (total) => String(total[idProperty])); })(); // For each metric in metric group return numerators.map((numerMetric) => { if (!numerMetric || numerMetric.value === undefined) throw new Error( `Malformed numerator metric: ${JSON.stringify(numerMetric)}`, ); const idValue = numerMetric[idProperty]; if (idValue === null || idValue === undefined) throw new Error( `Invalid ${idProperty} found in numerator metric: ${JSON.stringify( numerMetric, )}`, ); const denomMetric = totalsByKey[idValue]; if (!denomMetric) { throw new Error( `Missing matching denominator metric with ${idProperty} of ${idValue} for numerator: ${JSON.stringify( numerMetric, )}`, ); } if (denomMetric.value === null || denomMetric.value === undefined) { throw new Error( `Malformed denominator metric: ${JSON.stringify(numerMetric)}`, ); } const value = (() => { // Catch 0 or malformed denominator value and return percent metric with 0 value if (denomMetric.value === 0) { if (debug) console.log( `Denominator metric with ${idProperty} of ${idValue} has 0 value, returning 0 percent metric`, ); return Number.NaN; } else { return numerMetric.value / denomMetric.value; } })(); // Create percent metric return { ...cloneDeep(numerMetric), value, ...(metricIdOverride ? { metricId: metricIdOverride } : {}), }; }); }; /** * Recursively groups metrics by ID in order of ids specified to create arbitrary nested hierarchy for fast lookup. * Caller responsible for all metrics having the ID properties defined * If an id property is not defined on a metric, then 'undefined' will be used for the key */ export const nestMetrics = ( metrics: Metric[], ids: MetricDimension[], ): Record<string, any> => { const grouped = groupBy(metrics, (curMetric) => curMetric[ids[0]]!); if (ids.length === 1) { return grouped; } return reduce( grouped, (result, groupMetrics, curId) => { return { ...result, [curId]: nestMetrics(groupMetrics, ids.slice(1)), }; }, {}, ); }; /** * Flattens class sketch metrics into array of objects, one for each sketch, * where each object contains sketch id, sketch name, and all metric values for each class * @param metrics List of metrics, expects one metric per sketch and class combination * @param classes Data classes represented in metrics * @param sketchProperties SketchProperties of sketches represented in metrics * @param sortFn Function to sort class configs using Array.sort (defaults to alphabetical by display name) * @returns An array of objects with flattened sketch metrics */ export const flattenBySketchAllClass = ( metrics: Metric[], classes: DataClass[], sketchProperties: SketchProperties[], sortFn?: (a: DataClass, b: DataClass) => number, ): Record<string, string | number>[] => { const metricsByClassId = groupBy( metrics, (metric) => metric.classId || "error", ); const sketchRows: Record<string, string | number>[] = []; for (const curSketchProperties of sketchProperties) { // For current sketch, transform classes into an object mapping classId to its one metric value const classMetricAgg = classes .sort(sortFn || classSortAlphaDisplay) .reduce<Record<string, number>>((aggSoFar, curClass) => { // Transform current class metrics into an object mapping each sketchId to its one class Metric const sketchMetricsById = metricsByClassId[curClass.classId].reduce< Record<string, Metric> >((soFar, sm) => { soFar[sm.sketchId || "undefined"] = sm; return soFar; }, {}); // Map current classId to extracted metric value aggSoFar[curClass.classId] = sketchMetricsById[curSketchProperties.id].value; return aggSoFar; }, {}); sketchRows.push({ sketchId: curSketchProperties.id, sketchName: curSketchProperties.name, ...classMetricAgg, }); } return sketchRows; }; /** * Aggregates metrics by group * @param collection sketch collection metrics are for * @param groupMetrics metrics with assigned groupId (except group total metric) and sketchIds for collection * @param totalMetrics totals by class * @returns one aggregate object for every groupId present in metrics. Each object includes: * [numSketches] - count of child sketches in the group * [classId] - a percValue for each classId present in metrics for group * [value] - sum of value across all classIds present in metrics for group * [percValue] - given sum value across all classIds, contains ratio of total sum across all class IDs */ export const flattenByGroupAllClass = ( collection: SketchCollection | NullSketchCollection, groupMetrics: Metric[], totalMetrics: Metric[], ): { value: number; groupId: string; percValue: number; }[] => { // Stratify in order by Group -> Collection -> Class. Then flatten const metricsByGroup = groupBy(groupMetrics, (m) => m.groupId || "undefined"); return Object.keys(metricsByGroup).map((curGroupId) => { const collGroupMetrics = metricsByGroup[curGroupId].filter( (m) => m.sketchId === collection.properties.id && m.groupId === curGroupId, ); const collGroupMetricsByClass = keyBy( collGroupMetrics, (m) => m.classId || "undefined", ); const classAgg = Object.keys(collGroupMetricsByClass).reduce( (rowsSoFar, curClassId) => { const groupClassSketchMetrics = groupMetrics.filter( (m) => m.sketchId !== collection.properties.id && m.groupId === curGroupId && m.classId === curClassId, ); const curValue = collGroupMetricsByClass[curClassId]?.value; const classTotal = firstMatchingMetric( totalMetrics, (totalMetric) => totalMetric.classId === curClassId, ).value; return { ...rowsSoFar, [curClassId]: curValue / classTotal, numSketches: groupClassSketchMetrics.length, value: rowsSoFar.value + curValue, }; }, { value: 0 }, ); const groupTotal = firstMatchingMetric( totalMetrics, (m) => !m.groupId, // null groupId identifies group total metric ).value; return { groupId: curGroupId, percValue: classAgg.value / groupTotal, ...classAgg, }; }); }; /** * Flattens group class metrics, one for each group and sketch. * Each object includes the percValue for each class, and the total percValue with classes combined * groupId, sketchId, class1, class2, ..., total * @param groupMetrics - group metric data * @param totalValue - total value with classes combined * @param classes - class config */ export const flattenByGroupSketchAllClass = ( /** ToDo: is this needed? can the caller just pre-filter groupMetrics? */ sketches: Sketch[] | NullSketch[], /** Group metrics for collection and its child sketches */ groupMetrics: Metric[], /** Totals by class */ totals: Metric[], ): GroupMetricSketchAgg[] => { const sketchIds = new Set(sketches.map((sk) => sk.properties.id)); const sketchRows: GroupMetricSketchAgg[] = []; // Stratify in order by Group -> Sketch -> Class. Then flatten const metricsByGroup = groupBy(groupMetrics, (m) => m.groupId || "undefined"); for (const curGroupId of Object.keys(metricsByGroup)) { const groupSketchMetrics = metricsByGroup[curGroupId].filter( (m) => m.sketchId && sketchIds.has(m.sketchId), ); const groupSketchMetricsByClass = groupBy( groupSketchMetrics, (m) => m.classId || "undefined", ); const groupSketchMetricIds = Object.keys( groupBy(groupSketchMetrics, (m) => m.sketchId || "missing"), ); for (const curSketchId of groupSketchMetricIds) { const classAgg = Object.keys(groupSketchMetricsByClass).reduce< Record<string, number> >( (classAggSoFar, curClassId) => { const classMetric = firstMatchingMetric( groupSketchMetricsByClass[curClassId], (m) => m.sketchId === curSketchId, ); const classTotal = firstMatchingMetric( totals, (totalMetric) => totalMetric.classId === curClassId, ).value; return { ...classAggSoFar, value: classAggSoFar.value + classMetric.value, [curClassId]: classMetric.value / classTotal, }; }, { value: 0 }, ); const groupTotal = firstMatchingMetric(totals, (m) => !m.classId).value; sketchRows.push({ groupId: curGroupId, sketchId: curSketchId, value: classAgg.value, percValue: classAgg.value / groupTotal, ...classAgg, }); } } return sketchRows; }; /** * Given sketch collection, returns IDs of sketches in the collection * @deprecated */ export const getSketchCollectionChildIds = ( collection: SketchCollection | NullSketchCollection, ) => collection.features.map((sk) => sk.properties.id); /** * Returns an array of shorthand sketches (id + name) given a Sketch or SketchCollection. * Includes a shorthand of parent collection also * @deprecated */ export const toShortSketches = ( input: Sketch | SketchCollection | NullSketch | NullSketchCollection, ): { id: string; name: string }[] => { if (isSketch(input) || isNullSketch(input)) { return [{ id: input.properties.id, name: input.properties.name }]; } else if (isSketchCollection(input) || isNullSketchCollection(input)) { return [ { id: input.properties.id, name: input.properties.name }, ...input.features.map((sk) => ({ id: sk.properties.id, name: sk.properties.name, })), ]; } throw new Error("invalid input, must be Sketch or SketchCollection"); }; /** * Returns one aggregate object for every sketch ID present in metrics, * with additional property for each unique value for idProperty present for sketch. * Example - idProperty of 'classId', and two classes are present in metrics of 'classA', and 'classB' * then each flattened object will have two extra properties per sketch, .classA and .classB, each with the first metric value for that sketch/idProperty found * @param metrics - metrics with assigned sketch * @param extraIdProperty - optional second id property to cross flatten with idProperty. Properties will be keyed extraId_idProperty * @deprecated */ export const flattenSketchAllId = ( metrics: Metric[], idProperty: MetricDimension, options: { extraIdProperty?: MetricDimension; } = {}, ): Record<MetricProperty | string, MetricIdTypes>[] => { const { extraIdProperty } = options; const flatMetrics = groupBy(metrics, (m) => { if (m[idProperty]) { return m[idProperty] as MetricIdTypes; } throw new Error( `Metric is missing idProperty ${idProperty}: ${JSON.stringify(m)}`, ); }); const metricsBySketchId = groupBy( metrics, (metric) => metric.sketchId || "missing", ); const sketchRows = Object.keys(metricsBySketchId).reduce< Record<string, MetricIdTypes>[] >((rowsSoFar, curSketchId) => { const metricAgg = Object.keys(flatMetrics).reduce< Record<string, string | number> >((aggSoFar, curIdValue) => { const curMetric = metricsBySketchId[curSketchId].find( (m) => m[idProperty] === curIdValue, ); if (curMetric === undefined) return aggSoFar; const prop = extraIdProperty ? `${curMetric[extraIdProperty]}_${curMetric[idProperty]}` : curMetric[idProperty]; return { ...aggSoFar, [prop!]: curMetric?.value || 0, }; }, {}); return [ ...rowsSoFar, { sketchId: curSketchId, ...metricAgg, }, ]; }, []); return sketchRows; };