/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ import { cloneDeep, defaultTo, get, isEmpty, orderBy } from 'lodash'; import React from 'react'; import { EuiTitle, EuiSpacer, EuiText, EuiFlexGroup, EuiFlexItem, EuiComboBox, } from '@elastic/eui'; import { DateRange, Detector, MonitorAlert, AnomalySummary, EntityData, EntityOptionsMap, EntityOption, } from '../../../models/interfaces'; import { dateFormatter, minuteDateFormatter } from '../../utils/helpers'; import { RectAnnotationDatum } from '@elastic/charts'; import { DEFAULT_ANOMALY_SUMMARY } from './constants'; import { Datum, PlotData } from 'plotly.js'; import moment from 'moment'; import { calculateTimeWindowsWithMaxDataPoints, convertToEntityString, transformEntityListsForHeatmap, } from '../../utils/anomalyResultUtils'; import { HeatmapCell } from '../containers/AnomalyHeatmapChart'; import { EntityAnomalySummaries, EntityAnomalySummary, } from '../../../../server/models/interfaces'; import { toFixedNumberForAnomaly } from '../../../../server/utils/helpers'; import { Entity } from '../../../../server/models/interfaces'; import { TOP_CHILD_ENTITIES_TO_FETCH, MAX_TIME_SERIES_TO_DISPLAY, } from '../../DetectorResults/utils/constants'; export const convertAlerts = (response: any): MonitorAlert[] => { const alerts = get(response, 'response.alerts', []); return alerts.map((alert: any) => { return { monitorName: get(alert, 'monitor_name'), triggerName: get(alert, 'trigger_name'), severity: get(alert, 'severity'), state: get(alert, 'state'), error: get(alert, 'error_message'), startTime: get(alert, 'start_time'), endTime: get(alert, 'end_time'), acknowledgedTime: get(alert, 'acknowledged_time'), }; }); }; const getAlertMessage = (alert: MonitorAlert): string => { const message = alert.endTime ? `There is a severity ${alert.severity} alert with state ${ alert.state } between ${dateFormatter(alert.startTime)} and ${dateFormatter( alert.endTime )}.` : `There is a severity ${alert.severity} alert with state ${ alert.state } from ${dateFormatter(alert.startTime)}.`; return alert.error ? `${message} Error message: ${alert.error}` : message; }; export const generateAlertAnnotations = (alerts: MonitorAlert[]): any[] => { return alerts.map((alert: MonitorAlert) => ({ header: dateFormatter(alert.startTime), dataValue: alert.startTime, details: getAlertMessage(alert), })); }; const findLatestAnomaly = (anomalies: any[]) => { const latestAnomaly = anomalies.reduce((prevAnomaly, curAnomaly) => prevAnomaly.startTime > curAnomaly.startTime ? prevAnomaly : curAnomaly ); return latestAnomaly; }; const getAverageAnomalyGrade = (anomalyGrades: any[]) => { return anomalyGrades.length > 0 ? toFixedNumberForAnomaly( anomalyGrades.reduce((prevGrade, curGrade) => prevGrade + curGrade, 0) / anomalyGrades.length ) : 0; }; export const getAnomalySummary = (totalAnomalies: any[]): AnomalySummary => { if (totalAnomalies == undefined || totalAnomalies.length === 0) { return DEFAULT_ANOMALY_SUMMARY; } const anomalies = totalAnomalies.filter( (anomaly) => anomaly.anomalyGrade > 0 ); const anomalyGrades = anomalies.map((anomaly) => anomaly.anomalyGrade); const anomalyConfidences = anomalies.map((anomaly) => anomaly.confidence); const maxConfidence = Math.max(...anomalyConfidences, 0.0); const minConfidence = Math.min(...anomalyConfidences, 1.0); const maxAnomalyGrade = Math.max(...anomalyGrades, 0.0); const minAnomalyGrade = Math.min(...anomalyGrades, 1.0); const avgAnomalyGrade = getAverageAnomalyGrade(anomalyGrades); const lastAnomalyOccurrence = anomalies.length > 0 ? minuteDateFormatter(findLatestAnomaly(anomalies).endTime) : '-'; return { anomalyOccurrence: anomalies.length, minAnomalyGrade: minAnomalyGrade > maxAnomalyGrade ? 0 : minAnomalyGrade, maxAnomalyGrade: maxAnomalyGrade, avgAnomalyGrade: avgAnomalyGrade, minConfidence: minConfidence > maxConfidence ? 0 : minConfidence, maxConfidence: maxConfidence, lastAnomalyOccurrence: lastAnomalyOccurrence, }; }; export const disabledHistoryAnnotations = ( dateRange: DateRange, detector?: Detector ): RectAnnotationDatum[] => { if ( !detector || !detector.disabledTime || detector.disabledTime > dateRange.endDate.valueOf() || !detector.enabledTime || detector.enabledTime < dateRange.startDate.valueOf() ) { return []; } const startTime = detector.disabledTime; const endTime = detector.enabled ? detector.enabledTime : dateRange.endDate.valueOf(); const details = detector.enabled && detector.enabledTime ? `Detector was stopped from ${dateFormatter( startTime )} to ${dateFormatter(detector.enabledTime)}` : `Detector was stopped from ${dateFormatter(startTime)} until now`; const coordinateX0 = startTime >= dateRange.startDate.valueOf() ? startTime : dateRange.startDate.valueOf(); return [ { coordinates: { x0: coordinateX0, x1: endTime, }, details: details, }, ]; }; export const ANOMALY_HEATMAP_COLORSCALE = [ [0, '#F2F2F2'], [0.0000001, '#F2F2F2'], [0.0000001, '#F7E0B8'], [0.2, '#F7E0B8'], [0.2, '#F2C596'], [0.4, '#F2C596'], [0.4, '#ECA976'], [0.6, '#ECA976'], [0.6, '#E78D5B'], [0.8, '#E78D5B'], [0.8, '#E8664C'], [1, '#E8664C'], ]; export enum AnomalyHeatmapSortType { SEVERITY = 'severity', OCCURRENCE = 'occurrence', } const getHeatmapColorByValue = (value: number) => { // check if value is larger than largest value in color scale if ( value >= ANOMALY_HEATMAP_COLORSCALE[ANOMALY_HEATMAP_COLORSCALE.length - 1][0] ) { return ANOMALY_HEATMAP_COLORSCALE[ANOMALY_HEATMAP_COLORSCALE.length - 1][1]; } // check if value is smaller than smallest value in color scale if (value <= ANOMALY_HEATMAP_COLORSCALE[0][0]) { return ANOMALY_HEATMAP_COLORSCALE[0][1]; } for (let i = 0; i < ANOMALY_HEATMAP_COLORSCALE.length - 1; i++) { if ( value >= ANOMALY_HEATMAP_COLORSCALE[i][0] && value < ANOMALY_HEATMAP_COLORSCALE[i + 1][0] ) { return ANOMALY_HEATMAP_COLORSCALE[i + 1][1]; } } }; export const NUM_CELLS = 20; export const HEATMAP_X_AXIS_DATE_FORMAT = 'MM-DD HH:mm:ss YYYY'; const buildBlankStringWithLength = (length: number) => { let result = ''; for (let i = 0; i < length; i++) { result += ' '; } return result; }; export const getSampleAnomaliesHeatmapData = ( anomalies: any[] | undefined, dateRange: DateRange, sortType: AnomalyHeatmapSortType = AnomalyHeatmapSortType.SEVERITY, displayTopNum: number ): PlotData[] => { const entityAnomalyResultMap = getEntityAnomaliesMap(anomalies); const entityAnomaliesMap = filterEntityAnomalyResultMap( entityAnomalyResultMap ); if (isEmpty(entityAnomaliesMap)) { // put placeholder data so that heatmap won't look empty for (let i = 0; i < displayTopNum; i++) { // using blank string with different length as entity values instead of // only 1 whitesapce for all entities, to avoid heatmap with single row const blankStrValue = buildBlankStringWithLength(i); entityAnomaliesMap.set(blankStrValue, []); } } // entityStrings are the string representations used as y-axis labels for the heatmap, // and only contain the entity values. // entityLists are the entity objects (containing name and value) which are // populated in each of the heatmap cells, and used to fetch model-specific results when // a user clicks on a particular cell const entityStrings = [] as string[]; const entityLists = [] as any[]; const maxAnomalyGrades = [] as any[]; const numAnomalyGrades = [] as any[]; const timeWindows = calculateTimeWindowsWithMaxDataPoints( NUM_CELLS, dateRange ); entityAnomaliesMap.forEach((entityAnomalies, entityListAsString) => { const maxAnomalyGradesForEntity = [] as number[]; const numAnomalyGradesForEntity = [] as number[]; entityStrings.push(entityListAsString); entityLists.push(get(entityAnomalies, '0.entity', {})); timeWindows.forEach((timeWindow) => { const anomaliesInWindow = entityAnomalies.filter( (anomaly) => get(anomaly, 'plotTime', 0) <= timeWindow.endDate && get(anomaly, 'plotTime', 0) >= timeWindow.startDate ); const entityAnomalyGrades = anomaliesInWindow.map((anomaly) => get(anomaly, 'anomalyGrade', 0) ); if (!isEmpty(entityAnomalyGrades)) { maxAnomalyGradesForEntity.push(Math.max(...entityAnomalyGrades)); numAnomalyGradesForEntity.push( entityAnomalyGrades.filter((anomalyGrade) => anomalyGrade > 0).length ); } else { maxAnomalyGradesForEntity.push(0); numAnomalyGradesForEntity.push(0); } }); maxAnomalyGrades.push(maxAnomalyGradesForEntity); numAnomalyGrades.push(numAnomalyGradesForEntity); }); const plotTimes = timeWindows.map((timeWindow) => timeWindow.startDate); const plotTimesInString = plotTimes.map((timestamp) => moment(timestamp).format(HEATMAP_X_AXIS_DATE_FORMAT) ); const cellTimeInterval = timeWindows[0].endDate - timeWindows[0].startDate; const entityListsTransformed = transformEntityListsForHeatmap(entityLists); const plotData = buildHeatmapPlotData( plotTimesInString, entityStrings, maxAnomalyGrades, numAnomalyGrades, entityListsTransformed, cellTimeInterval ); const resultPlotData = sortHeatmapPlotData(plotData, sortType, displayTopNum); return [resultPlotData]; }; const buildHeatmapPlotData = ( x: any[], y: any[], z: any[], anomalyOccurrences: any[], entityLists: any[], cellTimeInterval: number ): PlotData => { //@ts-ignore return { x: x, y: y, z: z, colorscale: ANOMALY_HEATMAP_COLORSCALE, zmin: 0, zmax: 1, type: 'heatmap', showscale: false, xgap: 2, ygap: 2, opacity: 1, text: anomalyOccurrences, customdata: entityLists, hovertemplate: 'Entities: %{customdata}
' + 'Time: %{x}
' + 'Max anomaly grade: %{z}
' + 'Anomaly occurrences: %{text}' + '', cellTimeInterval: cellTimeInterval, } as PlotData; }; export const getEntityAnomaliesHeatmapData = ( dateRange: DateRange, entitiesAnomalySummaryResult: EntityAnomalySummaries[], displayTopNum: number ) => { const entityStrings = [] as string[]; const entityLists = [] as any[]; const maxAnomalyGrades = [] as any[]; const numAnomalyGrades = [] as any[]; const timeWindows = calculateTimeWindowsWithMaxDataPoints( NUM_CELLS, dateRange ); let entitiesAnomalySummaries = [] as EntityAnomalySummaries[]; if (isEmpty(entitiesAnomalySummaryResult)) { // put placeholder data so that heatmap won't look empty for (let i = 0; i < displayTopNum; i++) { // using blank string with different length as entity values instead of // only 1 whitesapce for all entities, to avoid heatmap with single row const blankStrValue = buildBlankStringWithLength(i); entitiesAnomalySummaries.push({ entityList: [ { value: blankStrValue, }, ], } as EntityAnomalySummaries); } } else { entitiesAnomalySummaries = entitiesAnomalySummaryResult; } entitiesAnomalySummaries.forEach((entityAnomalySummaries) => { const maxAnomalyGradesForEntity = [] as number[]; const numAnomalyGradesForEntity = [] as number[]; const entityString = convertToEntityString( get(entityAnomalySummaries, 'entityList', []) ) as string; const anomaliesSummary = get( entityAnomalySummaries, 'anomalySummaries', [] ) as EntityAnomalySummary[]; entityStrings.push(entityString); const entityList = get( entityAnomalySummaries, 'entityList', [] ) as Entity[]; entityLists.push(entityList); timeWindows.forEach((timeWindow) => { const anomalySummaryInTimeRange = anomaliesSummary.filter( (singleAnomalySummary) => singleAnomalySummary.startTime >= timeWindow.startDate && singleAnomalySummary.startTime < timeWindow.endDate ); if (isEmpty(anomalySummaryInTimeRange)) { maxAnomalyGradesForEntity.push(0); numAnomalyGradesForEntity.push(0); return; } const maxAnomalies = anomalySummaryInTimeRange.map((anomalySummary) => { return toFixedNumberForAnomaly( defaultTo(get(anomalySummary, 'maxAnomaly'), 0) ); }); const countAnomalies = anomalySummaryInTimeRange.map((anomalySummary) => { return defaultTo(get(anomalySummary, 'anomalyCount'), 0); }); maxAnomalyGradesForEntity.push(Math.max(...maxAnomalies)); numAnomalyGradesForEntity.push( countAnomalies.reduce((a, b) => { return a + b; }) ); }); maxAnomalyGrades.push(maxAnomalyGradesForEntity); numAnomalyGrades.push(numAnomalyGradesForEntity); }); const plotTimes = timeWindows.map((timeWindow) => timeWindow.startDate); const timeStamps = plotTimes.map((timestamp) => moment(timestamp).format(HEATMAP_X_AXIS_DATE_FORMAT) ); const entityListsTransformed = transformEntityListsForHeatmap(entityLists); const plotData = buildHeatmapPlotData( timeStamps, entityStrings.reverse(), maxAnomalyGrades.reverse(), numAnomalyGrades.reverse(), entityListsTransformed.reverse(), timeWindows[0].endDate - timeWindows[0].startDate ); return [plotData]; }; const getEntityAnomaliesMap = ( anomalies: any[] | undefined ): Map => { const entityAnomaliesMap = new Map(); if (anomalies == undefined) { return entityAnomaliesMap; } anomalies.forEach((anomaly) => { const entityList = get(anomaly, 'entity', [] as EntityData[]); if (isEmpty(entityList)) { return; } const entityListAsString = convertToEntityString(entityList); let singleEntityAnomalies = []; if (entityAnomaliesMap.has(entityListAsString)) { //@ts-ignore singleEntityAnomalies = entityAnomaliesMap.get(entityListAsString); } singleEntityAnomalies.push(anomaly); entityAnomaliesMap.set(entityListAsString, singleEntityAnomalies); }); return entityAnomaliesMap; }; const filterEntityAnomalyResultMap = ( entityAnomalyResultMap: Map ) => { const entityAnomaliesMap = new Map(); entityAnomalyResultMap.forEach((entityAnomalies, entity) => { if ( !isEmpty(entityAnomalies) && !isEmpty( entityAnomalies.filter((anomaly) => get(anomaly, 'anomalyGrade', 0) > 0) ) ) { entityAnomaliesMap.set(entity, entityAnomalies); } }); return entityAnomaliesMap; }; export const filterHeatmapPlotDataByY = ( heatmapData: PlotData, selectedYs: Datum[], sortType: AnomalyHeatmapSortType ) => { const originalYs = cloneDeep(heatmapData.y); const originalZs = cloneDeep(heatmapData.z); const originalTexts = cloneDeep(heatmapData.text); const originalEntityLists = cloneDeep(heatmapData.customdata); const resultYs = []; const resultZs = []; const resultTexts = []; const resultEntityLists = []; for (let i = 0; i < originalYs.length; i++) { //@ts-ignore if (selectedYs.includes(originalYs[i])) { resultYs.push(originalYs[i]); resultZs.push(originalZs[i]); resultTexts.push(originalTexts[i]); resultEntityLists.push(originalEntityLists[i]); } } const updateHeatmapPlotData = { ...cloneDeep(heatmapData), y: resultYs, z: resultZs, text: resultTexts, customdata: resultEntityLists, } as PlotData; return sortHeatmapPlotData( updateHeatmapPlotData, sortType, selectedYs.length ); }; export const sortHeatmapPlotData = ( heatmapData: PlotData, sortType: AnomalyHeatmapSortType, topNum: number ) => { const originalYs = cloneDeep(heatmapData.y); const originalZs = cloneDeep(heatmapData.z); const originalTexts = cloneDeep(heatmapData.text); const originalEntityLists = cloneDeep(heatmapData.customdata); const originalValuesToSort = sortType === AnomalyHeatmapSortType.SEVERITY ? cloneDeep(originalZs) : cloneDeep(originalTexts); const funcToAggregate = sortType === AnomalyHeatmapSortType.SEVERITY ? (a: number, b: number) => { return Math.max(a, b); } : (a: number, b: number) => { return a + b; }; const yIndicesToSort = [] as object[]; for (let i = 0; i < originalYs.length; i++) { yIndicesToSort.push({ index: i, //@ts-ignore value: originalValuesToSort[i].reduce(funcToAggregate), }); } const sortedYIndices = orderBy(yIndicesToSort, ['value'], 'desc').slice( 0, topNum ); const resultYs = [] as any[]; const resultZs = [] as any[]; const resultTexts = [] as any[]; const resultEntityLists = [] as any[]; for (let i = sortedYIndices.length - 1; i >= 0; i--) { const index = get(sortedYIndices[i], 'index', 0); resultYs.push(originalYs[index]); resultZs.push(originalZs[index]); resultTexts.push(originalTexts[index]); resultEntityLists.push(originalEntityLists[index]); } return { ...cloneDeep(heatmapData), y: resultYs, z: resultZs, text: resultTexts, customdata: resultEntityLists, } as PlotData; }; export const updateHeatmapPlotData = (heatmapData: PlotData, update: any) => { return { ...cloneDeep(heatmapData), ...update, } as PlotData; }; export const getSelectedHeatmapCellPlotData = ( heatmapData: PlotData, selectedX: number, selectedY: number ) => { const originalZ = cloneDeep(heatmapData.z); const selectedZData = []; //@ts-ignore const selectedValue = originalZ[selectedY][selectedX]; for (let i = 0; i < originalZ.length; i++) { const row = []; //@ts-ignore for (let j = 0; j < originalZ[0].length; j++) { if (i === selectedY && j === selectedX) { row.push(selectedValue); } else { row.push(null); } } selectedZData.push(row); } const colorForCell = getHeatmapColorByValue(selectedValue); //@ts-ignore return [ { ...cloneDeep(heatmapData), z: selectedZData, colorscale: [ [0, colorForCell], [1, colorForCell], ], opacity: 1, hoverinfo: 'skip', hovertemplate: null, }, ] as PlotData[]; }; export const getAnomalyGradeWording = (isNotSample: boolean | undefined) => { return isNotSample ? 'Anomaly grade' : 'Sample anomaly grade'; }; export const getConfidenceWording = (isNotSample: boolean | undefined) => { return isNotSample ? 'Confidence' : 'Sample confidence'; }; export const getFeatureBreakdownWording = ( isNotSample: boolean | undefined ) => { return isNotSample ? 'Feature breakdown' : 'Sample feature breakdown'; }; export const getFeatureDataWording = (isNotSample: boolean | undefined) => { return isNotSample ? 'Feature output' : 'Sample feature output'; }; export const getLastAnomalyOccurrenceWording = ( isNotSample: boolean | undefined ) => { return isNotSample ? 'Last anomaly occurrence' : 'Last sample anomaly occurrence'; }; export const getAnomalyOccurrenceWording = ( isNotSample: boolean | undefined ) => { return isNotSample ? 'Anomaly occurrences' : 'Sample anomaly occurrences'; }; export const getAnomalyHistoryWording = ( isNotSample: boolean | undefined, isHistorical: boolean | undefined ) => { return isNotSample ? isHistorical ? 'Anomaly history' : 'Anomaly overview' : 'Sample anomaly history'; }; export const getDateRangeWithSelectedHeatmapCell = ( originalDateRange: DateRange, isHCDetector: boolean | undefined, heatmapCell: HeatmapCell | undefined ) => { if (isHCDetector && heatmapCell) { return heatmapCell.dateRange; } return originalDateRange; }; export const getHCTitle = (entityList: Entity[]) => { return (

{entityList.map((entity: Entity) => { return (
{entity.name}: {entity.value}{' '}
); })}

); }; export const getCategoryFieldOptions = (categoryFields: string[]) => { const categoryFieldOptions = [] as any[]; if (categoryFields !== undefined) { categoryFields.forEach((categoryField: string) => { categoryFieldOptions.push({ label: categoryField, }); }); } return categoryFieldOptions; }; // Split up the child entity values into their respective category fields, to pass as options to the dropdowns. // For example, given child category fields ['A', 'B'], and child entity values // [ [ { name: 'A', value: 'A1' }, { name: 'B', value: 'B1' } ], [ { name: 'A', value: 'A2' }, { name: 'B', value: 'B1' } ] ], // => { [A]: [ { label: 'A1' }, { label: 'A2' } ], [B]: [ { label: B1 } ] } const getChildEntities = ( childCategoryFields: string[], childEntityCombos: Entity[][] ) => { const childEntityOptionsMap = {} as EntityOptionsMap; childCategoryFields.forEach((categoryField: string) => { // init the map for each category field childEntityOptionsMap[categoryField] = [] as EntityOption[]; childEntityCombos.forEach((childEntityCombo: Entity[]) => { childEntityCombo.forEach((childEntity: Entity) => { if (categoryField === childEntity.name) { childEntityOptionsMap[categoryField].push({ label: childEntity.value, }); } }); }); }); return childEntityOptionsMap; }; // Returns a string list of selected parent entities (populated from the selected heatmap cell) // + an array of comboboxes, one for each child category field. Populates the top // child entities per combo box. export const getMultiCategoryFilters = ( parentEntities: Entity[], childEntities: Entity[][], allCategoryFields: string[], selectedChildEntities: EntityOptionsMap, onSelectedOptionsChange: (childCategoryField: string, options: any[]) => void, sortType: AnomalyHeatmapSortType ) => { const parentEntityFields = parentEntities.map( (entity: Entity) => entity.name ); const childCategoryFields = allCategoryFields.filter( (categoryField: string) => !parentEntityFields.includes(categoryField) ); // Based on the available child entities, categorize them into their appropriate category field // so each combo box can be filled with the appopriate entity options const childEntityOptions = getChildEntities( childCategoryFields, childEntities ); return ( {getHCTitle(parentEntities)} {childCategoryFields.map((childCategoryField: string) => { return (

