diff --git a/react-components/src/components/RevealToolbar/RuleBasedOutputsButton.tsx b/react-components/src/components/RevealToolbar/RuleBasedOutputsButton.tsx index 6cb683ea627..43d1afc95f1 100644 --- a/react-components/src/components/RevealToolbar/RuleBasedOutputsButton.tsx +++ b/react-components/src/components/RevealToolbar/RuleBasedOutputsButton.tsx @@ -56,6 +56,8 @@ export const RuleBasedOutputsButton = ({ if (selectedRule !== undefined) { selectedRule.isEnabled = data.target.checked; + } else { + if (onRuleSetStylingChanged !== undefined) onRuleSetStylingChanged(undefined); } setCurrentRuleSetEnabled(selectedRule); setRuleInstances(ruleInstances); diff --git a/react-components/src/components/RuleBasedOutputs/RuleBasedOutputsSelector.tsx b/react-components/src/components/RuleBasedOutputs/RuleBasedOutputsSelector.tsx index 925253d07d6..fd0b089f344 100644 --- a/react-components/src/components/RuleBasedOutputs/RuleBasedOutputsSelector.tsx +++ b/react-components/src/components/RuleBasedOutputs/RuleBasedOutputsSelector.tsx @@ -1,15 +1,19 @@ /*! * Copyright 2024 Cognite AS */ -import { useEffect, type ReactElement, useState } from 'react'; +import { useEffect, type ReactElement, useState, useMemo } from 'react'; import { CogniteCadModel } from '@cognite/reveal'; -import { useAllMappedEquipmentAssetMappings } from '../..'; +import { type ModelMappingsWithAssets, useAllMappedEquipmentAssetMappings } from '../..'; import { type RuleOutputSet, type AssetStylingGroupAndStyleIndex } from './types'; -import { generateRuleBasedOutputs } from './utils'; +import { generateRuleBasedOutputs, traverseExpressionToGetTimeseries } from './utils'; import { use3dModels } from '../../hooks/use3dModels'; import { EMPTY_ARRAY } from '../../utilities/constants'; -import { type Asset } from '@cognite/sdk'; +import { type Datapoints, type Asset } from '@cognite/sdk'; +import { isDefined } from '../../utilities/isDefined'; +import { type InfiniteData } from '@tanstack/react-query'; +import { type AssetIdsAndTimeseries } from '../../utilities/types'; +import { useAssetsAndTimeseriesLinkageDataQuery } from '../../query/useAssetsAndTimeseriesLinkageDataQuery'; export type ColorOverlayProps = { ruleSet: RuleOutputSet | undefined; @@ -20,6 +24,8 @@ export function RuleBasedOutputsSelector({ ruleSet, onRuleSetChanged }: ColorOverlayProps): ReactElement | undefined { + if (ruleSet === undefined) return; + const models = use3dModels(); const [stylingGroups, setStylingsGroups] = useState(); @@ -32,56 +38,95 @@ export function RuleBasedOutputsSelector({ } = useAllMappedEquipmentAssetMappings(models); useEffect(() => { - if (!isFetching && hasNextPage) { + if (!isFetching && (hasNextPage ?? false)) { void fetchNextPage(); } }, [isFetching, hasNextPage, fetchNextPage]); + const contextualizedAssetNodes = useMemo(() => { + return ( + assetMappings?.pages + .flat() + .flatMap((item) => item.assets) + .map(convertAssetMetadataKeysToLowerCase) ?? [] + ); + }, [assetMappings]); + + const timeseriesExternalIds = useMemo(() => { + const expressions = ruleSet?.rulesWithOutputs + .map((ruleWithOutput) => ruleWithOutput.rule.expression) + .filter(isDefined); + return traverseExpressionToGetTimeseries(expressions) ?? []; + }, [ruleSet]); + + const { isLoading: isLoadingAssetIdsAndTimeseriesData, data: assetIdsWithTimeseriesData } = + useAssetsAndTimeseriesLinkageDataQuery({ + timeseriesExternalIds, + contextualizedAssetNodes + }); + useEffect(() => { if (onRuleSetChanged !== undefined) onRuleSetChanged(stylingGroups); }, [stylingGroups]); useEffect(() => { if (assetMappings === undefined || models === undefined || isFetching) return; + if (timeseriesExternalIds.length > 0 && isLoadingAssetIdsAndTimeseriesData) return; setStylingsGroups(EMPTY_ARRAY); if (ruleSet === undefined) return; - const initializeRuleBasedOutputs = async (model: CogniteCadModel): Promise => { - // parse assets and mappings - // TODO: refactor to be sure to filter only the mappings/assets for the current model within the pages - const flatAssetsMappingsList = assetMappings.pages - .flat() - .map((item) => item.mappings) - .flat(); - const flatMappings = flatAssetsMappingsList.map((node) => node.items).flat(); - const contextualizedAssetNodes = assetMappings.pages - .flat() - .flatMap((item) => item.assets) - .map(convertAssetMetadataKeysToLowerCase); - - const collectionStylings = await generateRuleBasedOutputs( - model, - contextualizedAssetNodes, - flatMappings, - ruleSet - ); - - setStylingsGroups(collectionStylings); - }; - models.forEach(async (model) => { if (!(model instanceof CogniteCadModel)) { return; } - await initializeRuleBasedOutputs(model); + setStylingsGroups( + await initializeRuleBasedOutputs({ + model, + assetMappings, + contextualizedAssetNodes, + ruleSet, + assetIdsAndTimeseries: assetIdsWithTimeseriesData?.assetIdsWithTimeseries ?? [], + timeseriesDatapoints: assetIdsWithTimeseriesData?.timeseriesDatapoints ?? [] + }) + ); }); - }, [assetMappings, ruleSet]); + }, [isLoadingAssetIdsAndTimeseriesData, ruleSet]); return <>; } +async function initializeRuleBasedOutputs({ + model, + assetMappings, + contextualizedAssetNodes, + ruleSet, + assetIdsAndTimeseries, + timeseriesDatapoints +}: { + model: CogniteCadModel; + assetMappings: InfiniteData; + contextualizedAssetNodes: Asset[]; + ruleSet: RuleOutputSet; + assetIdsAndTimeseries: AssetIdsAndTimeseries[]; + timeseriesDatapoints: Datapoints[] | undefined; +}): Promise { + const flatAssetsMappingsList = assetMappings.pages.flat().flatMap((item) => item.mappings); + const flatMappings = flatAssetsMappingsList.flatMap((node) => node.items); + + const collectionStylings = await generateRuleBasedOutputs({ + model, + contextualizedAssetNodes, + assetMappings: flatMappings, + ruleSet, + assetIdsAndTimeseries, + timeseriesDatapoints + }); + + return collectionStylings; +} + function convertAssetMetadataKeysToLowerCase(asset: Asset): Asset { return { ...asset, diff --git a/react-components/src/components/RuleBasedOutputs/types.ts b/react-components/src/components/RuleBasedOutputs/types.ts index 3020f059ef0..dc330a8ff2d 100644 --- a/react-components/src/components/RuleBasedOutputs/types.ts +++ b/react-components/src/components/RuleBasedOutputs/types.ts @@ -5,6 +5,7 @@ import { type TreeIndexNodeCollection, type NumericRange } from '@cognite/reveal'; import { type FdmNode, type EdgeItem } from '../../utilities/FdmSDK'; import { type AssetStylingGroup, type FdmPropertyType } from '../Reveal3DResources/types'; +import { type Datapoints, type Asset, type Timeseries, type ExternalId } from '@cognite/sdk'; // =========== RULE BASED OUTPUT DATA MODEL @@ -12,7 +13,7 @@ export type TriggerType = 'timeseries' | 'metadata'; export type TimeseriesRuleTrigger = { type: 'timeseries'; - timeseriesId: number; + externalId: string; }; export type MetadataRuleTrigger = { @@ -88,6 +89,7 @@ export type Rule = { export type BaseRuleOutput = { externalId: string; // comes from FDM + name?: string; // ruleId: string | undefined; // Transiently it can be left undefined }; @@ -227,7 +229,6 @@ export type ViewQueryFilter = { view: Source; }; -export type ExternalId = string; export type Space = string; export type ExternalIdsResultList = { @@ -261,3 +262,20 @@ export type NodeItem> = { deletedTime: number; properties: FdmPropertyType; }; + +export type TriggerTypeData = TriggerMetadataType | TriggerTimeseriesType; + +export type TriggerMetadataType = { + type: 'metadata'; + asset: Asset; +}; + +export type TriggerTimeseriesType = { + type: 'timeseries'; + timeseries: { + timeseriesWithDatapoints: TimeseriesAndDatapoints[]; + linkedAssets: Asset; + }; +}; + +export type TimeseriesAndDatapoints = Timeseries & Datapoints; diff --git a/react-components/src/components/RuleBasedOutputs/utils.ts b/react-components/src/components/RuleBasedOutputs/utils.ts index 6852d3a43a9..33690b81a12 100644 --- a/react-components/src/components/RuleBasedOutputs/utils.ts +++ b/react-components/src/components/RuleBasedOutputs/utils.ts @@ -17,27 +17,40 @@ import { type RuleAndStyleIndex, type AssetStylingGroupAndStyleIndex, type TriggerType, - type RuleWithOutputs + type RuleWithOutputs, + type TriggerTypeData, + type TimeseriesAndDatapoints } from './types'; import { type CogniteCadModel, TreeIndexNodeCollection, type NodeAppearance } from '@cognite/reveal'; -import { type AssetMapping3D, type Asset } from '@cognite/sdk'; +import { type AssetMapping3D, type Asset, type Datapoints } from '@cognite/sdk'; import { type AssetStylingGroup } from '../Reveal3DResources/types'; import { isDefined } from '../../utilities/isDefined'; import { assertNever } from '../../utilities/assertNever'; +import { type AssetIdsAndTimeseries } from '../../utilities/types'; const checkStringExpressionStatement = ( - asset: Asset, + triggerTypeData: TriggerTypeData[], expression: StringExpression ): boolean | undefined => { const { trigger, condition } = expression; let expressionResult: boolean | undefined = false; - const assetTrigger = asset[trigger.type]?.[trigger.key]; + const currentTriggerData = triggerTypeData.find( + (triggerType) => triggerType.type === trigger?.type + ); + const assetTrigger = + trigger?.type === 'metadata' && + currentTriggerData?.type === 'metadata' && + currentTriggerData?.asset !== undefined + ? currentTriggerData?.asset[trigger.type]?.[trigger.key] + : undefined; + + if (assetTrigger === undefined) return; switch (condition.type) { case 'equals': { @@ -64,61 +77,95 @@ const checkStringExpressionStatement = ( return expressionResult; }; + +const getTriggerNumericData = ( + triggerTypeData: TriggerTypeData[], + trigger: MetadataRuleTrigger | TimeseriesRuleTrigger +): number | undefined => { + const currentTriggerData = triggerTypeData.find( + (triggerType) => triggerType.type === trigger?.type + ); + + if (currentTriggerData === undefined) return; + + if (currentTriggerData.type === 'metadata' && trigger.type === 'metadata') { + return Number(currentTriggerData.asset[trigger.type]?.[trigger.key]); + } else if (currentTriggerData.type === 'timeseries' && trigger.type === 'timeseries') { + return getTriggerTimeseriesNumericData(currentTriggerData, trigger); + } +}; + +const getTriggerTimeseriesNumericData = ( + triggerTypeData: TriggerTypeData, + trigger: TimeseriesRuleTrigger +): number | undefined => { + if (trigger.type !== 'timeseries') return; + if (triggerTypeData.type !== 'timeseries') return; + + const timeseriesWithDatapoints = triggerTypeData.timeseries.timeseriesWithDatapoints; + + const dataFound = timeseriesWithDatapoints.find((item) => item.externalId === trigger.externalId); + + const datapoint = dataFound?.datapoints[dataFound?.datapoints.length - 1]?.value; + + return Number(datapoint); +}; + const checkNumericExpressionStatement = ( - asset: Asset, + triggerTypeData: TriggerTypeData[], expression: NumericExpression ): boolean | undefined => { - if (!isMetadataTrigger(expression.trigger)) return undefined; - const trigger = expression.trigger; const condition = expression.condition; let expressionResult: boolean = false; - const assetTrigger = Number(asset[trigger.type]?.[trigger.key]); + const dataTrigger = getTriggerNumericData(triggerTypeData, trigger); + + if (dataTrigger === undefined) return; switch (condition.type) { case 'equals': { const parameter = condition.parameters[0]; - expressionResult = assetTrigger === parameter; + expressionResult = dataTrigger === parameter; break; } case 'notEquals': { const parameter = condition.parameters[0]; - expressionResult = assetTrigger !== parameter; + expressionResult = dataTrigger !== parameter; break; } case 'lessThan': { const parameter = condition.parameters[0]; - expressionResult = assetTrigger < parameter; + expressionResult = dataTrigger < parameter; break; } case 'greaterThan': { const parameter = condition.parameters[0]; - expressionResult = assetTrigger > parameter; + expressionResult = dataTrigger > parameter; break; } case 'lessThanOrEquals': { const parameter = condition.parameters[0]; - expressionResult = assetTrigger <= parameter; + expressionResult = dataTrigger <= parameter; break; } case 'greaterThanOrEquals': { const parameter = condition.parameters[0]; - expressionResult = assetTrigger >= parameter; + expressionResult = dataTrigger >= parameter; break; } case 'within': { const lower = condition.lowerBoundInclusive; const upper = condition.upperBoundInclusive; - const value = assetTrigger; + const value = dataTrigger; expressionResult = lower < value && value < upper; break; } case 'outside': { const lower = condition.lowerBoundExclusive; const upper = condition.upperBoundExclusive; - const value = assetTrigger; + const value = dataTrigger; expressionResult = value <= lower && upper <= value; break; } @@ -127,8 +174,18 @@ const checkNumericExpressionStatement = ( return expressionResult; }; +const getTimeseriesExternalIdFromNumericExpression = ( + expression: NumericExpression +): string[] | undefined => { + const trigger = expression.trigger; + + if (isMetadataTrigger(trigger)) return; + + return [trigger.externalId]; +}; + const traverseExpression = ( - asset: Asset, + triggerTypeData: TriggerTypeData[], expressions: Expression[] ): Array => { let expressionResult: boolean | undefined = false; @@ -138,26 +195,26 @@ const traverseExpression = ( expressions.forEach((expression) => { switch (expression.type) { case 'or': { - const operatorResult = traverseExpression(asset, expression.expressions); + const operatorResult = traverseExpression(triggerTypeData, expression.expressions); expressionResult = operatorResult.find((result) => result) ?? false; break; } case 'and': { - const operatorResult = traverseExpression(asset, expression.expressions); + const operatorResult = traverseExpression(triggerTypeData, expression.expressions); expressionResult = operatorResult.every((result) => result === true) ?? false; break; } case 'not': { - const operatorResult = traverseExpression(asset, [expression.expression]); + const operatorResult = traverseExpression(triggerTypeData, [expression.expression]); expressionResult = operatorResult[0] !== undefined ? !operatorResult[0] : false; break; } case 'numericExpression': { - expressionResult = checkNumericExpressionStatement(asset, expression); + expressionResult = checkNumericExpressionStatement(triggerTypeData, expression); break; } case 'stringExpression': { - expressionResult = checkStringExpressionStatement(asset, expression); + expressionResult = checkStringExpressionStatement(triggerTypeData, expression); break; } } @@ -209,15 +266,25 @@ function getExpressionTriggerTypes(expression: Expression): TriggerType[] { } } -export const generateRuleBasedOutputs = async ( - model: CogniteCadModel, - contextualizedAssetNodes: Asset[], - assetMappings: AssetMapping3D[], - ruleSet: RuleOutputSet -): Promise => { +export const generateRuleBasedOutputs = async ({ + model, + contextualizedAssetNodes, + assetMappings, + ruleSet, + assetIdsAndTimeseries, + timeseriesDatapoints +}: { + model: CogniteCadModel; + contextualizedAssetNodes: Asset[]; + assetMappings: AssetMapping3D[]; + ruleSet: RuleOutputSet; + assetIdsAndTimeseries: AssetIdsAndTimeseries[]; + timeseriesDatapoints: Datapoints[] | undefined; +}): Promise => { const outputType = 'color'; // for now it only supports colors as the output const ruleWithOutputs = ruleSet?.rulesWithOutputs; + return ( await Promise.all( ruleWithOutputs?.map(async (ruleWithOutput: { rule: Rule; outputs: RuleOutput[] }) => { @@ -229,20 +296,18 @@ export const generateRuleBasedOutputs = async ( forEachExpression(expression, convertExpressionStringMetadataKeyToLowerCase); - const outputFound = outputs.find((output: { type: string }) => output.type === outputType); + const outputSelected: ColorRuleOutput | undefined = getRuleOutputFromTypeSelected( + outputs, + outputType + ); - if (outputFound?.type !== 'color') return; - - const outputSelected: ColorRuleOutput = { - externalId: outputFound.externalId, - type: 'color', - fill: outputFound.fill, - outline: outputFound.outline - }; + if (outputSelected === undefined) return; return await analyzeNodesAgainstExpression({ model, contextualizedAssetNodes, + assetIdsAndTimeseries, + timeseriesDatapoints, assetMappings, expression, outputSelected @@ -252,26 +317,75 @@ export const generateRuleBasedOutputs = async ( ).filter(isDefined); }; +const getRuleOutputFromTypeSelected = ( + outputs: RuleOutput[], + outputType: string +): ColorRuleOutput | undefined => { + const outputFound = outputs.find((output: { type: string }) => output.type === outputType); + + if (outputFound?.type !== 'color') return; + + const outputSelected: ColorRuleOutput = { + externalId: outputFound.externalId, + type: 'color', + fill: outputFound.fill, + outline: outputFound.outline + }; + + return outputSelected; +}; + const analyzeNodesAgainstExpression = async ({ model, contextualizedAssetNodes, + assetIdsAndTimeseries, + timeseriesDatapoints, assetMappings, expression, outputSelected }: { model: CogniteCadModel; contextualizedAssetNodes: Asset[]; + assetIdsAndTimeseries: AssetIdsAndTimeseries[]; + timeseriesDatapoints: Datapoints[] | undefined; assetMappings: AssetMapping3D[]; expression: Expression; outputSelected: ColorRuleOutput; }): Promise => { const allTreeNodes = await Promise.all( - contextualizedAssetNodes.map(async (assetNode) => { - const finalGlobalOutputResult = traverseExpression(assetNode, [expression]); + contextualizedAssetNodes.map(async (contextualizedAssetNode) => { + const triggerData: TriggerTypeData[] = []; + + const metadataTriggerData: TriggerTypeData = { + type: 'metadata', + asset: contextualizedAssetNode + }; + + triggerData.push(metadataTriggerData); + + const timeseriesDataForThisAsset = generateTimeseriesAndDatapointsFromTheAsset({ + contextualizedAssetNode, + assetIdsAndTimeseries, + timeseriesDatapoints + }); + + if (timeseriesDataForThisAsset.length > 0) { + const timeseriesTriggerData: TriggerTypeData = { + type: 'timeseries', + timeseries: { + timeseriesWithDatapoints: timeseriesDataForThisAsset, + linkedAssets: contextualizedAssetNode + } + }; + + triggerData.push(timeseriesTriggerData); + } + + const finalGlobalOutputResult = traverseExpression(triggerData, [expression]); if (finalGlobalOutputResult[0] ?? false) { const nodesFromThisAsset = assetMappings.filter( - (mapping) => mapping.assetId === assetNode.id + (mapping) => mapping.assetId === contextualizedAssetNode.id ); // get the 3d nodes linked to the asset and with treeindex and subtreeRange @@ -289,6 +403,68 @@ const analyzeNodesAgainstExpression = async ({ return applyNodeStyles(filteredAllTreeNodes, outputSelected); }; +const generateTimeseriesAndDatapointsFromTheAsset = ({ + contextualizedAssetNode, + assetIdsAndTimeseries, + timeseriesDatapoints +}: { + contextualizedAssetNode: Asset; + assetIdsAndTimeseries: AssetIdsAndTimeseries[]; + timeseriesDatapoints: Datapoints[] | undefined; +}): TimeseriesAndDatapoints[] => { + const timeseriesLinkedToThisAsset = assetIdsAndTimeseries.filter( + (item) => item.assetIds?.externalId === contextualizedAssetNode.externalId + ); + + const timeseries = timeseriesLinkedToThisAsset?.map((item) => item.timeseries).filter(isDefined); + const datapoints = timeseriesDatapoints?.filter((datapoint) => + timeseries?.find((item) => item?.externalId === datapoint.externalId) + ); + + const timeseriesData: TimeseriesAndDatapoints[] = timeseries + .map((item) => { + const datapoint = datapoints?.find((datapoint) => datapoint.externalId === item.externalId); + if (datapoint === undefined) return undefined; + + const content: TimeseriesAndDatapoints = { + ...item, + ...datapoint + }; + return content; + }) + .filter(isDefined); + return timeseriesData; +}; + +export const traverseExpressionToGetTimeseries = ( + expressions: Expression[] | undefined +): string[] | undefined => { + let timeseriesExternalIdFound: string[] | undefined = []; + + const timeseriesExternalIdResults = expressions + ?.map((expression) => { + switch (expression.type) { + case 'or': + case 'and': { + timeseriesExternalIdFound = traverseExpressionToGetTimeseries(expression.expressions); + break; + } + case 'not': { + timeseriesExternalIdFound = traverseExpressionToGetTimeseries([expression.expression]); + break; + } + case 'numericExpression': { + timeseriesExternalIdFound = getTimeseriesExternalIdFromNumericExpression(expression); + break; + } + } + return timeseriesExternalIdFound?.filter(isDefined) ?? []; + }) + .flat(); + + return timeseriesExternalIdResults; +}; + const getThreeDNodesFromAsset = async ( nodesFromThisAsset: AssetMapping3D[], model: CogniteCadModel @@ -321,8 +497,6 @@ const applyNodeStyles = ( nodeIndexSet.addRange(node.subtreeRange); }); - // assign the style with the color from the condition - const nodeAppearance: NodeAppearance = { color: new Color(outputSelected.fill) }; diff --git a/react-components/src/hooks/network/filterRelationships.ts b/react-components/src/hooks/network/filterRelationships.ts new file mode 100644 index 00000000000..82dc109d6d9 --- /dev/null +++ b/react-components/src/hooks/network/filterRelationships.ts @@ -0,0 +1,33 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { type CogniteClient, type Relationship, type RelationshipsFilter } from '@cognite/sdk'; + +type RelationshipWithResources = { + source?: Record; + target?: Record; +}; + +export const filterRelationships = async ( + sdk: CogniteClient, + filter?: RelationshipsFilter +): Promise => { + const fetch = async (): Promise => { + const response = await sdk.relationships + .list({ + filter, + fetchResources: true + }) + .autoPagingToArray({ limit: -1 }); + + return response.filter(isValidRelationship); + }; + + return await fetch(); +}; + +const isValidRelationship = (relationship: Relationship): boolean => { + const relationshipWithResources = relationship as RelationshipWithResources; + + return Boolean(relationshipWithResources.source) && Boolean(relationshipWithResources.target); +}; diff --git a/react-components/src/hooks/network/getAssetsByIds.ts b/react-components/src/hooks/network/getAssetsByIds.ts new file mode 100644 index 00000000000..409948d83c6 --- /dev/null +++ b/react-components/src/hooks/network/getAssetsByIds.ts @@ -0,0 +1,11 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { type CogniteClient, type IdEither, type Asset } from '@cognite/sdk'; + +export const getAssetsByIds = async ( + sdk: CogniteClient, + assetIds: IdEither[] +): Promise => { + return await sdk.assets.retrieve(assetIds, { ignoreUnknownIds: true }); +}; diff --git a/react-components/src/hooks/network/getRelationships.ts b/react-components/src/hooks/network/getRelationships.ts new file mode 100644 index 00000000000..b55a5468ca3 --- /dev/null +++ b/react-components/src/hooks/network/getRelationships.ts @@ -0,0 +1,42 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { + type RelationshipsFilterRequest, + type CogniteClient, + type CogniteExternalId, + type RelationshipResourceType +} from '@cognite/sdk'; + +import { getSourceRelationships } from './getSourceRelationships'; +import { getTargetRelationships } from './getTargetRelationships'; +import { type ExtendedRelationship } from '../../utilities/types'; + +type Payload = { + resourceExternalIds: CogniteExternalId[]; + relationshipResourceTypes?: RelationshipResourceType[]; +} & Omit< + RelationshipsFilterRequest, + 'sourceExternalIds' | 'targetExternalIds' | 'sourceTypes' | 'targetTypes' +>; + +export const getRelationships = async ( + sdk: CogniteClient, + payload: Payload +): Promise => { + const { resourceExternalIds, relationshipResourceTypes, ...rest } = payload; + + const [sourceRelationships, targetRelationships] = await Promise.all([ + getSourceRelationships(sdk, { + targetExternalIds: resourceExternalIds, + sourceTypes: relationshipResourceTypes, + ...rest + }), + getTargetRelationships(sdk, { + sourceExternalIds: resourceExternalIds, + targetTypes: relationshipResourceTypes, + ...rest + }) + ]); + return [...sourceRelationships, ...targetRelationships]; +}; diff --git a/react-components/src/hooks/network/getSourceRelationships.ts b/react-components/src/hooks/network/getSourceRelationships.ts new file mode 100644 index 00000000000..8d3d7c1f815 --- /dev/null +++ b/react-components/src/hooks/network/getSourceRelationships.ts @@ -0,0 +1,28 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { + type RelationshipsFilterRequest, + type CogniteClient, + type CogniteExternalId, + type RelationshipResourceType +} from '@cognite/sdk'; + +import { filterRelationships } from './filterRelationships'; +import { type ExtendedRelationship } from '../../utilities/types'; + +type Payload = { + targetExternalIds: CogniteExternalId[]; + sourceTypes?: RelationshipResourceType[]; +} & Omit; + +export const getSourceRelationships = async ( + sdk: CogniteClient, + payload: Payload +): Promise => { + const relationships = await filterRelationships(sdk, payload); + return relationships.map((relationship) => ({ + ...relationship, + relation: 'Source' + })); +}; diff --git a/react-components/src/hooks/network/getTargetRelationships.ts b/react-components/src/hooks/network/getTargetRelationships.ts new file mode 100644 index 00000000000..01f4425910f --- /dev/null +++ b/react-components/src/hooks/network/getTargetRelationships.ts @@ -0,0 +1,28 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { + type RelationshipsFilterRequest, + type CogniteClient, + type CogniteExternalId, + type RelationshipResourceType +} from '@cognite/sdk'; + +import { filterRelationships } from './filterRelationships'; +import { type ExtendedRelationship } from '../../utilities/types'; + +type Payload = { + sourceExternalIds: CogniteExternalId[]; + targetTypes?: RelationshipResourceType[]; +} & Omit; + +export const getTargetRelationships = async ( + sdk: CogniteClient, + payload: Payload +): Promise => { + const relationships = await filterRelationships(sdk, payload); + return relationships.map((relationship) => ({ + ...relationship, + relation: 'Target' + })); +}; diff --git a/react-components/src/hooks/network/getTimeseriesByIds.ts b/react-components/src/hooks/network/getTimeseriesByIds.ts new file mode 100644 index 00000000000..b9e7b4d1b68 --- /dev/null +++ b/react-components/src/hooks/network/getTimeseriesByIds.ts @@ -0,0 +1,19 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { type Timeseries, type CogniteClient, type IdEither } from '@cognite/sdk'; +import { chunk } from 'lodash'; +import { DEFAULT_GLOBAL_TABLE_MAX_RESULT_LIMIT } from '../../utilities/constants'; + +export const getTimeseriesByIds = async ( + sdk: CogniteClient, + timeseriesIds: IdEither[] +): Promise => { + const chunkedTimeseriesIds = chunk(timeseriesIds, DEFAULT_GLOBAL_TABLE_MAX_RESULT_LIMIT); + const chunkedPromises = chunkedTimeseriesIds.map( + async (timeseriesIds) => + await sdk.timeseries.retrieve(timeseriesIds, { ignoreUnknownIds: true }) + ); + const result = await Promise.all(chunkedPromises); + return result.flat(); +}; diff --git a/react-components/src/hooks/network/getTimeseriesLatestDatapoints.ts b/react-components/src/hooks/network/getTimeseriesLatestDatapoints.ts new file mode 100644 index 00000000000..8501661dd69 --- /dev/null +++ b/react-components/src/hooks/network/getTimeseriesLatestDatapoints.ts @@ -0,0 +1,11 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { type CogniteClient, type IdEither, type Datapoints } from '@cognite/sdk'; + +export const getTimeseriesLatestDatapoints = async ( + sdk: CogniteClient, + timeseriesIds: IdEither[] +): Promise => { + return await sdk.datapoints.retrieveLatest(timeseriesIds); +}; diff --git a/react-components/src/query/useAssetsAndTimeseriesLinkageDataQuery.ts b/react-components/src/query/useAssetsAndTimeseriesLinkageDataQuery.ts new file mode 100644 index 00000000000..b422e5287ea --- /dev/null +++ b/react-components/src/query/useAssetsAndTimeseriesLinkageDataQuery.ts @@ -0,0 +1,207 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { + type Asset, + type ExternalId, + type IdEither, + type InternalId, + type Timeseries, + type CogniteExternalId, + type RelationshipResourceType, + type CogniteClient +} from '@cognite/sdk'; + +import { getRelationships } from '../hooks/network/getRelationships'; +import { useSDK } from '../components/RevealCanvas/SDKProvider'; +import { + type AssetIdsAndTimeseries, + type AssetAndTimeseriesIds, + type AssetIdsAndTimeseriesData +} from '../utilities/types'; +import { queryKeys } from '../utilities/queryKeys'; +import { getTimeseriesByIds } from '../hooks/network/getTimeseriesByIds'; +import { isDefined } from '../utilities/isDefined'; +import { getAssetsByIds } from '../hooks/network/getAssetsByIds'; +import { getTimeseriesLatestDatapoints } from '../hooks/network/getTimeseriesLatestDatapoints'; + +type Props = { + timeseriesExternalIds: CogniteExternalId[]; + contextualizedAssetNodes: Asset[]; +}; + +export function useAssetsAndTimeseriesLinkageDataQuery({ + timeseriesExternalIds, + contextualizedAssetNodes +}: Props): UseQueryResult { + const sdk = useSDK(); + + const relationshipResourceTypes: RelationshipResourceType[] = ['asset']; + + return useQuery({ + queryKey: [ + queryKeys.timeseriesLinkedToAssets(), + timeseriesExternalIds, + relationshipResourceTypes + ], + queryFn: async () => { + const externalIds: ExternalId[] = timeseriesExternalIds.map((externalId) => { + return { + externalId + }; + }); + + const [assetAndTimeseriesIdsFromRelationship, timeseries, timeseriesDatapoints] = + await Promise.all([ + getLinkFromRelationships(sdk, timeseriesExternalIds, relationshipResourceTypes), + getTimeseriesByIds(sdk, externalIds), + getTimeseriesLatestDatapoints(sdk, externalIds) + ]); + + const assetIdsFound: IdEither[] = + timeseries + ?.map((timeseries) => { + return getAssetIdsFromTimeseries( + contextualizedAssetNodes, + timeseries, + assetAndTimeseriesIdsFromRelationship + ); + }) + .flat() + .filter(isDefined) ?? []; + + const assetFromTimeseries = await getAssetsByIds(sdk, assetIdsFound); + const assetIdsWithTimeseries = + timeseries + ?.map((timeseries): AssetIdsAndTimeseries[] | undefined => { + return generateAssetAndTimeseries( + assetFromTimeseries, + timeseries, + assetAndTimeseriesIdsFromRelationship + ); + }) + .flat() + .filter(isDefined) ?? []; + + const assetIdsAndTimeseriesData: AssetIdsAndTimeseriesData = { + assetIdsWithTimeseries, + timeseriesDatapoints + }; + return assetIdsAndTimeseriesData; + }, + enabled: timeseriesExternalIds.length > 0 + }); +} + +const getLinkFromRelationships = async ( + sdk: CogniteClient, + timeseriesExternalIds: string[], + relationshipResourceTypes: RelationshipResourceType[] +): Promise => { + const dataRelationship = await getRelationships(sdk, { + resourceExternalIds: timeseriesExternalIds, + relationshipResourceTypes + }); + + const assetAndTimeseriesIdsFromRelationship = + dataRelationship?.map((item) => { + const assetAndTimeseriesIds: AssetAndTimeseriesIds = { + assetIds: { externalId: '' }, + timeseriesIds: { externalId: '' } + }; + + if (item.sourceType === 'asset') { + assetAndTimeseriesIds.assetIds.externalId = item.sourceExternalId; + } else if (item.targetType === 'asset') { + assetAndTimeseriesIds.assetIds.externalId = item.targetExternalId; + } + + if (item.sourceType === 'timeSeries') { + assetAndTimeseriesIds.timeseriesIds.externalId = item.sourceExternalId; + } else if (item.targetType === 'timeSeries') { + assetAndTimeseriesIds.timeseriesIds.externalId = item.targetExternalId; + } + + return assetAndTimeseriesIds; + }) ?? []; + + return assetAndTimeseriesIdsFromRelationship; +}; + +const getAssetIdsFromTimeseries = ( + contextualizedAssetNodes: Array, + timeseries: Timeseries, + assetAndTimeseriesIdsFromRelationship: AssetAndTimeseriesIds[] | undefined +): Array | undefined => { + const assetFoundFromTimeseries = contextualizedAssetNodes.find( + (asset) => asset.id === timeseries.assetId + ); + + const assetIdFromTimeseries: ExternalId | undefined = + assetFoundFromTimeseries?.externalId !== undefined + ? { + externalId: assetFoundFromTimeseries.externalId + } + : undefined; + + const itemsFromRelationship = assetAndTimeseriesIdsFromRelationship?.filter( + (item) => + item.timeseriesIds.externalId === timeseries.externalId && + item.assetIds.externalId !== assetIdFromTimeseries?.externalId + ); + + const assetIdsFromRelationship = itemsFromRelationship?.map((item) => { + const itemFound: ExternalId | undefined = + item.assetIds.externalId !== undefined ? { externalId: item.assetIds.externalId } : undefined; + return itemFound; + }); + + const assetIdsFromTimeseriesAndRelationship = assetIdsFromRelationship + ?.concat(assetIdFromTimeseries) + .filter(isDefined); + + return assetIdsFromTimeseriesAndRelationship; +}; + +const generateAssetAndTimeseries = ( + assetFromTimeseries: Array | undefined, + timeseries: Timeseries, + assetAndTimeseriesIdsFromRelationship: AssetAndTimeseriesIds[] | undefined +): AssetIdsAndTimeseries[] => { + const linkedAsset = assetFromTimeseries?.find((asset) => asset.id === timeseries.assetId); + const assetIds: Partial | undefined = + linkedAsset !== undefined + ? { externalId: linkedAsset.externalId, id: linkedAsset.id } + : undefined; + + const assetAndTimeseriesFromLinked: AssetIdsAndTimeseries | undefined = + assetIds !== undefined + ? { + assetIds, + timeseries + } + : undefined; + + const itemsFromRelationship = assetAndTimeseriesIdsFromRelationship?.filter( + (item) => + item.timeseriesIds.externalId === timeseries.externalId && + item.assetIds.externalId !== linkedAsset?.externalId + ); + + const assetAndTimeseriesFromAll: AssetIdsAndTimeseries[] = + itemsFromRelationship?.map((item) => { + const itemFound: AssetIdsAndTimeseries = { + assetIds: item.assetIds, + timeseries + }; + return itemFound; + }) ?? []; + + if (assetAndTimeseriesFromLinked !== undefined) { + assetAndTimeseriesFromAll.push(assetAndTimeseriesFromLinked); + } + + return assetAndTimeseriesFromAll; +}; diff --git a/react-components/src/query/useAssetsByIdsQuery.ts b/react-components/src/query/useAssetsByIdsQuery.ts new file mode 100644 index 00000000000..4bc04582950 --- /dev/null +++ b/react-components/src/query/useAssetsByIdsQuery.ts @@ -0,0 +1,19 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { type IdEither, type Asset } from '@cognite/sdk'; + +import { queryKeys } from '../utilities/queryKeys'; +import { useSDK } from '../components/RevealCanvas/SDKProvider'; +import { getAssetsByIds } from '../hooks/network/getAssetsByIds'; + +export const useAssetsByIdsQuery = (ids: IdEither[]): UseQueryResult => { + const sdk = useSDK(); + return useQuery({ + queryKey: queryKeys.assetsById(ids), + queryFn: async () => await getAssetsByIds(sdk, ids), + enabled: ids.length > 0 + }); +}; diff --git a/react-components/src/query/useTimeseriesByIdsQuery.ts b/react-components/src/query/useTimeseriesByIdsQuery.ts new file mode 100644 index 00000000000..1656825dd61 --- /dev/null +++ b/react-components/src/query/useTimeseriesByIdsQuery.ts @@ -0,0 +1,19 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { type Timeseries, type IdEither } from '@cognite/sdk'; + +import { getTimeseriesByIds } from '../hooks/network/getTimeseriesByIds'; +import { queryKeys } from '../utilities/queryKeys'; +import { useSDK } from '../components/RevealCanvas/SDKProvider'; + +export function useTimeseriesByIdsQuery(ids: IdEither[]): UseQueryResult { + const sdk = useSDK(); + return useQuery({ + queryKey: queryKeys.timeseriesById(ids), + queryFn: async () => await getTimeseriesByIds(sdk, ids), + enabled: ids.length > 0 + }); +} diff --git a/react-components/src/query/useTimeseriesLatestDatapointQuery.ts b/react-components/src/query/useTimeseriesLatestDatapointQuery.ts new file mode 100644 index 00000000000..86277bd2e6b --- /dev/null +++ b/react-components/src/query/useTimeseriesLatestDatapointQuery.ts @@ -0,0 +1,26 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { useQuery } from '@tanstack/react-query'; +import { useSDK } from '../components/RevealCanvas/SDKProvider'; +import { queryKeys } from '../utilities/queryKeys'; +import { type Datapoints } from '@cognite/sdk/'; +import { getTimeseriesLatestDatapoints } from '../hooks/network/getTimeseriesLatestDatapoints'; + +export const useTimeseriesLatestDatapointQuery = ( + timeseriesIds: number[], + isLoadingAssetIdsAndTimeseries: boolean +): Datapoints[] | undefined => { + const sdk = useSDK(); + + const timeseries = timeseriesIds.map((id) => { + return { id }; + }); + const { data: timeseriesDatapoints } = useQuery({ + queryKey: [queryKeys.timeseriesLatestDatapoint(), timeseriesIds], + queryFn: async () => await getTimeseriesLatestDatapoints(sdk, timeseries), + enabled: !isLoadingAssetIdsAndTimeseries && timeseries.length > 0 + }); + + return timeseriesDatapoints; +}; diff --git a/react-components/src/utilities/constants.ts b/react-components/src/utilities/constants.ts index a8ea587a920..98203548de4 100644 --- a/react-components/src/utilities/constants.ts +++ b/react-components/src/utilities/constants.ts @@ -6,3 +6,4 @@ export const DEFAULT_QUERY_STALE_TIME = 1000 * 60 * 10; // 10 minutes export const METERS_TO_FEET = 3.28084; export const FEET_TO_INCHES = 12; export const EMPTY_ARRAY = []; +export const DEFAULT_GLOBAL_TABLE_MAX_RESULT_LIMIT = 1000; diff --git a/react-components/src/utilities/createLabelFilters.ts b/react-components/src/utilities/createLabelFilters.ts new file mode 100644 index 00000000000..5ca36f988a0 --- /dev/null +++ b/react-components/src/utilities/createLabelFilters.ts @@ -0,0 +1,16 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type LabelFilter } from '@cognite/sdk'; +import { isEmpty } from 'lodash'; + +export const createLabelFilter = (labels?: string[]): LabelFilter | undefined => { + if (labels === undefined || isEmpty(labels)) { + return undefined; + } + + return { + containsAny: labels.map((label) => ({ externalId: label })) + }; +}; diff --git a/react-components/src/utilities/queryKeys.ts b/react-components/src/utilities/queryKeys.ts new file mode 100644 index 00000000000..21dae6bc4f4 --- /dev/null +++ b/react-components/src/utilities/queryKeys.ts @@ -0,0 +1,19 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { type IdEither } from '@cognite/sdk/'; + +export const queryKeys = { + all: ['cdf'] as const, + // ASSETS + assetsById: (ids: IdEither[]) => [...assets, ids] as const, + // TIMESERIES + timeseriesById: (ids: IdEither[]) => [...timeseries, ids] as const, + timeseriesLatestDatapoint: () => [...timeseries, 'latest-datapoints'] as const, + // TIMESERIES RELATIONSHIPS WITH ASSETS + timeseriesLinkedToAssets: () => [...timeseries, 'timeseries-linked-assets'] as const +} as const; + +const assets: string[] = [...queryKeys.all, 'assets']; + +const timeseries: string[] = [...queryKeys.all, 'timeseries']; diff --git a/react-components/src/utilities/types.ts b/react-components/src/utilities/types.ts index dee18fde5ab..7101cb245d4 100644 --- a/react-components/src/utilities/types.ts +++ b/react-components/src/utilities/types.ts @@ -1,6 +1,14 @@ /*! * Copyright 2024 Cognite AS */ +import { + type Datapoints, + type Timeseries, + type ExternalId, + type InternalId, + type Metadata, + type Relationship +} from '@cognite/sdk/'; import { type DmsUniqueIdentifier, type Source } from './FdmSDK'; export type FdmInstanceWithView = DmsUniqueIdentifier & { view: Source }; @@ -15,3 +23,38 @@ export function isAssetInstance(instance: InstanceReference): instance is AssetI export function isDmsInstance(instance: InstanceReference): instance is DmsUniqueIdentifier { return 'externalId' in instance && 'space' in instance; } + +export type RelationshipsFilterInternal = { + labels?: string[]; +}; + +export type ExtendedRelationship = { + relation: 'Source' | 'Target'; +} & Relationship; + +export type RelationshipSourceAndTarget = { + source: RelationshipSourceAndTargetData; + target: RelationshipSourceAndTargetData; +}; +export type RelationshipSourceAndTargetData = { + externalId?: string; + id?: number; + metadata?: Metadata; +}; + +export type RelationshipData = ExtendedRelationship & RelationshipSourceAndTarget; + +export type AssetIdsAndTimeseries = { + assetIds?: Partial; + timeseries?: Timeseries | undefined; +}; + +export type AssetAndTimeseriesIds = { + assetIds: Partial; + timeseriesIds: Partial; +}; + +export type AssetIdsAndTimeseriesData = { + assetIdsWithTimeseries: AssetIdsAndTimeseries[]; + timeseriesDatapoints: Datapoints[]; +};