UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1 lines 119 kB
{"version":3,"file":"c8y-ngx-components-widgets-implementations-datapoints-table.mjs","sources":["../../widgets/implementations/datapoints-table/datapoints-table-widget.model.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/datapoints-table-view.service.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/adjust-aggregated-time-range.pipe.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/apply-range-class.pipe.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/column-title.pipe.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/virtual-scroll-listener.directive.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/datapoints-table/dynamic-column.directive.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/datapoints-table/datapoints-table.component.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/datapoints-table/datapoints-table.component.html","../../widgets/implementations/datapoints-table/datapoints-table-view/datapoints-table-view.component.ts","../../widgets/implementations/datapoints-table/datapoints-table-view/datapoints-table-view.component.html","../../widgets/implementations/datapoints-table/datapoints-table-config/datapoints-table-config.component.ts","../../widgets/implementations/datapoints-table/datapoints-table-config/datapoints-table-config.component.html","../../widgets/implementations/datapoints-table/c8y-ngx-components-widgets-implementations-datapoints-table.ts"],"sourcesContent":["import { IFetchResponse, ISeries } from '@c8y/client';\nimport { GlobalAutoRefreshWidgetConfig } from '@c8y/ngx-components';\nimport { KPIDetails } from '@c8y/ngx-components/datapoint-selector';\nimport { MinMaxValues, SourceId, TimeStamp } from '@c8y/ngx-components/datapoints-export-selector';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport { GlobalContextState } from '@c8y/ngx-components/global-context';\nimport { Interval, INTERVAL_VALUES } from '@c8y/ngx-components/interval-picker';\n\nexport const DEFAULT_DPT_REFRESH_INTERVAL_VALUE = 30_000;\n\nexport type DatapointTableMapKey = `${TimeStamp}_${DeviceName}`;\n/**\n * Represents a mapping where the key is a datapoint identifier containing the date and device name, and the value is an array of data point table items or null.\n */\nexport type DataPointsTableMap = Map<DatapointTableMapKey, (DatapointTableItem | null)[]>;\n\ntype DeviceName = string;\n\n/**\n * Represents a map of datapoints series data.\n * The key of the map is a source, and the value is the data for requested series.\n */\nexport type DatapointsSeriesDataMap = Map<SourceId, ISeries>;\n/**\n * Determines which values will be displayed as values for datapoints.\n * e.g. if user chose 'min', only the minimum values will be displayed.\n */\nexport type RenderType = keyof typeof RENDER_TYPES_LABELS;\n/**\n * Represents an object where key is a timestamp and value is an array\n * where first record contains min and max values for a corresponding timestamp.\n */\nexport type MeasurementRanges = {\n [key: TimeStamp]: Array<MinMaxValues>;\n};\n/**\n * Represents a value object where key is an object with min and max properties with its corresponding values.\n */\nexport type Value = {\n [key in keyof MinMaxValues]: number;\n};\n\n/**\n * Represents the legacy's global time context date selection of the widget.\n */\nexport const DATE_SELECTION_VALUES = {\n dashboard_context: 'dashboard_context',\n config: 'config',\n view_and_config: 'view_and_config'\n} as const;\n\nexport const DATE_SELECTION_VALUES_ARR = [\n DATE_SELECTION_VALUES.dashboard_context,\n DATE_SELECTION_VALUES.config,\n DATE_SELECTION_VALUES.view_and_config\n] as const;\n\nexport const DATE_SELECTION_LABELS = {\n config: gettext('Widget configuration') as 'Widget configuration',\n view_and_config: gettext('Widget and widget configuration') as 'Widget and widget configuration',\n dashboard_context: gettext('Dashboard time range') as 'Dashboard time range'\n} as const;\n\nexport const REFRESH_INTERVAL_VALUES_ARR = [5_000, 10_000, 15_000, 30_000, 60_000];\n\nexport const RENDER_TYPES_LABELS = {\n min: gettext('Minimum') as 'Minimum',\n max: gettext('Maximum') as 'Maximum',\n area: gettext('Area') as 'Area'\n} as const;\n\nexport const INTERVAL_VALUES_ARR = [\n INTERVAL_VALUES.minutes,\n INTERVAL_VALUES.hours,\n INTERVAL_VALUES.days,\n INTERVAL_VALUES.weeks,\n INTERVAL_VALUES.months,\n INTERVAL_VALUES.custom\n] as const;\n\nexport const TIME_RANGE_INTERVAL_LABELS = {\n minutes: gettext('Last minute') as 'Last minute',\n hours: gettext('Last hour') as 'Last hour',\n days: gettext('Last day') as 'Last day',\n weeks: gettext('Last week') as 'Last week',\n months: gettext('Last month') as 'Last month',\n custom: gettext('Custom') as 'Custom'\n} as const;\n\nexport const DURATION_OPTIONS = [\n {\n id: INTERVAL_VALUES.minutes,\n label: TIME_RANGE_INTERVAL_LABELS.minutes,\n unit: INTERVAL_VALUES.minutes,\n amount: 1\n },\n {\n id: INTERVAL_VALUES.hours,\n label: TIME_RANGE_INTERVAL_LABELS.hours,\n unit: INTERVAL_VALUES.hours,\n amount: 1\n },\n {\n id: INTERVAL_VALUES.days,\n label: TIME_RANGE_INTERVAL_LABELS.days,\n unit: INTERVAL_VALUES.days,\n amount: 1\n },\n {\n id: INTERVAL_VALUES.weeks,\n label: TIME_RANGE_INTERVAL_LABELS.weeks,\n unit: INTERVAL_VALUES.weeks,\n amount: 1\n },\n {\n id: INTERVAL_VALUES.months,\n label: TIME_RANGE_INTERVAL_LABELS.months,\n unit: INTERVAL_VALUES.months,\n amount: 1\n },\n { id: INTERVAL_VALUES.custom, label: TIME_RANGE_INTERVAL_LABELS.custom }\n];\n\nexport interface ColorRangeBoundaries {\n yellowRangeMin: number;\n yellowRangeMax: number;\n redRangeMin: number;\n redRangeMax: number;\n}\n\nexport interface DatapointWithValues extends KPIDetails {\n seriesUnit?: string;\n values: MeasurementRanges;\n}\n\nexport interface Duration {\n id: string;\n label: string;\n unit?: string;\n amount?: number;\n}\n\nexport interface TableColumnHeader {\n deviceName: string;\n label: string;\n renderType: string;\n unit: string;\n}\n\nexport interface DateRange {\n dateFrom: string;\n dateTo: string;\n}\n\nexport interface DatapointsTableConfig\n extends Partial<GlobalContextState>,\n LegacyGlobalTimeContextProperties {\n context?: number;\n datapoints: KPIDetails[];\n decimalPlaces?: number;\n interval: Interval['id'];\n realtime: boolean;\n selected?: object | null;\n sliderChange?: boolean | null;\n}\n\ninterface LegacyGlobalTimeContextProperties extends GlobalAutoRefreshWidgetConfig {\n /**\n * Array that contains global time context dateFrom and dateTo.\n */\n date?: string[];\n dateFrom: string;\n dateTo: string;\n displayDateSelection: boolean;\n displaySettings: {\n globalTimeContext: boolean;\n globalRealtimeContext: boolean;\n globalAggregationContext: boolean;\n globalAutoRefreshContext: boolean;\n };\n widgetInstanceGlobalTimeContext?: boolean | null;\n globalDateSelector?: keyof typeof DATE_SELECTION_VALUES;\n}\n\nexport interface DatapointTableItem {\n dateAndTime: string;\n deviceName: string;\n fragment: string;\n label: string;\n redRangeMax?: number;\n redRangeMin?: number;\n renderType: string;\n series: string;\n value: Value;\n yellowRangeMax?: number;\n yellowRangeMin?: number;\n}\n\nexport interface GroupedDatapointTableItem {\n dateAndTime: string;\n deviceName: string;\n rowItems: ({\n fragment: string;\n label: string;\n redRangeMax?: number;\n redRangeMin?: number;\n renderType: string;\n series: string;\n value: Value;\n yellowRangeMax?: number;\n yellowRangeMin?: number;\n } | null)[];\n}\n\nexport interface SeriesDataWithResponse {\n source: SourceId;\n data: ISeries;\n res: IFetchResponse;\n}\n","import { Injectable } from '@angular/core';\nimport { IResult, ISeries, ISeriesFilter, aggregationType } from '@c8y/client';\nimport { KPIDetails } from '@c8y/ngx-components/datapoint-selector';\nimport {\n DataFetchingService,\n DatapointsValuesDataMap,\n MinMaxValues,\n SourceId\n} from '@c8y/ngx-components/datapoints-export-selector';\nimport { INTERVAL_VALUES, Interval } from '@c8y/ngx-components/interval-picker';\nimport {\n DURATION_OPTIONS,\n DataPointsTableMap,\n DatapointTableItem,\n DatapointTableMapKey,\n DatapointWithValues,\n DatapointsSeriesDataMap,\n DatapointsTableConfig,\n Duration,\n GroupedDatapointTableItem,\n MeasurementRanges,\n SeriesDataWithResponse,\n TableColumnHeader,\n Value\n} from '../datapoints-table-widget.model';\n\nfunction mapToSourceValueObject([key, value]: [SourceId, string[]]): {\n key: SourceId;\n value: string[];\n} {\n return { key, value };\n}\n\n@Injectable({\n providedIn: 'root'\n})\nexport class DatapointsTableViewService {\n constructor(private dataFetchingService: DataFetchingService) {}\n\n /**\n * Filters out inactive data points from the given array.\n *\n * @param datapoints - The array of data points to filter.\n * @returns An array of data points that are active.\n */\n filterOutInactiveDatapoints(datapoints: KPIDetails[]): KPIDetails[] {\n return datapoints.filter((datapoint: KPIDetails) => datapoint.__active);\n }\n\n hasMultipleDatapoints(datapoints: KPIDetails[]): boolean {\n const ids = datapoints.map(dp => dp.__target.id);\n return ids.length > 1;\n }\n\n /**\n * Returns a map of active data points device IDs with their corresponding series.\n *\n * Example output:\n * ```typescript\n * new Map([\n * [\n * \"844657202\",\n * [\n * \"c8y_Temperature.T\"\n * ]\n * ],\n * [\n * \"32666427\",\n * [\n * \"c8y_Battery.Battery\"\n * ]\n * ]\n * ]);\n * ```\n * @param datapoints - An array of data points.\n * @returns A map where the key is the data point ID and the value is an array of data point series.\n */\n groupSeriesByDeviceId(activeDatapoints: KPIDetails[]): DatapointsValuesDataMap {\n return activeDatapoints.reduce(\n (map: DatapointsValuesDataMap, { fragment, series, __target: { id } }: KPIDetails) => {\n const value = `${fragment}.${series}`;\n const existingValue = map.get(id) ?? [];\n map.set(id, [...existingValue, value]);\n return map;\n },\n new Map<SourceId, string[]>()\n );\n }\n\n /**\n * Retrieves the active data points series data and returns it as a map.\n *\n * @param datapointsValuesDataMap - A map of data point sources with their associated series.\n * @param config - The configuration of the data points table.\n * @param roundSeconds - Whether to round the seconds or not.\n * If true, the seconds will be rounded to 0.\n * If false, the seconds will be displayed as they are.\n * @returns A Promise that resolves to a Map object with data point IDs as keys and DataObject as values or undefined when all series has forbidden access.\n */\n async getAllActiveSeriesDataMap(\n datapointsValuesDataMap: DatapointsValuesDataMap,\n config: DatapointsTableConfig,\n roundSeconds: boolean\n ): Promise<Map<string | number, ISeries>> {\n const promises = Array.from(datapointsValuesDataMap).map(async ([source, series]) => {\n const params: ISeriesFilter = {\n dateFrom: config.dateTimeContext.dateFrom,\n dateTo: config.dateTimeContext.dateTo,\n source,\n series,\n aggregationType: config.aggregation as aggregationType\n };\n const { data, res }: IResult<ISeries> = await this.dataFetchingService.fetchSeriesData(\n params,\n roundSeconds\n );\n\n return { source, data, res };\n });\n\n const results: SeriesDataWithResponse[] = await Promise.all(promises);\n\n const allSeriesHasForbiddenAccess = results.every(item => item.res?.status === 403);\n if (allSeriesHasForbiddenAccess) {\n throw new Error('Access forbidden: All series have a 403 status code');\n }\n\n const filteredResults: SeriesDataWithResponse[] =\n this.filterOutElementsWithForbiddenResponses(results);\n\n const resultMap: DatapointsSeriesDataMap = new Map<string | number, ISeries>();\n filteredResults.forEach(result => resultMap.set(result.source, result.data));\n\n return resultMap;\n }\n\n /**\n * Creates an array of DatapointsWithValues based on the provided datapoints and datapointsSeriesDataMap.\n *\n * Finds an index of a current data point within series object and based on that index filters values array.\n *\n * @param datapoints - An array of data points.\n * @param datapointsSeriesDataMap - A map containing series data for data points.\n * @returns An array of DatapointsWithValues.\n */\n getDatapointsWithValues(\n datapoints: KPIDetails[],\n datapointsSeriesDataMap: DatapointsSeriesDataMap\n ): DatapointWithValues[] {\n return datapoints.map((dp: KPIDetails) => {\n const seriesData: ISeries = datapointsSeriesDataMap.get(dp.__target.id);\n\n if (!seriesData) {\n return { ...dp, values: {} };\n }\n\n // Find an index of a corresponding datapoint data, within series object.\n const datapointSeriesArrayIndex = seriesData.series.findIndex(\n s => s.name === dp.series && s.type === dp.fragment\n );\n\n const valuesFilteredByDatapointSeriesArrayIndex: MeasurementRanges = Object.fromEntries(\n Object.entries(seriesData.values).map(([key, arr]) => [\n key,\n arr.filter((_, index) => index === datapointSeriesArrayIndex)\n ])\n );\n\n return {\n ...dp,\n seriesUnit: seriesData.series[datapointSeriesArrayIndex]?.unit,\n values: valuesFilteredByDatapointSeriesArrayIndex\n };\n });\n }\n\n /**\n * Creates the column headers for the devices in the data points table.\n *\n * @param datapointsWithValues - An array of data points.\n * @returns An array of column headers for the devices.\n */\n getColumnHeaders(datapointsWithValues: KPIDetails[]): TableColumnHeader[] {\n return datapointsWithValues.map(\n ({ __target: { name }, label, renderType, unit }): TableColumnHeader => {\n return { deviceName: name, label, renderType, unit };\n }\n );\n }\n\n mapDatapointsWithValuesToList(datapointsWithValues: DatapointWithValues[]): DatapointTableItem[] {\n return datapointsWithValues.flatMap(dp => {\n if (!dp.values) {\n return [];\n }\n return Object.entries(dp.values).flatMap(([date, valuesArray]) => {\n const value = this.findMinMaxValues(valuesArray);\n if (value == null) {\n return [];\n }\n return [\n {\n dateAndTime: date,\n deviceName: dp.__target.name,\n fragment: dp.fragment,\n label: dp.label,\n redRangeMax: dp.redRangeMax,\n redRangeMin: dp.redRangeMin,\n renderType: dp.renderType,\n series: dp.series,\n value: value,\n yellowRangeMax: dp.yellowRangeMax,\n yellowRangeMin: dp.yellowRangeMin\n }\n ];\n });\n });\n }\n\n /**\n * Finds the overall minimum and maximum values from an array of objects containing 'min' and 'max' properties.\n *\n * If the array contains only one object, that object's 'min' and 'max' values will be returned.\n *\n * @param valuesArray - An array with objects, where each contains 'min' and 'max' properties.\n * @returns An object with the smallest 'min' and largest 'max' values found in the array.\n *\n * @example\n * const values = [\n * { min: 1, max: 10 }\n * ];\n *\n * const result = findMinMaxValues(values);\n * // result is { min: 1, max: 10 }\n */\n findMinMaxValues(valuesArray: MinMaxValues[]): Value | null {\n const validItems = valuesArray.filter(\n (item): item is Value => item && item.min != null && item.max != null\n );\n\n if (validItems.length === 0) {\n return null;\n }\n\n const initialValue: Value = { min: validItems[0].min, max: validItems[0].max };\n\n return validItems.reduce<Value>(\n (acc, item) => ({\n min: Math.min(acc.min, item.min),\n max: Math.max(acc.max, item.max)\n }),\n initialValue\n );\n }\n\n /**\n * Groups a list of data points by date and device, based on given references.\n *\n * @param dataList - The list of data points to be grouped.\n * @param references - The column headers that serve as references for grouping.\n * @returns An array of grouped data points, where each group corresponds to a unique date and device.\n */\n groupByDateAndDevice(\n dataList: DatapointTableItem[],\n references: TableColumnHeader[]\n ): GroupedDatapointTableItem[] {\n const map = this.generateDataPointMap(dataList, references);\n return this.mergeDatapoints(map);\n }\n\n /**\n * Generates and populates a map with data points.\n *\n * This function processes the provided data points and organizes them into a map structure\n * where each key is a unique combination of date and device identifiers, and the value is an\n * array of data points (or null values) associated with that key. This structured data is then\n * used in the data point table to render the data appropriately.\n *\n * @param dataList - The list of data point table items to be processed.\n * @param columnsHeadersReferences - The list of column headers used to determine the order and structure of the map values.\n * @returns A map where the key is a datapoint identifier containing the date and device name, and the value is an array of data point table items or null.\n */\n generateDataPointMap(\n dataList: DatapointTableItem[],\n columnsHeadersReferences: TableColumnHeader[]\n ): DataPointsTableMap {\n // Map to store the data points indexed by a unique identifier.\n const map: DataPointsTableMap = new Map<DatapointTableMapKey, (DatapointTableItem | null)[]>();\n\n dataList.forEach(obj => {\n // Generate a unique identifier for each data point using the date and device name.\n const dateKey = this.toISOFormat(obj.dateAndTime);\n const datapointIdentifier: DatapointTableMapKey = `${dateKey}_${obj.deviceName}`;\n\n // Initialize the map entry if it does not exist with a unique identifier.\n if (!map.has(datapointIdentifier)) {\n map.set(datapointIdentifier, Array(columnsHeadersReferences.length).fill(null));\n }\n\n // Find the index of the reference that matches the current data point.\n const matchingColumnIndex = columnsHeadersReferences.findIndex(\n ref => ref.deviceName === obj.deviceName && ref.label === obj.label\n );\n\n // Update the map entry with the data point.\n const tableItem: DatapointTableItem[] = map.get(datapointIdentifier);\n if (tableItem) {\n tableItem[matchingColumnIndex] = { ...obj };\n }\n });\n\n return map;\n }\n\n /**\n * Merges the data points from the given map into an array of grouped data point table items.\n *\n * @param map - The map containing the data points to be merged.\n * @returns An array of grouped data point table items.\n */\n mergeDatapoints(map: DataPointsTableMap): GroupedDatapointTableItem[] {\n const mergedData: GroupedDatapointTableItem[] = [];\n\n map.forEach((values, datapointIdentifier) => {\n const [dateKey, deviceName] = datapointIdentifier.split('_');\n const validDataPoint = values.some(item => item && Object.keys(item.value).length > 0);\n\n if (validDataPoint) {\n mergedData.push({\n dateAndTime: dateKey,\n deviceName: deviceName,\n rowItems: values.map(item => (item ? { ...item } : null))\n });\n }\n });\n\n return mergedData;\n }\n\n sortDataByDateDescending(data: GroupedDatapointTableItem[]): GroupedDatapointTableItem[] {\n return data.sort(\n (a, b) => new Date(b.dateAndTime).getTime() - new Date(a.dateAndTime).getTime()\n );\n }\n\n /**\n * Prepares the updated time range based on the selected interval.\n *\n * In case of a 'custom' interval or no quantity, the original date range is returned.\n *\n * @param interval - The selected interval type.\n * @returns An object containing the `dateFrom` and `dateTo` in ISO string format.\n */\n prepareTimeRange(\n interval: Interval['id'],\n dateFromInput: string,\n dateToInput: string\n ): { dateFrom: string; dateTo: string } {\n if (!interval || interval === INTERVAL_VALUES.custom) {\n return { dateFrom: dateFromInput, dateTo: dateToInput };\n }\n\n const selected: Duration = DURATION_OPTIONS.find(\n selectedDuration => selectedDuration.id === interval\n );\n\n if (!selected?.amount) {\n return { dateFrom: dateFromInput, dateTo: dateToInput };\n }\n\n const now = new Date();\n\n return {\n dateFrom: this.subtractTime(now, selected.amount, selected.unit).toISOString(),\n dateTo: now.toISOString()\n };\n }\n\n /**\n * Subtracts an amount of time from a given date.\n *\n * @param date - The original date.\n * @param amount - The amount of time units to subtract.\n * @param unit - The unit of time to subtract (e.g., minutes, hours, days, weeks, months).\n * @returns A new date with the specified time subtracted.\n */\n subtractTime(date: Date, amount: number, unit: string): Date {\n const newDate = new Date(date);\n\n switch (unit) {\n case INTERVAL_VALUES.minutes:\n newDate.setUTCMinutes(newDate.getUTCMinutes() - amount);\n break;\n case INTERVAL_VALUES.hours:\n newDate.setUTCHours(newDate.getUTCHours() - amount);\n break;\n case INTERVAL_VALUES.days:\n newDate.setUTCDate(newDate.getUTCDate() - amount);\n break;\n case INTERVAL_VALUES.weeks:\n newDate.setUTCDate(newDate.getUTCDate() - amount * 7);\n break;\n case INTERVAL_VALUES.months:\n this.subtractMonthsAndAdjustDay(newDate, amount);\n break;\n }\n return newDate;\n }\n\n getSeriesWithoutPermissionToRead(\n activeDatapointsSeriesData: Map<string | number, ISeries> | undefined,\n activeDatapointsIdsWithSeries: DatapointsValuesDataMap\n ): { key: SourceId; value: string[] }[] {\n if (!activeDatapointsSeriesData) {\n // Returns all activeDatapointsIdsWithSeries entries if activeDatapointsSeriesData is undefined.\n // It means that the user does not have permission to see any of the selected datapoints data.\n return Array.from(activeDatapointsIdsWithSeries, mapToSourceValueObject);\n }\n\n const availableSources = new Set(activeDatapointsSeriesData.keys());\n\n return Array.from(activeDatapointsIdsWithSeries)\n .filter(([source]) => !availableSources.has(source))\n .map(mapToSourceValueObject);\n }\n\n hasSecondsAndMillisecondsEqualZero(timeString: string): boolean {\n if (!timeString) {\n return false;\n }\n\n const date = new Date(timeString);\n if (isNaN(date.getTime())) {\n return false;\n }\n\n return date.getUTCSeconds() === 0 && date.getUTCMilliseconds() === 0;\n }\n\n /**\n * Converts a date string to ISO format.\n *\n * @param dateStr - The date string to convert.\n * @returns The ISO format of the given date string.\n */\n private toISOFormat(dateStr: string): string {\n return new Date(dateStr).toISOString();\n }\n\n private filterOutElementsWithForbiddenResponses(\n seriesDataWithResponse: SeriesDataWithResponse[]\n ): SeriesDataWithResponse[] {\n return seriesDataWithResponse.filter(item => {\n return !(item.res?.status === 403);\n });\n }\n\n private subtractMonthsAndAdjustDay(date: Date, monthsToSubtract: number): void {\n const currentMonth = date.getUTCMonth();\n const expectedTargetMonth = this.calculateTargetMonth(currentMonth, monthsToSubtract);\n\n date.setUTCMonth(currentMonth - monthsToSubtract);\n\n const actualMonth = date.getUTCMonth();\n const dayDoesNotExistInTargetMonth = actualMonth !== expectedTargetMonth;\n\n if (dayDoesNotExistInTargetMonth) {\n this.setToLastDayOfPreviousMonth(date);\n }\n }\n\n /**\n * Calculates the target month number (0-11) after subtracting months from the current month.\n * Handles negative month numbers by normalizing them to the valid 0-11 range.\n *\n * Examples:\n * - January(0) - 1 month = December(11)\n * - March(2) - 4 months = November(10)\n * - December(11) - 1 month = November(10)\n *\n * @param currentMonth - Current month (0-11, where 0 is January)\n * @param monthsToSubtract - Number of months to subtract\n * @returns Normalized month number in range 0-11\n */\n private calculateTargetMonth(currentMonth: number, monthsToSubtract: number): number {\n return (currentMonth - monthsToSubtract + 12) % 12;\n }\n\n /**\n * Sets the date to the last day of the previous month.\n * Using 0 as dateValue makes JavaScript automatically calculate\n * last day of previous month, per JavaScript Date API behavior.\n * @param date - Date to modify\n */\n private setToLastDayOfPreviousMonth(date: Date): void {\n date.setUTCDate(0);\n }\n}\n","import { Pipe, PipeTransform } from '@angular/core';\nimport { aggregationType as AggregationTypeEnum } from '@c8y/client';\nimport { AggregationOption } from '@c8y/ngx-components';\n\n/**\n * A pipe that adjusts the aggregated time range based on the aggregation type.\n *\n * ```html\n * '9:00' | adjustAggregatedTimeRange: config.aggregation (e.g.:HOURLY)\n * ```\n * The output will be '9:00-10:00'.\n */\n@Pipe({\n name: 'adjustAggregatedTimeRange',\n standalone: true\n})\nexport class AdjustAggregatedTimeRangePipe implements PipeTransform {\n /**\n * Transforms the input time based on the aggregation type.\n * @param inputTime The input time string.\n * @param aggregationType The type of aggregation (optional).\n * @returns The transformed time string.\n */\n transform(inputTime: string, aggregationType?: AggregationOption): string {\n if (!aggregationType) {\n return inputTime;\n }\n\n if (aggregationType === AggregationTypeEnum.DAILY) {\n return '';\n }\n\n const date = this.createDateFromInput(inputTime);\n const isTwelveHoursFormat = this.isTwelveHoursFormat(inputTime);\n\n switch (aggregationType) {\n case AggregationTypeEnum.HOURLY:\n return this.getHourlyTimeRange(date, isTwelveHoursFormat);\n case AggregationTypeEnum.MINUTELY:\n return this.getMinutelyTimeRange(date, isTwelveHoursFormat);\n default:\n throw new Error('Unsupported aggregation type');\n }\n }\n\n /**\n * Creates a date object from the input time string.\n * @param inputTime The input time string.\n * @returns The created Date object.\n */\n private createDateFromInput(inputTime: string): Date {\n const defaultDate = '1970-01-01 ';\n const isPM = /PM/i.test(inputTime);\n const cleanedTime = inputTime.replace(/AM|PM/i, '').trim();\n\n this.validateTimeFormat(cleanedTime, inputTime);\n\n const dateTimeString = `${defaultDate}${cleanedTime}`;\n const date = new Date(dateTimeString);\n\n if (isNaN(date.getTime())) {\n throw new Error('Invalid input time');\n }\n\n return this.adjustForPMTime(date, isPM);\n }\n\n /**\n * Validates if the time string matches the required format and has valid values.\n * @param time The time string to validate.\n * @param originalInput The original input string (including AM/PM if present).\n * @throws Error if the time format is invalid or values are out of range.\n */\n private validateTimeFormat(time: string, originalInput: string): void {\n const parts = time.split(':');\n this.validateTimeParts(parts);\n\n const [hoursStr, minutesStr, secondsStr] = parts;\n this.validateTimeDigits(hoursStr, minutesStr, secondsStr);\n\n const { hours, minutes, seconds } = this.parseTimeComponents(hoursStr, minutesStr, secondsStr);\n this.validateTimeRanges(hours, minutes, seconds);\n this.validateTimeFormat24Hour(hours, originalInput);\n }\n\n private validateTimeParts(parts: string[]): void {\n if (parts.length < 2 || parts.length > 3) {\n throw new Error('Invalid input time');\n }\n }\n\n private validateTimeDigits(hoursStr: string, minutesStr: string, secondsStr?: string): void {\n if (\n !this.isValidNumberString(hoursStr) ||\n !this.isValidNumberString(minutesStr) ||\n (secondsStr !== undefined && !this.isValidNumberString(secondsStr))\n ) {\n throw new Error('Invalid input time');\n }\n }\n\n private parseTimeComponents(hoursStr: string, minutesStr: string, secondsStr?: string) {\n return {\n hours: Number(hoursStr),\n minutes: Number(minutesStr),\n seconds: secondsStr ? Number(secondsStr) : 0\n };\n }\n\n private validateTimeRanges(hours: number, minutes: number, seconds: number): void {\n if (hours > 23 || hours < 0 || minutes > 59 || minutes < 0 || seconds > 59 || seconds < 0) {\n throw new Error('Invalid input time');\n }\n }\n\n private validateTimeFormat24Hour(hours: number, originalInput: string): void {\n if (hours > 12 && this.hasAmPm(originalInput)) {\n throw new Error('Invalid input time');\n }\n }\n\n /**\n * Checks if string contains only digits and is 1-2 characters long.\n * @param value String to check\n * @returns boolean indicating if string is valid\n */\n private isValidNumberString(value: string): boolean {\n return (\n value.length > 0 &&\n value.length <= 2 &&\n value.split('').every(char => char >= '0' && char <= '9')\n );\n }\n\n /**\n * Checks if the input time has AM/PM markers.\n * @param input The input time string to check.\n * @returns boolean indicating if the input contains AM/PM.\n */\n private hasAmPm(input: string): boolean {\n return /AM|PM/i.test(input);\n }\n\n /**\n * Adjusts the date for PM times by adding 12 hours when necessary.\n * @param date The date object to adjust.\n * @param isPM Boolean indicating if the time is PM.\n * @returns The adjusted Date object.\n */\n private adjustForPMTime(date: Date, isPM: boolean): Date {\n const hours = date.getHours();\n if (isPM && hours < 12) {\n date.setHours(hours + 12);\n } else if (!isPM && hours === 12) {\n date.setHours(0);\n }\n return date;\n }\n\n /**\n * Checks if the input time is in twelve hours format.\n * @param inputTime The input time string.\n * @returns True if the input time is in twelve hours format, false otherwise.\n */\n private isTwelveHoursFormat(inputTime: string): boolean {\n return /AM|PM/i.test(inputTime);\n }\n\n /**\n * Gets the hourly time range for the given date.\n * @param date The date object.\n * @param twelveHoursFormat Indicates whether to use twelve hours format.\n * @returns The hourly time range string.\n */\n private getHourlyTimeRange(date: Date, twelveHoursFormat: boolean): string {\n const nextHour = new Date(date.getTime());\n nextHour.setHours(date.getHours() + 1);\n return `${this.formatTime(date, twelveHoursFormat, true)}-${this.formatTime(nextHour, twelveHoursFormat, true)}`;\n }\n\n /**\n * Gets the minutely time range for the given date.\n * @param date The date object.\n * @param twelveHoursFormat Indicates whether to use twelve hours format.\n * @returns The minutely time range string.\n */\n private getMinutelyTimeRange(date: Date, twelveHoursFormat: boolean): string {\n const nextMinute = new Date(date.getTime());\n nextMinute.setMinutes(date.getMinutes() + 1);\n return `${this.formatTime(date, twelveHoursFormat, false)}-${this.formatTime(nextMinute, twelveHoursFormat, false)}`;\n }\n\n /**\n * Formats the given date into a time string.\n * @param date The date to format.\n * @param usePeriod Indicates whether to include the period (AM/PM) in the formatted time.\n * @param useHourOnly Indicates whether to include only the hour part in the formatted time.\n * @returns The formatted time string.\n */\n private formatTime(date: Date, usePeriod: boolean, useHourOnly: boolean): string {\n const hours = date.getHours();\n const minutes = date.getMinutes().toString().padStart(2, '0');\n if (usePeriod) {\n const period = hours >= 12 ? 'PM' : 'AM';\n const formattedHours = hours % 12 === 0 ? 12 : hours % 12;\n return `${formattedHours}:${useHourOnly ? '00' : minutes} ${period}`;\n } else {\n return `${hours.toString().padStart(2, '0')}:${useHourOnly ? '00' : minutes}`;\n }\n }\n}\n","import { Pipe, PipeTransform } from '@angular/core';\nimport { ColorRangeBoundaries } from '../datapoints-table-widget.model';\n\n/**\n * Applies CSS classes based on the value's range.\n */\n@Pipe({\n name: 'applyRangeClass',\n standalone: true\n})\nexport class ApplyRangeClassPipe implements PipeTransform {\n /**\n * Transforms the input value based on the specified ranges.\n *\n * @param value - Initial value used to determine the CSS class.\n * @param ranges - An object containing the min and max range values for yellow and red colors.\n * @returns The CSS class to be applied.\n */\n transform(value: number, ranges: ColorRangeBoundaries): string {\n if (value == null) {\n return;\n }\n\n if (value >= ranges.yellowRangeMin && value < ranges.yellowRangeMax) {\n return 'text-warning';\n } else if (value >= ranges.redRangeMin && value <= ranges.redRangeMax) {\n return 'text-danger';\n }\n return 'default';\n }\n}\n","import { Pipe, PipeTransform } from '@angular/core';\nimport { TranslateService } from '@ngx-translate/core';\nimport { TableColumnHeader, RENDER_TYPES_LABELS } from '../datapoints-table-widget.model';\n\n/**\n * Creates a column header title message.\n *\n * ```html\n * title=\"{{ header | columnTitle }}\"\n * ```\n * The output will be e.g.: 'c8y_Temperature → T [T] (Area)'.\n */\n@Pipe({\n name: 'columnTitle',\n standalone: true\n})\nexport class ColumnTitlePipe implements PipeTransform {\n constructor(private translateService: TranslateService) {}\n\n /**\n * Transforms the column header into a formatted string with label and optionally unit and render type.\n *\n * @param columnHeader - The column header object.\n * @returns The formatted string with label, unit, and render type.\n */\n transform(columnHeader: TableColumnHeader): string {\n const label = columnHeader.label.trim();\n const unit = columnHeader.unit ? `[${columnHeader.unit.trim()}]` : '';\n const renderType = columnHeader.renderType\n ? this.translateService.instant(RENDER_TYPES_LABELS[columnHeader.renderType])\n : '';\n\n if (!renderType) {\n return `${label} ${unit}`.trim();\n }\n\n return `${label} ${unit} (${renderType})`.trim();\n }\n}\n","import {\n AfterViewInit,\n Directive,\n ElementRef,\n EventEmitter,\n Input,\n NgZone,\n OnDestroy,\n Output\n} from '@angular/core';\n\n/**\n * Directive to listen for scroll events on a virtual scroll container.\n * Emits `scrolled` and `scrolledToTop` events.\n *\n * The directive listens for scroll events on a virtual scroll container.\n * - When the container is scrolled by at least 50 pixels (or a custom threshold), the `scrolled` event is emitted.\n * - When the container is scrolled to the top, the `scrolledToTop` event is emitted.\n *\n */\n@Directive({\n selector: '[c8yVirtualScrollListener]',\n standalone: true\n})\nexport class VirtualScrollListenerDirective implements AfterViewInit, OnDestroy {\n /**\n * Pixel threshold for emitting the scrolled event.\n */\n @Input() scrollThreshold = 50;\n /**\n * Event emitted when the virtual scroll container is scrolled by at least 50 pixels.\n */\n @Output() scrolled = new EventEmitter<void>();\n /**\n * Event emitted when the virtual scroll container is scrolled to the top.\n */\n @Output() scrolledToTop = new EventEmitter<void>();\n\n private virtualScrollContainer: HTMLElement;\n private lastScrollTop = 0;\n\n constructor(\n private el: ElementRef,\n private ngZone: NgZone\n ) {}\n\n ngAfterViewInit(): void {\n this.virtualScrollContainer = this.el.nativeElement.querySelector(\n '.cdk-virtual-scroll-viewport'\n );\n\n if (this.virtualScrollContainer) {\n this.ngZone.runOutsideAngular(() => {\n this.virtualScrollContainer.addEventListener('scroll', this.onScroll.bind(this));\n });\n }\n }\n\n ngOnDestroy(): void {\n if (this.virtualScrollContainer) {\n this.virtualScrollContainer.removeEventListener('scroll', this.onScroll.bind(this));\n }\n }\n\n /**\n * Handles the scroll event. Emits `scrolled` event if scrolled by at least `scrollThreshold` pixels\n * and `scrolledToTop` event if scrolled to the top.\n * @param event - The scroll event.\n */\n private onScroll(event: Event): void {\n const target = event.target as HTMLElement;\n const scrollTop = target.scrollTop;\n\n if (Math.abs(scrollTop - this.lastScrollTop) > this.scrollThreshold) {\n this.lastScrollTop = scrollTop;\n this.ngZone.run(() => this.scrolled.emit());\n }\n\n if (scrollTop === 0) {\n this.ngZone.run(() => this.scrolledToTop.emit());\n }\n }\n}\n","import { Directive, ElementRef, Input, OnInit, Renderer2 } from '@angular/core';\n\n@Directive({\n selector: '[c8yDynamicColumn]',\n standalone: true\n})\nexport class DynamicColumnDirective implements OnInit {\n @Input('c8yDynamicColumn') numberOfColumns: number;\n\n constructor(\n private el: ElementRef,\n private renderer: Renderer2\n ) {}\n\n ngOnInit() {\n this.updateColumnClass();\n }\n\n private updateColumnClass() {\n let className = '';\n switch (this.numberOfColumns) {\n case 1:\n className = 'col-md-12';\n break;\n case 2:\n className = 'col-md-6';\n break;\n default:\n if (this.numberOfColumns >= 3) {\n className = 'col-md-3';\n }\n break;\n }\n\n if (className) {\n this.renderer.addClass(this.el.nativeElement, className);\n }\n }\n}\n","import {\n ChangeDetectionStrategy,\n Component,\n EventEmitter,\n Input,\n OnChanges,\n Output\n} from '@angular/core';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport {\n AggregationOption,\n CommonModule,\n DocsModule,\n DynamicComponentAlert,\n DynamicComponentAlertAggregator,\n DynamicComponentModule,\n ListGroupModule\n} from '@c8y/ngx-components';\nimport { KPIDetails } from '@c8y/ngx-components/datapoint-selector';\nimport {\n ColorRangeBoundaries,\n GroupedDatapointTableItem,\n TableColumnHeader\n} from '../../datapoints-table-widget.model';\nimport { AdjustAggregatedTimeRangePipe } from '../adjust-aggregated-time-range.pipe';\nimport { ApplyRangeClassPipe } from '../apply-range-class.pipe';\nimport { ColumnTitlePipe } from '../column-title.pipe';\nimport { VirtualScrollListenerDirective } from '../virtual-scroll-listener.directive';\nimport { DynamicColumnDirective } from './dynamic-column.directive';\n\n@Component({\n selector: 'c8y-datapoints-table',\n templateUrl: './datapoints-table.component.html',\n host: { class: 'd-col flex-grow' },\n standalone: true,\n imports: [\n AdjustAggregatedTimeRangePipe,\n ApplyRangeClassPipe,\n ColumnTitlePipe,\n CommonModule,\n DocsModule,\n DynamicColumnDirective,\n DynamicComponentModule,\n ListGroupModule,\n VirtualScrollListenerDirective\n ],\n /**\n * Used to prevent 'getRangeValues' from being called on every change detection cycle.\n */\n changeDetection: ChangeDetectionStrategy.OnPush\n})\nexport class DatapointsTableComponent implements OnChanges {\n @Input() aggregationType: AggregationOption;\n @Input() datapointsTableItems: GroupedDatapointTableItem[];\n @Input() devicesColumnHeaders: TableColumnHeader[];\n @Input() decimalPlaces: number;\n @Input() hasMultipleDatapoints: boolean;\n @Input() isLoading: boolean;\n @Input() seriesWithoutPermissionToReadCount: number;\n @Output() isScrolling = new EventEmitter<boolean>();\n\n hasNoPermissionsToReadAnyMeasurement = false;\n missingAllPermissionsAlert = new DynamicComponentAlertAggregator();\n\n /**\n * Default fraction size format for numbers with decimal places.\n */\n private fractionSize = '1.2-2';\n\n ngOnChanges(): void {\n if (typeof this.decimalPlaces === 'number' && !Number.isNaN(this.decimalPlaces)) {\n this.fractionSize = `1.${this.decimalPlaces}-${this.decimalPlaces}`;\n }\n\n if (!this.seriesWithoutPermissionToReadCount) {\n return;\n }\n\n this.missingAllPermissionsAlert.clear();\n this.handleNoPermissionErrorMessage();\n }\n\n onListScrolled(): void {\n this.isScrolling.emit(true);\n }\n\n onListScrolledToTop(): void {\n this.isScrolling.emit(false);\n }\n\n getRangeValues(row: KPIDetails): ColorRangeBoundaries {\n return {\n yellowRangeMin: row.yellowRangeMin,\n yellowRangeMax: row.yellowRangeMax,\n redRangeMin: row.redRangeMin,\n redRangeMax: row.redRangeMax\n };\n }\n\n /**\n * Determines the fraction size format based on whether the number is an integer or has decimal places.\n *\n * @param value - The number to be formatted.\n * @returns Returns '1.0-0' if the number is an integer, otherwise returns the current fraction size.\n */\n getFractionSize(value: number): string {\n return value % 1 === 0 ? '1.0-0' : this.fractionSize;\n }\n\n private handleNoPermissionErrorMessage(): void {\n this.hasNoPermissionsToReadAnyMeasurement =\n this.seriesWithoutPermissionToReadCount === this.devicesColumnHeaders.length;\n\n if (this.hasNoPermissionsToReadAnyMeasurement) {\n this.showMessageForMissingPermissionsForAllSeries();\n }\n }\n\n private showMessageForMissingPermissionsForAllSeries(): void {\n this.missingAllPermissionsAlert.addAlerts(\n new DynamicComponentAlert({\n allowHtml: true,\n text: gettext(`<p>To view data, you must meet at least one of these criteria:</p>\n <ul>\n <li>\n Have\n <b>READ permission for \"Measurements\" permission type</b>\n (either as a global role or for the specific source)\n </li>\n <li>\n Be the\n <b>owner of the source</b>\n you want to export data from\n </li>\n </ul>\n <p>Don't meet these requirements? Contact your system administrator for assistance.</p>`),\n type: 'system'\n })\n );\n }\n}\n","<ng-container *ngIf=\"!hasNoPermissionsToReadAnyMeasurement; else missingAllPermissions\">\n <div class=\"c8y-cq-440\">\n <div\n class=\"hidden-xs c8y-list__item c8y-list--timeline hidden-cq\"\n [ngClass]=\"{ 'separator-top-bottom': devicesColumnHeaders.length > 0 }\"\n >\n <div class=\"d-flex container-fluid\">\n <div class=\"c8y-list--timeline__item__date\"></div>\n <div class=\"c8y-list__item__block flex-grow min-width-0\">\n <div class=\"c8y-list__item__body\">\n <div class=\"d-flex row\">\n <ng-container *ngIf=\"hasMultipleDatapoints\">\n <div\n class=\"min-width-0\"\n [title]=\"'Device' | translate\"\n [c8yDynamicColumn]=\"devicesColumnHeaders.length\"\n >\n <span class=\"text-medium text-truncate\">\n {{ 'Device' | translate }}\n </span>\n </div>\n </ng-container>\n <!-- Data points column headers -->\n <ng-container *ngFor=\"let header of devicesColumnHeaders\">\n <div\n class=\"min-width-0\"\n title=\"{{ header | columnTitle }}\"\n [c8yDynamicColumn]=\"devicesColumnHeaders.length\"\n >\n <span class=\"text-medium text-truncate\">\n {{ header.label }} {{ header.unit }}\n </span>\n </div>\n </ng-container>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <!-- The record list -->\n <ng-container *ngIf=\"!isLoading; else loading\">\n <ng-container *ngIf=\"datapointsTableItems.length; else emptyState\">\n <c8y-list-group\n class=\"p-t-8 flex-grow c8y-cq-440\"\n c8yVirtualScrollListener\n (scrolled)=\"onListScrolled()\"\n (scrolledToTop)=\"onListScrolledToTop()\"\n >\n <c8y-li-timeline\n *c8yFor=\"\n let tableItem of { data: datapointsTableItems, res: null };\n enableVirtualScroll: true;\n virtualScrollElementSize: 48;\n virtualScrollStrategy: 'fixed'\n \"\n >\n {{ tableItem.dateAndTime | c8yDate: 'mediumDate' }}\n {{\n tableItem.dateAndTime\n | c8yDate: 'mediumTime'\n | adjustAggregatedTimeRange: aggregationType\n }}\n <c8y-li>\n <c8y-li-body>\n <div class=\"d-flex row\">\n <div\n class=\"min-width-0\"\n [c8yDynamicColumn]=\"devicesColumnHeaders.length\"\n *ngIf=\"devicesColumnHeaders.length > 1\"\n [attr.data-label]=\"'Device' | translate\"\n >\n <div\n class=\"text-truncate\"\n title=\"{{ tableItem.deviceName }}\"\n >\n {{ tableItem.deviceName }}\n </div>\n </div>\n <!-- Data point value row cells -->\n <ng-container *ngFor=\"let row of tableItem.rowItems\">\n <ng-container *ngIf=\"row !== null; else emptyRowContent\">\n <ng-container [ngSwitch]=\"row.renderType\">\n <div\n [c8yDynamicColumn]=\"devicesColumnHeaders.length\"\n [ngClass]=\"row.value.min ?? null | applyRangeClass: getRangeValues(row)\"\n *ngSwitchCase=\"'min'\"\n [attr.data-label]=\"row.label\"\n >\n <div\n class=\"text-truncate\"\n title=\"{{ row.value.min ?? '' | number: getFractionSize(row.value.min) }}\"\n >\n {{ row.value.min ?? '' | number: getFractionSize(row.value.min) }}\n </div>\n </div>\n <div\n class=\"col-md-4\"\n [ngClass]=\"row.value.max ?? null | applyRangeClass: getRangeValues(row)\"\n *ngSwitchCase=\"'max'\"\n >\n <div\n class=\"text-truncate\"\n title=\"{{ row.value.max ?? '' | number: getFractionSize(row.value.max) }}\"\n >\n {{ row.value.max ?? '' | number: getFractionSize(row.value.max) }}\n </div>\n </div>\n <div\n [c8yDynamicColumn]=\"devicesColumnHeaders.length\"\n *ngSwitchCase=\"'area'\"\n >\n <span\n class=\"text-truncate\"\n title=\"{{ row.value.min ?? '' | number: getFractionSize(row.value.min) }}\"\n [ngClass]=\"row.value.min ?? null | applyRangeClass: getRangeValues(row)\"\n >\n {{ row.value.min ?? '' | number: getFractionSize(row.value.min) }}\n </span>\n ...\n <span\n class=\"text-truncate\"\n title=\"{{ row.value.max ?? '' | number: getFractionSize(row.value.max) }}\"\n [ngClass]=\"row.value.max ?? null | applyRangeClass: getRangeValues(row)\"\n >\n {{ row.value.max ?? '' | number: getFractionSize(row.value.max) }}\n </span>\n </div>\n <div\n [c8yDynamicColumn]=\"devicesColumnHeaders.length\"\n *ngSwitchDefault\n >\n <span\n class=\"text-truncate\"\n title=\"{{ row.value.min ?? '' | number: getFractionSize(row.value.min) }}\"\n [ngClass]=\"row.value.min ?? null | applyRangeClass: getRangeValues(row)\"\n >\n {{ row.value.min ?? '' | number: getFractionSize(row.value.min) }}\n </span>\n </div>\n </ng-container>\n </ng-container>\n <ng-template #emptyRowContent>\n <div [c8yDynamicColumn]=\"devicesColumnHeaders.length\"></div>\n </ng-template>\n </ng-container>\n </div>\n </c8y-li-body>\n </c8y-li>\n </c8y-li-timeline>\n </c8y-list-group>\n </ng-container>\n </ng-container>\n</ng-contai