@c8y/ngx-components 1024.20.0 → 1024.20.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/fesm2022/c8y-ngx-components-application-select.mjs +1 -1
  2. package/fesm2022/c8y-ngx-components-application-select.mjs.map +1 -1
  3. package/fesm2022/c8y-ngx-components-asset-properties.mjs +3 -2
  4. package/fesm2022/c8y-ngx-components-asset-properties.mjs.map +1 -1
  5. package/fesm2022/c8y-ngx-components-context-dashboard.mjs +78 -17
  6. package/fesm2022/c8y-ngx-components-context-dashboard.mjs.map +1 -1
  7. package/fesm2022/c8y-ngx-components-datapoint-selector.mjs +1 -1
  8. package/fesm2022/c8y-ngx-components-datapoint-selector.mjs.map +1 -1
  9. package/fesm2022/c8y-ngx-components-device-profile.mjs +1 -1
  10. package/fesm2022/c8y-ngx-components-device-profile.mjs.map +1 -1
  11. package/fesm2022/c8y-ngx-components-echart-models.mjs.map +1 -1
  12. package/fesm2022/c8y-ngx-components-echart.mjs +571 -401
  13. package/fesm2022/c8y-ngx-components-echart.mjs.map +1 -1
  14. package/fesm2022/c8y-ngx-components-ecosystem-shared.mjs +1 -1
  15. package/fesm2022/c8y-ngx-components-ecosystem-shared.mjs.map +1 -1
  16. package/fesm2022/c8y-ngx-components-ecosystem.mjs +1 -1
  17. package/fesm2022/c8y-ngx-components-ecosystem.mjs.map +1 -1
  18. package/fesm2022/c8y-ngx-components-icon-selector.mjs +4 -4
  19. package/fesm2022/c8y-ngx-components-icon-selector.mjs.map +1 -1
  20. package/fesm2022/c8y-ngx-components-operations-stepper-bulk-type-configuration.mjs +1 -1
  21. package/fesm2022/c8y-ngx-components-operations-stepper-bulk-type-configuration.mjs.map +1 -1
  22. package/fesm2022/c8y-ngx-components-operations-stepper-bulk-type-device-profile.mjs +1 -1
  23. package/fesm2022/c8y-ngx-components-operations-stepper-bulk-type-device-profile.mjs.map +1 -1
  24. package/fesm2022/c8y-ngx-components-operations-stepper-bulk-type-software.mjs +1 -1
  25. package/fesm2022/c8y-ngx-components-operations-stepper-bulk-type-software.mjs.map +1 -1
  26. package/fesm2022/c8y-ngx-components-protocol-lpwan.mjs +2 -2
  27. package/fesm2022/c8y-ngx-components-protocol-lpwan.mjs.map +1 -1
  28. package/fesm2022/c8y-ngx-components-repository-configuration.mjs +4 -4
  29. package/fesm2022/c8y-ngx-components-repository-configuration.mjs.map +1 -1
  30. package/fesm2022/c8y-ngx-components-repository-firmware.mjs +2 -2
  31. package/fesm2022/c8y-ngx-components-repository-firmware.mjs.map +1 -1
  32. package/fesm2022/c8y-ngx-components-repository-shared.mjs +2 -2
  33. package/fesm2022/c8y-ngx-components-repository-shared.mjs.map +1 -1
  34. package/fesm2022/c8y-ngx-components-repository-software.mjs +1 -1
  35. package/fesm2022/c8y-ngx-components-repository-software.mjs.map +1 -1
  36. package/fesm2022/c8y-ngx-components-widgets-definitions-html-widget.mjs +17 -1
  37. package/fesm2022/c8y-ngx-components-widgets-definitions-html-widget.mjs.map +1 -1
  38. package/fesm2022/c8y-ngx-components-widgets-definitions-scada.mjs +21 -1
  39. package/fesm2022/c8y-ngx-components-widgets-definitions-scada.mjs.map +1 -1
  40. package/fesm2022/c8y-ngx-components-widgets-implementations-asset-table.mjs +60 -31
  41. package/fesm2022/c8y-ngx-components-widgets-implementations-asset-table.mjs.map +1 -1
  42. package/fesm2022/c8y-ngx-components-widgets-implementations-datapoints-graph.mjs +15 -7
  43. package/fesm2022/c8y-ngx-components-widgets-implementations-datapoints-graph.mjs.map +1 -1
  44. package/fesm2022/c8y-ngx-components.mjs +199 -21
  45. package/fesm2022/c8y-ngx-components.mjs.map +1 -1
  46. package/locales/de.po +6 -0
  47. package/locales/es.po +6 -0
  48. package/locales/fr.po +6 -0
  49. package/locales/ja_JP.po +6 -0
  50. package/locales/ko.po +6 -0
  51. package/locales/nl.po +6 -0
  52. package/locales/pl.po +6 -0
  53. package/locales/pt_BR.po +8 -2
  54. package/locales/zh_CN.po +6 -0
  55. package/locales/zh_TW.po +6 -0
  56. package/package.json +1 -1
  57. package/types/c8y-ngx-components-asset-properties.d.ts.map +1 -1
  58. package/types/c8y-ngx-components-context-dashboard.d.ts +37 -3
  59. package/types/c8y-ngx-components-context-dashboard.d.ts.map +1 -1
  60. package/types/c8y-ngx-components-echart-models.d.ts +2 -0
  61. package/types/c8y-ngx-components-echart-models.d.ts.map +1 -1
  62. package/types/c8y-ngx-components-echart.d.ts +70 -1
  63. package/types/c8y-ngx-components-echart.d.ts.map +1 -1
  64. package/types/c8y-ngx-components-widgets-implementations-asset-table.d.ts +5 -0
  65. package/types/c8y-ngx-components-widgets-implementations-asset-table.d.ts.map +1 -1
  66. package/types/c8y-ngx-components-widgets-implementations-datapoints-graph.d.ts.map +1 -1
  67. package/types/c8y-ngx-components.d.ts +141 -4
  68. package/types/c8y-ngx-components.d.ts.map +1 -1
@@ -11,7 +11,7 @@ import * as echarts from 'echarts';
11
11
  import * as i1 from '@ngx-translate/core';
12
12
  import { TranslateService } from '@ngx-translate/core';
13
13
  import * as i1$3 from '@c8y/client';
14
- import { AlarmStatus, aggregationType, MeasurementService } from '@c8y/client';
14
+ import { aggregationType, AlarmStatus, MeasurementService } from '@c8y/client';
15
15
  import * as i6 from '@angular/router';
16
16
  import * as i4 from '@c8y/ngx-components/alarms';
17
17
  import { AlarmsModule, AlarmSeverityToIconPipe, AlarmSeverityToLabelPipe } from '@c8y/ngx-components/alarms';
@@ -482,10 +482,252 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
482
482
  type: Injectable
483
483
  }] });
484
484
 