{childCategoryField}:  

onSelectedOptionsChange(childCategoryField, options) } /> {/** * This is currently correct, in that the top TOP_CHILD_ENTITIES_TO_FETCH for this * single child category field will be fetched and available to view. But in the future, * if we add more category fields and more possible child category fields, we will only fetch * the top TOP_CHILD_ENTITIES_TO_FETCH values across ALL child category fields, so it may * be split between them. In that case this help text will need to be changed. */} {`Top ${TOP_CHILD_ENTITIES_TO_FETCH} ${childCategoryField}s sorted by anomaly ${sortType}. You may select up to ${MAX_TIME_SERIES_TO_DISPLAY}.`}
); })}
); }; // Get a list of entity lists, where each list represents a unique entity combination of // all parent + child entities (a single model). // In order to get all unique results, we first gather all of the entity sources, then get the cartesian product // of such sources. Each "source" here represents unique entities for a single category field. For example, // consider category field A with entities [A1, A2], and category field B with entities [B1, B2]. // The sources would be: [[A1, A2], [B1, B2]] // The cartesian product would be [[A1, B1], [A1, B2], [A2, B1], [A2, B2]] export const getAllEntityCombos = ( parentEntities: Entity[], childEntities: EntityOptionsMap ) => { let entitySources = [] as Entity[][]; // getting parent sources entitySources.push(parentEntities); // getting all child sources for (var childCategoryField in childEntities) { let curChildEntities = [] as Entity[]; childEntities[childCategoryField].forEach( (childEntityValue: EntityOption) => { const childEntity = { name: childCategoryField, value: childEntityValue.label, } as Entity; curChildEntities.push(childEntity); } ); entitySources.push(curChildEntities); } // Common JS pattern to return a cartesian product of an array of arrays, by iterating through // pairs of results, and appending all values of the current array to all values in the previous array. // // reduce(): set a custom fn to perform on the current and previous values in the array // map(): set a custom fn on the current value in the array // flatMap(): similar to map(), but flattens the result into a single-depth array // // More info: https://stackoverflow.com/questions/12303989/cartesian-product-of-multiple-arrays-in-javascript return entitySources.reduce( //@ts-ignore (a, b) => a.flatMap((x) => b.map((y) => [...x, y])), [[]] ) as Entity[][]; };