485
+ class ChartHelpersService {
486
+ constructor() {
487
+ /** Vertical pixel distance between adjacent alarm/event rows in the carrier render path. */
488
+ this.ALARM_EVENT_ROW_STEP = 28;
489
+ /** Radius in pixels of an alarm/event mark (a 24 px circle), used to keep rows clear of the grid edges. */
490
+ this.ALARM_EVENT_MARK_RADIUS = 12;
491
+ /** Clearance in pixels kept at each grid edge when only the chart height (not the grid) can be measured. */
492
+ this.ALARM_EVENT_ROW_EDGE_PADDING = 12;
493
+ /** Row span assumed before the first layout, when neither the grid nor the chart height is measurable yet. */
494
+ this.ALARM_EVENT_ROW_FALLBACK_SPAN = 200;
495
+ /** Top data value of the fixed [0, 2] carrier y-axis, used to measure the usable row span in pixels. */
496
+ this.ALARM_EVENT_CARRIER_AXIS_MAX = 2;
497
+ }
498
+ /**
499
+ * Places each alarm/event type on its own row in the carrier render path by applying a per-type
500
+ * vertical `symbolOffset` to its markPoints. Without a datapoint line to anchor them the marks all
501
+ * resolve to the single carrier y-coordinate, so two types raised seconds apart would otherwise be
502
+ * drawn on top of each other. Rows are centered around the carrier line and the step is capped so
503
+ * they all fit inside the plot area at the current height.
504
+ * @param series - The carrier series to mutate in place.
505
+ * @param echartsInstance - The chart instance, used to measure the available row span.
506
+ */
507
+ assignAlarmEventRowOffsets(series, echartsInstance) {
508
+ const markPointSeries = series.filter(s => s.markPoint);
509
+ // Distinct types in stable order — one row per type, shared by all devices of that type.
510
+ const types = [];
511
+ for (const s of markPointSeries) {
512
+ const [type] = s.id.split('/');
513
+ if (!types.includes(type)) {
514
+ types.push(type);
515
+ }
516
+ }
517
+ const centerIndex = (types.length - 1) / 2;
518
+ const rowStep = this.getAlarmEventRowStep(types.length, echartsInstance);
519
+ markPointSeries.forEach(s => {
520
+ const withMarkPoint = s;
521
+ const data = withMarkPoint.markPoint?.data;
522
+ if (!data) {
523
+ return;
524
+ }
525
+ const [type] = s.id.split('/');
526
+ const offsetY = (types.indexOf(type) - centerIndex) * rowStep;
527
+ withMarkPoint.markPoint.data = data.map(point => ({
528
+ ...point,
529
+ symbolOffset: [0, offsetY]
530
+ }));
531
+ });
532
+ }
533
+ /**
534
+ * Reapplies the per-type row offsets to the current carrier series and pushes only the markPoint
535
+ * data back to the chart. Called on resize so the rows are re-spaced for the new height instead of
536
+ * keeping the spacing computed at build time.
537
+ * @param echartsInstance - The chart instance.
538
+ * @param isAlarmEventOnlyChart - Whether the alarms/events-only (carrier) chart is currently shown;
539
+ * the datapoint path also has markPoint series and must never be touched.
540
+ */
541
+ reapplyAlarmEventRowOffsets(echartsInstance, isAlarmEventOnlyChart) {
542
+ if (!isAlarmEventOnlyChart || !echartsInstance) {
543
+ return;
544
+ }
545
+ const series = (echartsInstance.getOption()?.['series'] ?? []);
546
+ const markPointSeries = series.filter(s => s.markPoint);
547
+ if (!markPointSeries.length) {
548
+ return;
549
+ }
550
+ this.assignAlarmEventRowOffsets(markPointSeries, echartsInstance);
551
+ echartsInstance.setOption({
552
+ series: markPointSeries.map(s => ({
553
+ id: s.id,
554
+ markPoint: { data: s.markPoint.data }
555
+ }))
556
+ });
557
+ }
558
+ /**
559
+ * Cleans the widget configuration which will be sent to Gainsight by removing unnecessary properties.
560
+ * @param config The widget configuration to clean.
561
+ * @returns The cleaned widget configuration.
562
+ */
563
+ getConfigSummaryForGainsight(config) {
564
+ return {
565
+ datapointCount: config.datapoints?.length ?? 0,
566
+ alarmsEventsCount: config.alarmsEventsConfigs?.length ?? 0,
567
+ interval: config.interval,
568
+ forceMergeDatapoints: config.forceMergeDatapoints,
569
+ mergeMatchingDatapoints: config.mergeMatchingDatapoints,
570
+ showSlider: config.showSlider,
571
+ dateFrom: config.dateFrom,
572
+ dateTo: config.dateTo
573
+ };
574
+ }
575
+ findMatchingDatapoint(datapoints, selected) {
576
+ if (!selected)
577
+ return undefined;
578
+ return datapoints.find(dp => this.getTargetId(dp) === this.getTargetId(selected) &&
579
+ dp.fragment === selected.fragment &&
580
+ dp.series === selected.series);
581
+ }
582
+ /**
583
+ * Maps a computed auto-aggregation interval string (e.g. `'5m'`, `'3h'`, `'7d'`) to
584
+ * the nearest standard `aggregationType` for non-time-series tenants.
585
+ * - seconds / minutes → MINUTELY (finest supported granularity)
586
+ * - hours → HOURLY
587
+ * - days → DAILY
588
+ */
589
+ mapIntervalToAggregationType(interval) {
590
+ if (interval.endsWith('d'))
591
+ return aggregationType.DAILY;
592
+ if (interval.endsWith('h'))
593
+ return aggregationType.HOURLY;
594
+ if (interval.endsWith('m') || interval.endsWith('s'))
595
+ return aggregationType.MINUTELY;
596
+ return null;
597
+ }
598
+ /**
599
+ * Computes the aggregation interval string (e.g. `30s`, `5m`, `24h`, `7d`) that
600
+ * targets approximately `desiredDatapoints` buckets within the given time range.
601
+ * The integer part is capped at 999 to satisfy the API constraint.
602
+ */
603
+ calculateAutoAggregationInterval(dateFrom, dateTo, desiredDatapoints) {
604
+ const rangeMs = new Date(dateTo).valueOf() - new Date(dateFrom).valueOf();
605
+ const intervalMs = rangeMs / Math.max(1, desiredDatapoints);
606
+ // If each bucket would be sub-second, the zoom is fine enough to show raw data
607
+ if (intervalMs <= 1_000)
608
+ return null;
609
+ // Use the most natural human-readable unit.
610
+ // e.g. 24h / 300 dp → 288 s → 5 m (not 288s)
611
+ const totalSeconds = Math.max(1, Math.round(intervalMs / 1_000));
612
+ if (totalSeconds < 60)
613
+ return `${Math.min(999, totalSeconds)}s`;
614
+ const totalMinutes = Math.round(totalSeconds / 60);
615
+ if (totalMinutes < 60)
616
+ return `${Math.min(999, totalMinutes)}m`;
617
+ const totalHours = Math.round(totalSeconds / 3_600);
618
+ if (totalHours < 24)
619
+ return `${Math.min(999, totalHours)}h`;
620
+ const totalDays = Math.max(1, Math.round(totalSeconds / 86_400));
621
+ return `${Math.min(999, totalDays)}d`;
622
+ }
623
+ /**
624
+ * Scans raw DpWithValues to determine which datapoints should be collapsed to a single
625
+ * series — either because all per-function values are identical at every timestamp (the
626
+ * aggregation interval is too fine — each interval holds a single measurement — to
627
+ * differentiate functions).
628
+ */
629
+ detectCollapsedDatapoints(dps) {
630
+ const collapsed = new Set();
631
+ for (const dp of dps) {
632
+ const dpId = `${dp.__target?.id}${dp.fragment}${dp.series}`;
633
+ const valueEntries = Object.values(dp.values ?? {});
634
+ if (!valueEntries.length)
635
+ continue;
636
+ const renderTypeConfigs = dp.renderTypeConfigs;
637
+ const renderAsBand = !!dp.renderAsBand;
638
+ let fnsToCompare = [];
639
+ let isBand = false;
640
+ if (renderTypeConfigs) {
641
+ const enabled = Object.keys(renderTypeConfigs).filter(k => renderTypeConfigs[k]?.enabled);
642
+ const hasMinMax = enabled.includes('min') && enabled.includes('max');
643
+ if (renderAsBand && hasMinMax) {
644
+ isBand = true;
645
+ }
646
+ else if (enabled.length >= 2) {
647
+ fnsToCompare = enabled;
648
+ }
649
+ else {
650
+ continue;
651
+ }
652
+ }
653
+ else if (dp.renderTypes && dp.renderTypes.length > 1) {
654
+ const fns = dp.renderTypes.filter(rt => rt !== 'area');
655
+ if (fns.length >= 2) {
656
+ fnsToCompare = fns;
657
+ }
658
+ else {
659
+ continue;
660
+ }
661
+ }
662
+ else {
663
+ continue;
664
+ }
665
+ if (isBand) {
666
+ const allSame = valueEntries.every(vals => vals[0]?.min === vals[0]?.max);
667
+ if (allSame)
668
+ collapsed.add(dpId);
669
+ }
670
+ else {
671
+ const allSame = valueEntries.every(vals => {
672
+ const first = vals[0]?.[fnsToCompare[0]];
673
+ if (first === undefined || first === null)
674
+ return false;
675
+ return fnsToCompare.slice(1).every(fn => vals[0]?.[fn] === first);
676
+ });
677
+ if (allSame)
678
+ collapsed.add(dpId);
679
+ }
680
+ }
681
+ return collapsed;
682
+ }
683
+ getTargetId(obj) {
684
+ return obj?.__target?.id ?? obj?.target;
685
+ }
686
+ /**
687
+ * Vertical distance between adjacent alarm/event rows, capped so all rows fit inside the plot area.
688
+ * Uses the default step until the rows would overflow, then compresses to the measured span.
689
+ * @param rowCount - The number of type rows to lay out.
690
+ * @param echartsInstance - The chart instance, used to measure the available span.
691
+ * @returns The per-row step in pixels.
692
+ */
693
+ getAlarmEventRowStep(rowCount, echartsInstance) {
694
+ if (rowCount < 2) {
695
+ return this.ALARM_EVENT_ROW_STEP;
696
+ }
697
+ return Math.min(this.ALARM_EVENT_ROW_STEP, Math.max(this.getAlarmEventRowSpan(echartsInstance), 0) / (rowCount - 1));
698
+ }
699
+ /**
700
+ * Pixel height available for the centered rows, measured from the plot grid (the marks live in the
701
+ * grid, which is shorter than the chart by the axis-label band) minus one mark diameter of
702
+ * clearance. Falls back to the chart height, then a fixed span, before the first layout exists.
703
+ * @param echartsInstance - The chart instance.
704
+ * @returns The usable vertical span in pixels.
705
+ */
706
+ getAlarmEventRowSpan(echartsInstance) {
707
+ if (typeof echartsInstance?.convertToPixel === 'function') {
708
+ const top = echartsInstance.convertToPixel({ yAxisIndex: 0 }, this.ALARM_EVENT_CARRIER_AXIS_MAX);
709
+ const bottom = echartsInstance.convertToPixel({ yAxisIndex: 0 }, 0);
710
+ if (Number.isFinite(top) && Number.isFinite(bottom) && bottom > top) {
711
+ return bottom - top - this.ALARM_EVENT_MARK_RADIUS * 2;
712
+ }
713
+ }
714
+ // Before the first layout there is no grid to measure, so fall back to the chart's own height.
715
+ const chartHeight = typeof echartsInstance?.getHeight === 'function' ? echartsInstance.getHeight() : 0;
716
+ return chartHeight > 0
717
+ ? chartHeight - this.ALARM_EVENT_ROW_EDGE_PADDING * 2
718
+ : this.ALARM_EVENT_ROW_FALLBACK_SPAN;
719
+ }
720
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: ChartHelpersService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
721
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: ChartHelpersService }); }
722
+ }
723
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: ChartHelpersService, decorators: [{
724
+ type: Injectable
725
+ }] });
726
+
485
727
  const INDEX_HTML = '/index.html';
486
728
  const DEFAULT_DATAPOINT_COLOR = '#1776bf';
487
729
  class EchartsOptionsService {
488
- constructor(datePipe, yAxisService, chartTypesService, severityIconPipe, severityLabelPipe, translate, router) {
730
+ constructor(datePipe, yAxisService, chartTypesService, severityIconPipe, severityLabelPipe, translate, router, chartHelpersService) {
489
731
  this.datePipe = datePipe;
490
732
  this.yAxisService = yAxisService;
491
733
  this.chartTypesService = chartTypesService;
@@ -493,11 +735,25 @@ class EchartsOptionsService {
493
735
  this.severityLabelPipe = severityLabelPipe;
494
736
  this.translate = translate;
495
737
  this.router = router;
738
+ this.chartHelpersService = chartHelpersService;
496
739
  this.highlightedSeriesName = null;
497
740
  this.AGGREGATED_SERIES_TYPE = 'aggr';
498
741
  this.TOOLTIP_WIDTH = 300;
499
742
  this.COMPACT_TOOLTIP_WIDTH = 360;
500
743
  this.COMPACT_TOOLTIP_THRESHOLD = 5;
744
+ /**
745
+ * Fixed y-coordinate the alarm/event marks are anchored to when no datapoint line exists to
746
+ * host them (the "carrier" render path). It sits in the middle of the hidden [0, 2] y-axis.
747
+ */
748
+ this.ALARM_EVENT_CARRIER_Y = 1;
749
+ /**
750
+ * Vertical pixel offset applied to event marks so that an event and an alarm occurring at the
751
+ * same time no longer paint on top of each other. Alarms stay on the anchor line, events float
752
+ * just above it. Applied in both render paths (with and without a datapoint line).
753
+ */
754
+ this.EVENT_MARK_VERTICAL_OFFSET = -18;
755
+ /** True while the chart shows only alarms/events (carrier path); gates the resize-driven row reflow. */
756
+ this.isAlarmEventOnlyChart = false;
501
757
  }
502
758
  getChartOptions(datapointsWithValues, timeRange, showSplitLines, events, alarms, displayOptions, selectedTimeRange, aggregatedDatapoint, sliderZoomUsed = false) {
503
759
  displayOptions = {
@@ -526,23 +782,9 @@ class EchartsOptionsService {
526
782
  if (sliderZoomUsed) {
527
783
  intervalInMs = this.calculateExtendedIntervalInMs(timeRange.interval || 'hours', timeRange);
528
784
  }
785
+ this.isAlarmEventOnlyChart = !!displayOptions.showOnlyAlarmsOrEvents;
529
786
  if (displayOptions.showOnlyAlarmsOrEvents) {
530
- return {
531
- xAxis: {
532
- min: timeRange.dateFrom,
533
- max: timeRange.dateTo,
534
- type: 'time'
535
- },
536
- yAxis: {
537
- show: false
538
- },
539
- tooltip: {
540
- trigger: 'axis',
541
- // intended empty for now. Later we can add a tooltip for more info on the hovered alarm/event
542
- formatter: () => ''
543
- },
544
- series: this.createAlarmEventTypeSeries(alarms, events)
545
- };
787
+ return this.getAlarmEventOnlyChartOptions(alarms, events, timeRange, displayOptions);
546
788
  }
547
789
  return {
548
790
  grid: {
@@ -889,141 +1131,138 @@ class EchartsOptionsService {
889
1131
  series['typeOfSeries'] !== 'event' &&
890
1132
  legendSelected?.[series['name']] !== false);
891
1133
  this.processSeries(allDataPointSeries, XAxisValue, YAxisReadings, displayOptions);
892
- // find event and alarm of the same type as the hovered markedLine or markedPoint
893
- const event = allEvents.find(e => e.type === params.data.itemType && params.data.value === e.time);
894
- const alarm = allAlarms.find(a => a.type === params.data.itemType && params.data.value === a.lastUpdated) ??
895
- allAlarms.find(a => a.type === params.data.itemType && params.data.value === a.creationTime); // fallback to creationTime for cleared alarms.
1134
+ const { itemType, value: markValue, coord } = params.data;
1135
+ const markTimestamp = markValue ?? coord?.[0];
1136
+ const event = allEvents.find(e => e.type === itemType && markTimestamp === e.time) ??
1137
+ allEvents.find(e => e.type === itemType);
1138
+ const alarm = allAlarms.find(a => a.type === itemType && markTimestamp === a.lastUpdated) ??
1139
+ allAlarms.find(a => a.type === itemType && markTimestamp === a.creationTime) ?? // cleared alarms
1140
+ allAlarms.find(a => a.type === itemType);
896
1141
  let value = '';
897
1142
  if (event) {
898
1143
  value = this.processEvent(event);
899
1144
  }
900
- if (alarm) {
901
- this.processAlarm(alarm).then(alarmVal => {
902
- value = alarmVal;
903
- YAxisReadings.push(value);
904
- const options = this.echartsInstance?.getOption();
905
- if (!options.tooltip || !Array.isArray(options.tooltip)) {
906
- return;
907
- }
908
- const updatedOptions = {
909
- tooltip: options['tooltip'][0]
910
- };
911
- if (!updatedOptions.tooltip) {
912
- return;
913
- }
914
- updatedOptions.tooltip.formatter = `<div style="width: ${this.TOOLTIP_WIDTH}px">${YAxisReadings.join('')}</div>`;
915
- updatedOptions.tooltip.transitionDuration = 0;
916
- updatedOptions.tooltip.position = this.tooltipPosition();
917
- this.echartsInstance?.setOption(updatedOptions);
918
- return;
919
- });
1145
+ else if (alarm) {
1146
+ value = this.processAlarm(alarm);
920
1147
  }
921
1148
  YAxisReadings.push(value);
922
- return `<div style="width: 300px">${YAxisReadings.join('')}</div>`;
1149
+ return `<div style="width: ${this.TOOLTIP_WIDTH}px">${YAxisReadings.join('')}</div>`;
923
1150
  }
924
1151
  /**
925
- * Creates the series for alarms and events by their type.
926
- * @param alarms - The list of alarms.
927
- * @param events - The list of events.
928
- * @returns The series options for the chart.
1152
+ * Builds alarm/event markLine + markPoint series when there is no datapoint line to anchor them.
1153
+ * @param alarms - The list of alarms to render.
1154
+ * @param events - The list of events to render.
1155
+ * @param timeRange - The visible time range (used to span the flat carrier values).
1156
+ * @param displayOptions - The display options forwarded to `getAlarmOrEventSeries`.
1157
+ * @returns The markLine/markPoint series for the alarms/events-only view.
929
1158
  */
930
- createAlarmEventTypeSeries(alarms, events) {
1159
+ getAlarmEventCarrierSeries(alarms, events, timeRange, displayOptions) {
1160
+ const deviceIds = Array.from(new Set([...alarms, ...events].map(item => item.source?.id).filter(Boolean)));
1161
+ const flatValues = {
1162
+ [new Date(timeRange.dateFrom).toISOString()]: [
1163
+ { min: this.ALARM_EVENT_CARRIER_Y, max: this.ALARM_EVENT_CARRIER_Y }
1164
+ ],
1165
+ [new Date(timeRange.dateTo).toISOString()]: [
1166
+ { min: this.ALARM_EVENT_CARRIER_Y, max: this.ALARM_EVENT_CARRIER_Y }
1167
+ ]
1168
+ };
931
1169
  const series = [];
932
- let yValue = 10;
933
- // Group alarms and events by type
934
- const alarmTypes = Array.from(new Set(alarms.map(a => a.type)));
935
- const eventTypes = Array.from(new Set(events.map(e => e.type)));
936
- // Alarms by type
937
- for (const type of alarmTypes) {
938
- const alarmsOfType = alarms.filter(a => a.type === type);
939
- // Background circle series
940
- series.push({
941
- type: 'scatter',
942
- name: `${type}-circle`,
943
- data: alarmsOfType.map(a => [a.creationTime, yValue]),
944
- symbol: 'circle',
945
- symbolSize: 24,
946
- itemStyle: {
947
- color: alarmsOfType[0]?.color || '#EE6666'
948
- },
949
- z: 2
950
- });
951
- // Foreground icon series
952
- series.push({
953
- type: 'scatter',
954
- name: `${type}-icon`,
955
- data: alarmsOfType.map(a => {
956
- const isCleared = a.status === AlarmStatus.CLEARED;
957
- return {
958
- value: [isCleared ? a.lastUpdated : a.creationTime, yValue],
959
- itemStyle: { color: 'white' },
960
- symbol: isCleared ? ICONS_MAP.CLEARED : ICONS_MAP[a.severity],
961
- symbolSize: 16
962
- };
963
- }),
964
- z: 3
965
- });
966
- // If alarm is cleared, also show the cleared icon at lastUpdated
967
- alarmsOfType.forEach(a => {
968
- if (a.status === AlarmStatus.CLEARED && a.lastUpdated !== a.creationTime) {
969
- series.push({
970
- type: 'scatter',
971
- name: `${type}-cleared-circle`,
972
- data: [[a.lastUpdated, yValue]],
973
- symbol: 'circle',
974
- symbolSize: 24,
975
- itemStyle: {
976
- color: a.color || '#EE6666'
977
- },
978
- z: 2
979
- });
980
- series.push({
981
- type: 'scatter',
982
- name: `${type}-cleared-icon`,
983
- data: [
984
- {
985
- value: [a.lastUpdated, yValue],
986
- itemStyle: { color: 'white' },
987
- symbol: ICONS_MAP.CLEARED,
988
- symbolSize: 16
989
- }
990
- ],
991
- z: 3
992
- });
1170
+ for (const deviceId of deviceIds) {
1171
+ const carrierDp = {
1172
+ __target: { id: deviceId },
1173
+ values: flatValues,
1174
+ renderType: 'avg',
1175
+ lineType: 'line',
1176
+ color: 'transparent'
1177
+ };
1178
+ const deviceEvents = events.filter(e => e.source?.id === deviceId);
1179
+ const deviceAlarms = alarms.filter(a => a.source?.id === deviceId);
1180
+ series.push(...this.getAlarmOrEventSeries(carrierDp, 'avg', false, deviceEvents, 'event', displayOptions, null, 0), ...this.getAlarmOrEventSeries(carrierDp, 'avg', false, deviceAlarms, 'alarm', displayOptions, null, 0));
1181
+ }
1182
+ // markLines are full-height vertical lines keyed only by x, so copies for the same type across
1183
+ // devices are redundant; keep one per type (mirrors the dedup in getChartSeries).
1184
+ const seenMarkLineTypes = new Set();
1185
+ const deduped = series.filter(s => {
1186
+ const id = s.id;
1187
+ if (!id?.endsWith('-markLine')) {
1188
+ return true;
1189
+ }
1190
+ const isFirst = !seenMarkLineTypes.has(id);
1191
+ seenMarkLineTypes.add(id);
1192
+ return isFirst;
1193
+ });
1194
+ this.chartHelpersService.assignAlarmEventRowOffsets(deduped, this.echartsInstance);
1195
+ deduped.forEach(s => {
1196
+ const withMarks = s;
1197
+ if (withMarks.markLine) {
1198
+ withMarks.markLine.label = { show: false };
1199
+ withMarks.markLine.emphasis = { label: { show: false } };
1200
+ }
1201
+ if (withMarks.markPoint) {
1202
+ withMarks.markPoint.label = { show: false };
1203
+ withMarks.markPoint.emphasis = { label: { show: false } };
1204
+ }
1205
+ });
1206
+ return deduped;
1207
+ }
1208
+ /**
1209
+ * Exposes the default markLine data (creation time, plus lastUpdated when it differs) for a set of
1210
+ * alarms or events.
1211
+ * @param items - The alarms or events to build lines for.
1212
+ * @param itemType - Whether the items are alarms or events.
1213
+ * @returns The default markLine data.
1214
+ */
1215
+ getDefaultMarkLineData(items, itemType) {
1216
+ return this.createMarkLine(items, itemType);
1217
+ }
1218
+ /**
1219
+ * Builds the full chart options for the "alarms/events only" case (no active datapoint).
1220
+ * @param alarms - The list of alarms to render.
1221
+ * @param events - The list of events to render.
1222
+ * @param timeRange - The visible time range.
1223
+ * @param displayOptions - The display options (marked line/point flags, axis color, …).
1224
+ * @returns The chart options for the alarms/events-only view.
1225
+ */
1226
+ getAlarmEventOnlyChartOptions(alarms, events, timeRange, displayOptions) {
1227
+ return {
1228
+ grid: { containLabel: true, left: 32, top: 16, right: 32, bottom: 8 },
1229
+ dataZoom: [
1230
+ {
1231
+ type: 'inside',
1232
+ filterMode: 'none',
1233
+ zoomOnMouseWheel: true,
1234
+ startValue: new Date(timeRange.dateFrom).valueOf(),
1235
+ endValue: new Date(timeRange.dateTo).valueOf()
993
1236
  }
994
- });
995
- yValue += 10;
996
- }
997
- // Events by type
998
- for (const type of eventTypes) {
999
- const eventsOfType = events.filter(e => e.type === type);
1000
- // Background circle series
1001
- series.push({
1002
- type: 'scatter',
1003
- name: `${type}-circle`,
1004
- data: eventsOfType.map(e => [e.creationTime, yValue]),
1005
- symbol: 'circle',
1006
- symbolSize: 24,
1007
- itemStyle: {
1008
- color: eventsOfType[0]?.color || '#6f6cd0'
1009
- },
1010
- z: 2
1011
- });
1012
- // Foreground icon series
1013
- series.push({
1014
- type: 'scatter',
1015
- name: `${type}-icon`,
1016
- data: eventsOfType.map(e => ({
1017
- value: [e.creationTime, yValue],
1018
- itemStyle: { color: 'white' },
1019
- symbol: ICONS_MAP.EVENT,
1020
- symbolSize: 16
1021
- })),
1022
- z: 3
1023
- });
1024
- yValue += 10;
1025
- }
1026
- return series;
1237
+ ],
1238
+ animation: false,
1239
+ tooltip: {
1240
+ trigger: 'axis',
1241
+ formatter: () => '',
1242
+ backgroundColor: 'var(--c8y-root-component-background-default)',
1243
+ borderColor: 'var(--c8y-root-component-border-color)',
1244
+ padding: 0,
1245
+ appendToBody: true,
1246
+ position: this.tooltipPosition(),
1247
+ transitionDuration: 0
1248
+ },
1249
+ legend: { show: false },
1250
+ xAxis: {
1251
+ min: new Date(timeRange.dateFrom).valueOf(),
1252
+ max: new Date(timeRange.dateTo).valueOf(),
1253
+ type: 'time',
1254
+ axisLine: { lineStyle: { color: displayOptions.axisColor } },
1255
+ axisLabel: { hideOverlap: true, color: displayOptions.axisColor }
1256
+ },
1257
+ // Hidden fixed-range axis so the carrier marks always render at a stable vertical position.
1258
+ yAxis: {
1259
+ show: false,
1260
+ type: 'value',
1261
+ min: 0,
1262
+ max: 2
1263
+ },
1264
+ series: this.getAlarmEventCarrierSeries(alarms, events, timeRange, displayOptions)
1265
+ };
1027
1266
  }
1028
1267
  tooltipPosition() {
1029
1268
  let lastPositionOfTooltip = {};
@@ -1179,14 +1418,14 @@ class EchartsOptionsService {
1179
1418
  * @param alarm - The alarm object.
1180
1419
  * @returns The processed value.
1181
1420
  */
1182
- async processAlarm(alarm) {
1421
+ processAlarm(alarm) {
1183
1422
  let value = `<ul class="list-unstyled small separator-top text-default m-0 p-l-8 p-r-8">`;
1184
1423
  value += `<li class="p-t-4 p-b-4 d-flex a-i-center separator-bottom text-truncate"><label class="text-label-small m-b-0 m-r-8">${echarts.format.encodeHTML(this.translate.instant(gettext('Alarm severity')))}</label>`;
1185
1424
  value += `<span class="small d-inline-flex a-i-center gap-4 m-l-auto"><i class="stroked-icon icon-14 status dlt-c8y-icon-${echarts.format.encodeHTML(this.severityIconPipe.transform(alarm.severity))} ${alarm.severity.toLowerCase()}" > </i> ${this.severityLabelPipe.transform(alarm.severity)} </span></li>`;
1186
1425
  value += `<li class="p-t-4 p-b-4 d-flex separator-bottom text-truncate"><label class="text-label-small m-b-0 m-r-8">${echarts.format.encodeHTML(this.translate.instant(gettext('Alarm type')))}</label><span class="small m-l-auto"><code title="${echarts.format.encodeHTML(alarm.type)}">${echarts.format.encodeHTML(alarm.type)}</code></span></li>`;
1187
1426
  value += `<li class="p-t-4 p-b-4 d-flex separator-bottom text-truncate"><label class="text-label-small m-b-0 m-r-8">${echarts.format.encodeHTML(this.translate.instant(gettext('Message')))}</label><span class="small m-l-auto" style="display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; text-overflow: ellipsis; word-break: break-word; white-space: normal; max-width: 200px;" title="${echarts.format.encodeHTML(this.translate.instant(alarm.text))}">${echarts.format.encodeHTML(this.translate.instant(alarm.text))}</span></li>`;
1188
1427
  value += `<li class="p-t-4 p-b-4 d-flex separator-bottom text-truncate"><label class="text-label-small m-b-0 m-r-8">${echarts.format.encodeHTML(this.translate.instant(gettext('Last updated')))}</label><span class="small m-l-auto">${echarts.format.encodeHTML(this.datePipe.transform(alarm['lastUpdated']))}</span></li>`;
1189
- const exists = await this.alarmRouteExists();
1428
+ const exists = this.alarmRouteExists();
1190
1429
  if (exists) {
1191
1430
  const currentUrl = window.location.href;
1192
1431
  const baseUrlIndex = currentUrl.indexOf(INDEX_HTML);
@@ -1197,11 +1436,8 @@ class EchartsOptionsService {
1197
1436
  value += `</ul>`;
1198
1437
  return value;
1199
1438
  }
1200
- async alarmRouteExists() {
1201
- const exists = this.router.config.some(route => {
1202
- return `${route.path}` === 'alarms';
1203
- });
1204
- return exists;
1439
+ alarmRouteExists() {
1440
+ return this.router.config.some(route => `${route.path}` === 'alarms');
1205
1441
  }
1206
1442
  getChartSeries(datapointsWithValues, events, alarms, displayOptions) {
1207
1443
  const series = [];
@@ -1485,6 +1721,8 @@ class EchartsOptionsService {
1485
1721
  ];
1486
1722
  }
1487
1723
  if (isEvent) {
1724
+ // Offset events vertically so an event and an alarm at the same time don't obscure each other.
1725
+ const symbolOffset = [0, this.EVENT_MARK_VERTICAL_OFFSET];
1488
1726
  return [
1489
1727
  {
1490
1728
  coord: coordCreationTime,
@@ -1494,7 +1732,8 @@ class EchartsOptionsService {
1494
1732
  color: item['color']
1495
1733
  },
1496
1734
  symbol: 'circle',
1497
- symbolSize: 24
1735
+ symbolSize: 24,
1736
+ symbolOffset
1498
1737
  },
1499
1738
  {
1500
1739
  coord: coordCreationTime,
@@ -1502,87 +1741,51 @@ class EchartsOptionsService {
1502
1741
  itemType: item.type,
1503
1742
  itemStyle: { color: 'white' },
1504
1743
  symbol: ICONS_MAP.EVENT,
1505
- symbolSize: 16
1744
+ symbolSize: 16,
1745
+ symbolOffset
1506
1746
  }
1507
1747
  ];
1508
1748
  }
1509
- return isCleared
1510
- ? [
1511
- {
1512
- coord: coordCreationTime,
1513
- name: item.type,
1514
- itemType: item.type,
1515
- itemStyle: {
1516
- color: item['color']
1517
- },
1518
- symbol: 'circle',
1519
- symbolSize: 24
1520
- },
1521
- {
1522
- coord: coordCreationTime,
1523
- name: item.type,
1524
- itemType: item.type,
1525
- itemStyle: { color: 'white' },
1526
- symbol: ICONS_MAP[item.severity],
1527
- symbolSize: 16
1528
- },
1529
- {
1530
- coord: coordLastUpdatedTime,
1531
- name: item.type,
1532
- itemType: item.type,
1533
- itemStyle: {
1534
- color: item['color']
1535
- },
1536
- symbol: 'circle',
1537
- symbolSize: 24
1538
- },
1539
- {
1540
- coord: coordLastUpdatedTime,
1541
- name: item.type,
1542
- itemType: item.type,
1543
- itemStyle: { color: 'white' },
1544
- symbol: ICONS_MAP.CLEARED,
1545
- symbolSize: 16
1546
- }
1547
- ]
1548
- : [
1549
- {
1550
- coord: coordCreationTime,
1551
- name: item.type,
1552
- itemType: item.type,
1553
- itemStyle: {
1554
- color: item['color']
1555
- },
1556
- symbol: 'circle',
1557
- symbolSize: 24
1558
- },
1559
- {
1560
- coord: coordCreationTime,
1561
- name: item.type,
1562
- itemType: item.type,
1563
- itemStyle: { color: 'white' },
1564
- symbol: ICONS_MAP[item.severity],
1565
- symbolSize: 16
1566
- },
1567
- {
1568
- coord: coordLastUpdatedTime,
1569
- name: item.type,
1570
- itemType: item.type,
1571
- itemStyle: {
1572
- color: item['color']
1573
- },
1574
- symbol: 'circle',
1575
- symbolSize: 24
1576
- },
1577
- {
1578
- coord: coordLastUpdatedTime,
1579
- name: item.type,
1580
- itemType: item.type,
1581
- itemStyle: { color: 'white' },
1582
- symbol: ICONS_MAP[item.severity],
1583
- symbolSize: 16
1584
- }
1585
- ];
1749
+ const creationMarks = [
1750
+ {
1751
+ coord: coordCreationTime,
1752
+ name: item.type,
1753
+ itemType: item.type,
1754
+ itemStyle: { color: item['color'] },
1755
+ symbol: 'circle',
1756
+ symbolSize: 24
1757
+ },
1758
+ {
1759
+ coord: coordCreationTime,
1760
+ name: item.type,
1761
+ itemType: item.type,
1762
+ itemStyle: { color: 'white' },
1763
+ symbol: ICONS_MAP[item.severity],
1764
+ symbolSize: 16
1765
+ }
1766
+ ];
1767
+ if (!item['lastUpdated']) {
1768
+ return creationMarks;
1769
+ }
1770
+ return [
1771
+ ...creationMarks,
1772
+ {
1773
+ coord: coordLastUpdatedTime,
1774
+ name: item.type,
1775
+ itemType: item.type,
1776
+ itemStyle: { color: item['color'] },
1777
+ symbol: 'circle',
1778
+ symbolSize: 24
1779
+ },
1780
+ {
1781
+ coord: coordLastUpdatedTime,
1782
+ name: item.type,
1783
+ itemType: item.type,
1784
+ itemStyle: { color: 'white' },
1785
+ symbol: isCleared ? ICONS_MAP.CLEARED : ICONS_MAP[item.severity],
1786
+ symbolSize: 16
1787
+ }
1788
+ ];
1586
1789
  }
1587
1790
  /**
1588
1791
  * This method creates a markLine on the chart which represents the line between every alarm or event on the chart.
@@ -1604,7 +1807,7 @@ class EchartsOptionsService {
1604
1807
  }
1605
1808
  ]);
1606
1809
  }
1607
- if (item.creationTime === item['lastUpdated']) {
1810
+ if (!item['lastUpdated'] || item.creationTime === item['lastUpdated']) {
1608
1811
  return acc.concat([
1609
1812
  {
1610
1813
  xAxis: item.creationTime,
@@ -2014,12 +2217,12 @@ class EchartsOptionsService {
2014
2217
  return baseColor;
2015
2218
  }
2016
2219
  }
2017
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: EchartsOptionsService, deps: [{ token: i1$1.DatePipe }, { token: YAxisService }, { token: ChartTypesService }, { token: i4.AlarmSeverityToIconPipe }, { token: i4.AlarmSeverityToLabelPipe }, { token: i1.TranslateService }, { token: i6.Router }], target: i0.ɵɵFactoryTarget.Injectable }); }
2220
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: EchartsOptionsService, deps: [{ token: i1$1.DatePipe }, { token: YAxisService }, { token: ChartTypesService }, { token: i4.AlarmSeverityToIconPipe }, { token: i4.AlarmSeverityToLabelPipe }, { token: i1.TranslateService }, { token: i6.Router }, { token: ChartHelpersService }], target: i0.ɵɵFactoryTarget.Injectable }); }
2018
2221
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: EchartsOptionsService }); }
2019
2222
  }
2020
2223
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: EchartsOptionsService, decorators: [{
2021
2224
  type: Injectable
2022
- }], ctorParameters: () => [{ type: i1$1.DatePipe }, { type: YAxisService }, { type: ChartTypesService }, { type: i4.AlarmSeverityToIconPipe }, { type: i4.AlarmSeverityToLabelPipe }, { type: i1.TranslateService }, { type: i6.Router }] });
2225
+ }], ctorParameters: () => [{ type: i1$1.DatePipe }, { type: YAxisService }, { type: ChartTypesService }, { type: i4.AlarmSeverityToIconPipe }, { type: i4.AlarmSeverityToLabelPipe }, { type: i1.TranslateService }, { type: i6.Router }, { type: ChartHelpersService }] });
2023
2226
 
2024
2227
  class ChartRealtimeService {
2025
2228
  constructor(measurementRealtime, alarmRealtimeService, eventRealtimeService, echartsOptionsService) {
@@ -2049,6 +2252,7 @@ class ChartRealtimeService {
2049
2252
  if (displayOptions.showOnlyAlarmsOrEvents) {
2050
2253
  this.currentAlarms = alarms ? [...alarms] : [];
2051
2254
  this.currentEvents = events ? [...events] : [];
2255
+ this.currentDisplayOptions = displayOptions;
2052
2256
  // Subscribe to realtime alarms/events
2053
2257
  const allAlarmsAndEvents$ = from(uniqueAlarmOrEventTargets).pipe(mergeMap(targetId => {
2054
2258
  const alarmsRealtime$ = this.alarmRealtimeService.onAll$(targetId);
@@ -2058,9 +2262,19 @@ class ChartRealtimeService {
2058
2262
  this.realtimeSubscriptionAlarmsEvents = allAlarmsAndEvents$.subscribe(alarmOrEvent => {
2059
2263
  if (!alarmOrEvent)
2060
2264
  return;
2265
+ const matchingConfig = activeAlarmsOrEvents.find(aOrE => aOrE.filters.type === alarmOrEvent.type && aOrE.__target?.id === alarmOrEvent.source?.id);
2266
+ if (!matchingConfig) {
2267
+ return;
2268
+ }
2269
+ alarmOrEvent['color'] = matchingConfig.color;
2270
+ alarmOrEvent['selectedDatapoint'] = matchingConfig.selectedDatapoint;
2061
2271
  if ('severity' in alarmOrEvent) {
2062
2272
  // Alarm
2063
- if (!this.currentAlarms.some(a => a.id === alarmOrEvent.id)) {
2273
+ const existingIdx = this.currentAlarms.findIndex(a => a.id === alarmOrEvent.id);
2274
+ if (existingIdx !== -1) {
2275
+ this.currentAlarms[existingIdx] = alarmOrEvent;
2276
+ }
2277
+ else {
2064
2278
  this.currentAlarms.push(alarmOrEvent);
2065
2279
  }
2066
2280
  }
@@ -2070,6 +2284,7 @@ class ChartRealtimeService {
2070
2284
  this.currentEvents.push(alarmOrEvent);
2071
2285
  }
2072
2286
  }
2287
+ alarmOrEventReceivedCallback?.(alarmOrEvent);
2073
2288
  this.updateAlarmsEventsChartRealtime();
2074
2289
  });
2075
2290
  const updateThrottleTime = this.getRealtimeUpdateThrottleTime({
@@ -2167,7 +2382,18 @@ class ChartRealtimeService {
2167
2382
  * Updates the chart with the current alarms and events data. Only used when no active datapoints are selected.
2168
2383
  */
2169
2384
  updateAlarmsEventsChartRealtime() {
2170
- const series = this.echartsOptionsService.createAlarmEventTypeSeries(this.currentAlarms, this.currentEvents);
2385
+ const series = this.echartsOptionsService.getAlarmEventCarrierSeries(this.currentAlarms, this.currentEvents, {
2386
+ dateFrom: (this.currentTimeRange?.dateFrom ?? new Date()).toISOString(),
2387
+ dateTo: (this.currentTimeRange?.dateTo ?? new Date()).toISOString()
2388
+ }, this.currentDisplayOptions ?? {
2389
+ displayMarkedLine: true,
2390
+ displayMarkedPoint: true,
2391
+ mergeMatchingDatapoints: false,
2392
+ forceMergeDatapoints: false,
2393
+ setYaxisStartToZero: false,
2394
+ showLabelAndUnit: false,
2395
+ showSlider: false
2396
+ });
2171
2397
  this.echartsInstance?.setOption({
2172
2398
  series,
2173
2399
  xAxis: {
@@ -2496,142 +2722,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
2496
2722
  type: Injectable
2497
2723
  }], ctorParameters: () => [{ type: i1$3.AlarmService }] });
2498
2724
 
2499
- class ChartHelpersService {
2500
- /**
2501
- * Cleans the widget configuration which will be sent to Gainsight by removing unnecessary properties.
2502
- * @param config The widget configuration to clean.
2503
- * @returns The cleaned widget configuration.
2504
- */
2505
- getConfigSummaryForGainsight(config) {
2506
- return {
2507
- datapointCount: config.datapoints?.length ?? 0,
2508
- alarmsEventsCount: config.alarmsEventsConfigs?.length ?? 0,
2509
- interval: config.interval,
2510
- forceMergeDatapoints: config.forceMergeDatapoints,
2511
- mergeMatchingDatapoints: config.mergeMatchingDatapoints,
2512
- showSlider: config.showSlider,
2513
- dateFrom: config.dateFrom,
2514
- dateTo: config.dateTo
2515
- };
2516
- }
2517
- findMatchingDatapoint(datapoints, selected) {
2518
- if (!selected)
2519
- return undefined;
2520
- return datapoints.find(dp => this.getTargetId(dp) === this.getTargetId(selected) &&
2521
- dp.fragment === selected.fragment &&
2522
- dp.series === selected.series);
2523
- }
2524
- /**
2525
- * Maps a computed auto-aggregation interval string (e.g. `'5m'`, `'3h'`, `'7d'`) to
2526
- * the nearest standard `aggregationType` for non-time-series tenants.
2527
- * - seconds / minutes → MINUTELY (finest supported granularity)
2528
- * - hours → HOURLY
2529
- * - days → DAILY
2530
- */
2531
- mapIntervalToAggregationType(interval) {
2532
- if (interval.endsWith('d'))
2533
- return aggregationType.DAILY;
2534
- if (interval.endsWith('h'))
2535
- return aggregationType.HOURLY;
2536
- if (interval.endsWith('m') || interval.endsWith('s'))
2537
- return aggregationType.MINUTELY;
2538
- return null;
2539
- }
2540
- /**
2541
- * Computes the aggregation interval string (e.g. `30s`, `5m`, `24h`, `7d`) that
2542
- * targets approximately `desiredDatapoints` buckets within the given time range.
2543
- * The integer part is capped at 999 to satisfy the API constraint.
2544
- */
2545
- calculateAutoAggregationInterval(dateFrom, dateTo, desiredDatapoints) {
2546
- const rangeMs = new Date(dateTo).valueOf() - new Date(dateFrom).valueOf();
2547
- const intervalMs = rangeMs / Math.max(1, desiredDatapoints);
2548
- // If each bucket would be sub-second, the zoom is fine enough to show raw data
2549
- if (intervalMs <= 1_000)
2550
- return null;
2551
- // Use the most natural human-readable unit.
2552
- // e.g. 24h / 300 dp → 288 s → 5 m (not 288s)
2553
- const totalSeconds = Math.max(1, Math.round(intervalMs / 1_000));
2554
- if (totalSeconds < 60)
2555
- return `${Math.min(999, totalSeconds)}s`;
2556
- const totalMinutes = Math.round(totalSeconds / 60);
2557
- if (totalMinutes < 60)
2558
- return `${Math.min(999, totalMinutes)}m`;
2559
- const totalHours = Math.round(totalSeconds / 3_600);
2560
- if (totalHours < 24)
2561
- return `${Math.min(999, totalHours)}h`;
2562
- const totalDays = Math.max(1, Math.round(totalSeconds / 86_400));
2563
- return `${Math.min(999, totalDays)}d`;
2564
- }
2565
- /**
2566
- * Scans raw DpWithValues to determine which datapoints should be collapsed to a single
2567
- * series — either because all per-function values are identical at every timestamp (the
2568
- * aggregation interval is too fine — each interval holds a single measurement — to
2569
- * differentiate functions).
2570
- */
2571
- detectCollapsedDatapoints(dps) {
2572
- const collapsed = new Set();
2573
- for (const dp of dps) {
2574
- const dpId = `${dp.__target?.id}${dp.fragment}${dp.series}`;
2575
- const valueEntries = Object.values(dp.values ?? {});
2576
- if (!valueEntries.length)
2577
- continue;
2578
- const renderTypeConfigs = dp.renderTypeConfigs;
2579
- const renderAsBand = !!dp.renderAsBand;
2580
- let fnsToCompare = [];
2581
- let isBand = false;
2582
- if (renderTypeConfigs) {
2583
- const enabled = Object.keys(renderTypeConfigs).filter(k => renderTypeConfigs[k]?.enabled);
2584
- const hasMinMax = enabled.includes('min') && enabled.includes('max');
2585
- if (renderAsBand && hasMinMax) {
2586
- isBand = true;
2587
- }
2588
- else if (enabled.length >= 2) {
2589
- fnsToCompare = enabled;
2590
- }
2591
- else {
2592
- continue;
2593
- }
2594
- }
2595
- else if (dp.renderTypes && dp.renderTypes.length > 1) {
2596
- const fns = dp.renderTypes.filter(rt => rt !== 'area');
2597
- if (fns.length >= 2) {
2598
- fnsToCompare = fns;
2599
- }
2600
- else {
2601
- continue;
2602
- }
2603
- }
2604
- else {
2605
- continue;
2606
- }
2607
- if (isBand) {
2608
- const allSame = valueEntries.every(vals => vals[0]?.min === vals[0]?.max);
2609
- if (allSame)
2610
- collapsed.add(dpId);
2611
- }
2612
- else {
2613
- const allSame = valueEntries.every(vals => {
2614
- const first = vals[0]?.[fnsToCompare[0]];
2615
- if (first === undefined || first === null)
2616
- return false;
2617
- return fnsToCompare.slice(1).every(fn => vals[0]?.[fn] === first);
2618
- });
2619
- if (allSame)
2620
- collapsed.add(dpId);
2621
- }
2622
- }
2623
- return collapsed;
2624
- }
2625
- getTargetId(obj) {
2626
- return obj?.__target?.id ?? obj?.target;
2627
- }
2628
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: ChartHelpersService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2629
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: ChartHelpersService }); }
2630
- }
2631
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: ChartHelpersService, decorators: [{
2632
- type: Injectable
2633
- }] });
2634
-
2635
2725
  class SelectAggregatedDatapointComponent {
2636
2726
  constructor() {
2637
2727
  this.activeDatapoints = [];
@@ -2973,6 +3063,16 @@ class ChartsComponent {
2973
3063
  }
2974
3064
  });
2975
3065
  this.echartsInstance.on('click', this.onChartClick.bind(this));
3066
+ // ZRender fires for every click, including on blank canvas — where echarts' own 'click' event
3067
+ // does not. A click on empty space (no target) therefore never reached onChartClick, so after
3068
+ // clicking an event/alarm (which switches the tooltip to triggerOn: 'click') hovering could no
3069
+ // longer reopen a tooltip. Routing blank clicks through the deselect path restores hover mode
3070
+ // (triggerOn back to 'mousemove') and clears any active alarm area, mirroring a click on the area.
3071
+ this.echartsInstance.getZr().on('click', (event) => {
3072
+ if (!event.target) {
3073
+ this.onChartClick({ data: { itemType: '' } });
3074
+ }
3075
+ });
2976
3076
  let originalFormatter = null;
2977
3077
  this.echartsInstance.on('mouseover', (params) => {
2978
3078
  if (params?.componentType !== 'markLine' && params?.componentType !== 'markPoint') {
@@ -2999,13 +3099,14 @@ class ChartsComponent {
2999
3099
  this.echartsInstance.setOption(options);
3000
3100
  }
3001
3101
  });
3102
+ this.observeChartResize();
3002
3103
  }
3003
3104
  onChartClick(params) {
3004
- const options = this.echartsInstance.getOption();
3105
+ const clearActiveArea = this.getActiveAlarmAreaClearPatch();
3005
3106
  if (!this.isAlarmOrEventClick(params)) {
3006
3107
  this.echartsInstance.setOption({
3007
3108
  tooltip: { triggerOn: 'mousemove' },
3008
- series: [{ markArea: { data: [] }, markLine: { data: [] } }]
3109
+ ...(clearActiveArea ? { series: [clearActiveArea] } : {})
3009
3110
  });
3010
3111
  if (params.componentType === 'series' && params.seriesName) {
3011
3112
  this.toggleStickyHighlight(params.seriesName);
@@ -3031,44 +3132,49 @@ class ChartsComponent {
3031
3132
  }
3032
3133
  const clickedAlarms = this.alarms.filter(alarm => alarm.type === params.data.itemType);
3033
3134
  const clickedEvents = this.events.filter(event => event.type === params.data.itemType);
3135
+ const clickedTime = params.data.xAxis;
3136
+ const selectedAlarms = clickedAlarms.filter(alarm => alarm.creationTime === clickedTime || alarm['lastUpdated'] === clickedTime);
3137
+ const alarmsToMark = selectedAlarms.length ? selectedAlarms : clickedAlarms;
3034
3138
  if (clickedEvents.length > 0) {
3035
- const updatedOptions = {
3036
- tooltip: {
3037
- enterable: true,
3038
- triggerOn: 'click'
3039
- }
3040
- };
3041
- this.echartsInstance.setOption(updatedOptions);
3139
+ this.echartsInstance.setOption({ tooltip: { enterable: true, triggerOn: 'click' } });
3042
3140
  return;
3043
3141
  }
3044
- this.isMarkedAreaEnabled.emit(this.hasMarkArea(options));
3045
- const updatedOptions = !this.hasMarkArea(options)
3046
- ? {
3047
- tooltip: { enterable: true, triggerOn: 'click' },
3048
- series: [
3049
- {
3050
- markArea: { label: { show: false }, data: this.getMarkedAreaData(clickedAlarms) },
3051
- markLine: {
3052
- showSymbol: true,
3053
- symbol: ['none', 'none'],
3054
- data: this.getMarkedLineData(clickedAlarms)
3055
- }
3056
- }
3057
- ]
3058
- }
3059
- : // if markArea already exists, remove it and remove lastUpdated from markLine
3142
+ const markLineSeriesId = params.seriesName?.replace('-markPoint', '-markLine');
3143
+ const isReclickOfActiveArea = !!clearActiveArea && clearActiveArea.id === markLineSeriesId;
3144
+ this.isMarkedAreaEnabled.emit(isReclickOfActiveArea);
3145
+ if (isReclickOfActiveArea) {
3146
+ // Clicking the active alarm again just removes its area.
3147
+ this.echartsInstance.setOption({
3148
+ tooltip: { triggerOn: 'mousemove' },
3149
+ series: [clearActiveArea]
3150
+ });
3151
+ return;
3152
+ }
3153
+ // Activate this alarm's area, first removing any other active area (mutual exclusivity).
3154
+ this.echartsInstance.setOption({
3155
+ tooltip: { enterable: true, triggerOn: 'click' },
3156
+ series: [
3157
+ ...(clearActiveArea ? [clearActiveArea] : []),
3060
3158
  {
3061
- tooltip: { triggerOn: 'mousemove' },
3062
- series: [{ markArea: { data: [] }, markLine: { data: [] } }]
3063
- };
3064
- this.echartsInstance.setOption(updatedOptions);
3159
+ id: markLineSeriesId,
3160
+ markArea: { label: { show: false }, data: this.getMarkedAreaData(alarmsToMark) },
3161
+ markLine: {
3162
+ showSymbol: true,
3163
+ symbol: ['none', 'none'],
3164
+ data: this.getMarkedLineData(clickedAlarms)
3165
+ }
3166
+ }
3167
+ ]
3168
+ });
3065
3169
  }
3066
3170
  isAlarmOrEventClick(params) {
3067
3171
  return (this.alarms.some(alarm => alarm.type === params.data?.itemType) ||
3068
3172
  this.events.some(event => event.type === params.data?.itemType));
3069
3173
  }
3070
- hasMarkArea(options) {
3071
- return options?.series?.[0]?.markArea?.data?.length > 0;
3174
+ hasMarkArea(options, seriesId) {
3175
+ const series = (options?.series ?? []);
3176
+ const target = seriesId ? series.find(s => s.id === seriesId) : series[0];
3177
+ return (target?.markArea?.data?.length ?? 0) > 0;
3072
3178
  }
3073
3179
  saveAsImage() {
3074
3180
  const dataZoom = this.echartsInstance.getOption().dataZoom;
@@ -3160,6 +3266,21 @@ class ChartsComponent {
3160
3266
  }
3161
3267
  });
3162
3268
  }
3269
+ /**
3270
+ * Whether the given datapoint currently has at least one series rendered in the chart.
3271
+ * A datapoint can be `__active` in the config yet absent from the chart if a full rebuild ran
3272
+ * while it was hidden (the fetch pipeline only builds series for active datapoints). In that case
3273
+ * a legend toggle has nothing to act on, so the caller must trigger a reload instead.
3274
+ * @param datapoint - The datapoint to look for.
3275
+ */
3276
+ hasRenderedDatapointSeries(datapoint) {
3277
+ if (!this.echartsInstance) {
3278
+ return false;
3279
+ }
3280
+ const dpId = `${datapoint.__target?.id}${datapoint.fragment}${datapoint.series}`;
3281
+ const allSeries = this.echartsInstance.getOption().series;
3282
+ return allSeries?.some(s => s.datapointId === dpId) ?? false;
3283
+ }
3163
3284
  /** Toggles a single chart series by its exact name (used for per-function legend entries). */
3164
3285
  toggleSeriesByName(seriesName, visible) {
3165
3286
  if (!this.echartsInstance) {
@@ -3321,6 +3442,54 @@ class ChartsComponent {
3321
3442
  series: []
3322
3443
  };
3323
3444
  }
3445
+ /**
3446
+ * Re-spaces the alarm/event rows whenever the chart height changes, so the rows stay inside the
3447
+ * plot area on a short chart and after a resize (the pixel offsets are otherwise fixed at build
3448
+ * time). Starting at -1 makes the observer's initial callback also correct the estimated first
3449
+ * build, once a real grid exists to measure. No-op outside the alarms/events-only chart.
3450
+ */
3451
+ observeChartResize() {
3452
+ const chartDom = typeof this.echartsInstance.getDom === 'function' ? this.echartsInstance.getDom() : null;
3453
+ if (!chartDom || typeof ResizeObserver === 'undefined') {
3454
+ return;
3455
+ }
3456
+ let lastHeight = -1;
3457
+ const resizeObserver = new ResizeObserver(() => {
3458
+ const height = chartDom.clientHeight;
3459
+ if (height === lastHeight) {
3460
+ return;
3461
+ }
3462
+ lastHeight = height;
3463
+ this.echartsInstance.resize();
3464
+ this.chartHelpersService.reapplyAlarmEventRowOffsets(this.echartsInstance, this.echartsOptionsService.isAlarmEventOnlyChart);
3465
+ });
3466
+ resizeObserver.observe(chartDom);
3467
+ this.destroyRef.onDestroy(() => resizeObserver.disconnect());
3468
+ }
3469
+ /**
3470
+ * Finds the one alarm series that currently shows a markArea and builds the `setOption` patch that
3471
+ * removes it, restoring that series' default mark-line. Only one area is ever active, so there is
3472
+ * nothing to iterate; the event lines live on their own series and are left untouched.
3473
+ * @returns The clear patch, or null when no alarm area is active.
3474
+ */
3475
+ getActiveAlarmAreaClearPatch() {
3476
+ const series = (this.echartsInstance.getOption().series ?? []);
3477
+ const activeArea = series.find(s => s.typeOfSeries === 'alarm' && (s.markArea?.data?.length ?? 0) > 0);
3478
+ if (!activeArea) {
3479
+ return null;
3480
+ }
3481
+ const type = String(activeArea.id).split('/')[0];
3482
+ const alarmsOfType = this.alarms.filter(alarm => alarm.type === type);
3483
+ return {
3484
+ id: activeArea.id,
3485
+ markArea: { data: [] },
3486
+ markLine: {
3487
+ showSymbol: false,
3488
+ symbol: ['none', 'none'],
3489
+ data: this.echartsOptionsService.getDefaultMarkLineData(alarmsOfType, 'alarm')
3490
+ }
3491
+ };
3492
+ }
3324
3493
  getMarkedAreaData(clickedAlarms) {
3325
3494
  const timeRange = this.getTimeRange();
3326
3495
  const clearedAlarmColor = 'rgba(221,255,221,1.00)';
@@ -3401,7 +3570,8 @@ class ChartsComponent {
3401
3570
  if ((this.activeDatapoints || this.config.alarmsEventsConfigs?.length > 0) &&
3402
3571
  this.config.realtime &&
3403
3572
  this.echartsInstance) {
3404
- const showOnlyAlarmsOrEvents = this.config.alarmsEventsConfigs?.length > 0 && this.activeDatapoints?.length === 0;
3573
+ const showOnlyAlarmsOrEvents = this.config.alarmsEventsConfigs?.length > 0 &&
3574
+ (!this.activeDatapoints || this.activeDatapoints.length === 0);
3405
3575
  this.chartRealtimeService.startRealtime(this.echartsInstance, this.activeDatapoints, this.getTimeRange(), dp => this.datapointOutOfSync.emit(dp), dp => this.datapointBackInSync.emit(dp), timeRange => this.timeRangeChangeOnRealtime.emit(timeRange), this.config.alarmsEventsConfigs, {
3406
3576
  ...this.displayOptions,
3407
3577
  showOnlyAlarmsOrEvents