@c8y/ngx-components 1024.20.0 → 1024.20.1

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.
@@ -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,7 +1741,8 @@ 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
  }
@@ -2014,12 +2254,12 @@ class EchartsOptionsService {
2014
2254
  return baseColor;
2015
2255
  }
2016
2256
  }
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 }); }
2257
+ 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
2258
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: EchartsOptionsService }); }
2019
2259
  }
2020
2260
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: EchartsOptionsService, decorators: [{
2021
2261
  type: Injectable
2022
- }], ctorParameters: () => [{ type: i1$1.DatePipe }, { type: YAxisService }, { type: ChartTypesService }, { type: i4.AlarmSeverityToIconPipe }, { type: i4.AlarmSeverityToLabelPipe }, { type: i1.TranslateService }, { type: i6.Router }] });
2262
+ }], ctorParameters: () => [{ type: i1$1.DatePipe }, { type: YAxisService }, { type: ChartTypesService }, { type: i4.AlarmSeverityToIconPipe }, { type: i4.AlarmSeverityToLabelPipe }, { type: i1.TranslateService }, { type: i6.Router }, { type: ChartHelpersService }] });
2023
2263
 
2024
2264
  class ChartRealtimeService {
2025
2265
  constructor(measurementRealtime, alarmRealtimeService, eventRealtimeService, echartsOptionsService) {
@@ -2049,6 +2289,7 @@ class ChartRealtimeService {
2049
2289
  if (displayOptions.showOnlyAlarmsOrEvents) {
2050
2290
  this.currentAlarms = alarms ? [...alarms] : [];
2051
2291
  this.currentEvents = events ? [...events] : [];
2292
+ this.currentDisplayOptions = displayOptions;
2052
2293
  // Subscribe to realtime alarms/events
2053
2294
  const allAlarmsAndEvents$ = from(uniqueAlarmOrEventTargets).pipe(mergeMap(targetId => {
2054
2295
  const alarmsRealtime$ = this.alarmRealtimeService.onAll$(targetId);
@@ -2058,9 +2299,18 @@ class ChartRealtimeService {
2058
2299
  this.realtimeSubscriptionAlarmsEvents = allAlarmsAndEvents$.subscribe(alarmOrEvent => {
2059
2300
  if (!alarmOrEvent)
2060
2301
  return;
2302
+ const matchingConfig = activeAlarmsOrEvents.find(aOrE => aOrE.filters.type === alarmOrEvent.type && aOrE.__target?.id === alarmOrEvent.source?.id);
2303
+ if (matchingConfig) {
2304
+ alarmOrEvent['color'] = matchingConfig.color;
2305
+ alarmOrEvent['selectedDatapoint'] = matchingConfig.selectedDatapoint;
2306
+ }
2061
2307
  if ('severity' in alarmOrEvent) {
2062
2308
  // Alarm
2063
- if (!this.currentAlarms.some(a => a.id === alarmOrEvent.id)) {
2309
+ const existingIdx = this.currentAlarms.findIndex(a => a.id === alarmOrEvent.id);
2310
+ if (existingIdx !== -1) {
2311
+ this.currentAlarms[existingIdx] = alarmOrEvent;
2312
+ }
2313
+ else {
2064
2314
  this.currentAlarms.push(alarmOrEvent);
2065
2315
  }
2066
2316
  }
@@ -2070,6 +2320,7 @@ class ChartRealtimeService {
2070
2320
  this.currentEvents.push(alarmOrEvent);
2071
2321
  }
2072
2322
  }
2323
+ alarmOrEventReceivedCallback?.(alarmOrEvent);
2073
2324
  this.updateAlarmsEventsChartRealtime();
2074
2325
  });
2075
2326
  const updateThrottleTime = this.getRealtimeUpdateThrottleTime({
@@ -2167,7 +2418,18 @@ class ChartRealtimeService {
2167
2418
  * Updates the chart with the current alarms and events data. Only used when no active datapoints are selected.
2168
2419
  */
2169
2420
  updateAlarmsEventsChartRealtime() {
2170
- const series = this.echartsOptionsService.createAlarmEventTypeSeries(this.currentAlarms, this.currentEvents);
2421
+ const series = this.echartsOptionsService.getAlarmEventCarrierSeries(this.currentAlarms, this.currentEvents, {
2422
+ dateFrom: (this.currentTimeRange?.dateFrom ?? new Date()).toISOString(),
2423
+ dateTo: (this.currentTimeRange?.dateTo ?? new Date()).toISOString()
2424
+ }, this.currentDisplayOptions ?? {
2425
+ displayMarkedLine: true,
2426
+ displayMarkedPoint: true,
2427
+ mergeMatchingDatapoints: false,
2428
+ forceMergeDatapoints: false,
2429
+ setYaxisStartToZero: false,
2430
+ showLabelAndUnit: false,
2431
+ showSlider: false
2432
+ });
2171
2433
  this.echartsInstance?.setOption({
2172
2434
  series,
2173
2435
  xAxis: {
@@ -2496,142 +2758,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
2496
2758
  type: Injectable
2497
2759
  }], ctorParameters: () => [{ type: i1$3.AlarmService }] });
2498
2760
 
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
2761
  class SelectAggregatedDatapointComponent {
2636
2762
  constructor() {
2637
2763
  this.activeDatapoints = [];
@@ -2973,6 +3099,16 @@ class ChartsComponent {
2973
3099
  }
2974
3100
  });
2975
3101
  this.echartsInstance.on('click', this.onChartClick.bind(this));
3102
+ // ZRender fires for every click, including on blank canvas — where echarts' own 'click' event
3103
+ // does not. A click on empty space (no target) therefore never reached onChartClick, so after
3104
+ // clicking an event/alarm (which switches the tooltip to triggerOn: 'click') hovering could no
3105
+ // longer reopen a tooltip. Routing blank clicks through the deselect path restores hover mode
3106
+ // (triggerOn back to 'mousemove') and clears any active alarm area, mirroring a click on the area.
3107
+ this.echartsInstance.getZr().on('click', (event) => {
3108
+ if (!event.target) {
3109
+ this.onChartClick({ data: { itemType: '' } });
3110
+ }
3111
+ });
2976
3112
  let originalFormatter = null;
2977
3113
  this.echartsInstance.on('mouseover', (params) => {
2978
3114
  if (params?.componentType !== 'markLine' && params?.componentType !== 'markPoint') {
@@ -2999,13 +3135,14 @@ class ChartsComponent {
2999
3135
  this.echartsInstance.setOption(options);
3000
3136
  }
3001
3137
  });
3138
+ this.observeChartResize();
3002
3139
  }
3003
3140
  onChartClick(params) {
3004
- const options = this.echartsInstance.getOption();
3141
+ const clearActiveArea = this.getActiveAlarmAreaClearPatch();
3005
3142
  if (!this.isAlarmOrEventClick(params)) {
3006
3143
  this.echartsInstance.setOption({
3007
3144
  tooltip: { triggerOn: 'mousemove' },
3008
- series: [{ markArea: { data: [] }, markLine: { data: [] } }]
3145
+ ...(clearActiveArea ? { series: [clearActiveArea] } : {})
3009
3146
  });
3010
3147
  if (params.componentType === 'series' && params.seriesName) {
3011
3148
  this.toggleStickyHighlight(params.seriesName);
@@ -3031,44 +3168,49 @@ class ChartsComponent {
3031
3168
  }
3032
3169
  const clickedAlarms = this.alarms.filter(alarm => alarm.type === params.data.itemType);
3033
3170
  const clickedEvents = this.events.filter(event => event.type === params.data.itemType);
3171
+ const clickedTime = params.data.xAxis;
3172
+ const selectedAlarms = clickedAlarms.filter(alarm => alarm.creationTime === clickedTime || alarm['lastUpdated'] === clickedTime);
3173
+ const alarmsToMark = selectedAlarms.length ? selectedAlarms : clickedAlarms;
3034
3174
  if (clickedEvents.length > 0) {
3035
- const updatedOptions = {
3036
- tooltip: {
3037
- enterable: true,
3038
- triggerOn: 'click'
3039
- }
3040
- };
3041
- this.echartsInstance.setOption(updatedOptions);
3175
+ this.echartsInstance.setOption({ tooltip: { enterable: true, triggerOn: 'click' } });
3042
3176
  return;
3043
3177
  }
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
3178
+ const markLineSeriesId = params.seriesName?.replace('-markPoint', '-markLine');
3179
+ const isReclickOfActiveArea = !!clearActiveArea && clearActiveArea.id === markLineSeriesId;
3180
+ this.isMarkedAreaEnabled.emit(isReclickOfActiveArea);
3181
+ if (isReclickOfActiveArea) {
3182
+ // Clicking the active alarm again just removes its area.
3183
+ this.echartsInstance.setOption({
3184
+ tooltip: { triggerOn: 'mousemove' },
3185
+ series: [clearActiveArea]
3186
+ });
3187
+ return;
3188
+ }
3189
+ // Activate this alarm's area, first removing any other active area (mutual exclusivity).
3190
+ this.echartsInstance.setOption({
3191
+ tooltip: { enterable: true, triggerOn: 'click' },
3192
+ series: [
3193
+ ...(clearActiveArea ? [clearActiveArea] : []),
3060
3194
  {
3061
- tooltip: { triggerOn: 'mousemove' },
3062
- series: [{ markArea: { data: [] }, markLine: { data: [] } }]
3063
- };
3064
- this.echartsInstance.setOption(updatedOptions);
3195
+ id: markLineSeriesId,
3196
+ markArea: { label: { show: false }, data: this.getMarkedAreaData(alarmsToMark) },
3197
+ markLine: {
3198
+ showSymbol: true,
3199
+ symbol: ['none', 'none'],
3200
+ data: this.getMarkedLineData(clickedAlarms)
3201
+ }
3202
+ }
3203
+ ]
3204
+ });
3065
3205
  }
3066
3206
  isAlarmOrEventClick(params) {
3067
3207
  return (this.alarms.some(alarm => alarm.type === params.data?.itemType) ||
3068
3208
  this.events.some(event => event.type === params.data?.itemType));
3069
3209
  }
3070
- hasMarkArea(options) {
3071
- return options?.series?.[0]?.markArea?.data?.length > 0;
3210
+ hasMarkArea(options, seriesId) {
3211
+ const series = (options?.series ?? []);
3212
+ const target = seriesId ? series.find(s => s.id === seriesId) : series[0];
3213
+ return (target?.markArea?.data?.length ?? 0) > 0;
3072
3214
  }
3073
3215
  saveAsImage() {
3074
3216
  const dataZoom = this.echartsInstance.getOption().dataZoom;
@@ -3160,6 +3302,21 @@ class ChartsComponent {
3160
3302
  }
3161
3303
  });
3162
3304
  }
3305
+ /**
3306
+ * Whether the given datapoint currently has at least one series rendered in the chart.
3307
+ * A datapoint can be `__active` in the config yet absent from the chart if a full rebuild ran
3308
+ * while it was hidden (the fetch pipeline only builds series for active datapoints). In that case
3309
+ * a legend toggle has nothing to act on, so the caller must trigger a reload instead.
3310
+ * @param datapoint - The datapoint to look for.
3311
+ */
3312
+ hasRenderedDatapointSeries(datapoint) {
3313
+ if (!this.echartsInstance) {
3314
+ return false;
3315
+ }
3316
+ const dpId = `${datapoint.__target?.id}${datapoint.fragment}${datapoint.series}`;
3317
+ const allSeries = this.echartsInstance.getOption().series;
3318
+ return allSeries?.some(s => s.datapointId === dpId) ?? false;
3319
+ }
3163
3320
  /** Toggles a single chart series by its exact name (used for per-function legend entries). */
3164
3321
  toggleSeriesByName(seriesName, visible) {
3165
3322
  if (!this.echartsInstance) {
@@ -3321,6 +3478,54 @@ class ChartsComponent {
3321
3478
  series: []
3322
3479
  };
3323
3480
  }
3481
+ /**
3482
+ * Re-spaces the alarm/event rows whenever the chart height changes, so the rows stay inside the
3483
+ * plot area on a short chart and after a resize (the pixel offsets are otherwise fixed at build
3484
+ * time). Starting at -1 makes the observer's initial callback also correct the estimated first
3485
+ * build, once a real grid exists to measure. No-op outside the alarms/events-only chart.
3486
+ */
3487
+ observeChartResize() {
3488
+ const chartDom = typeof this.echartsInstance.getDom === 'function' ? this.echartsInstance.getDom() : null;
3489
+ if (!chartDom || typeof ResizeObserver === 'undefined') {
3490
+ return;
3491
+ }
3492
+ let lastHeight = -1;
3493
+ const resizeObserver = new ResizeObserver(() => {
3494
+ const height = chartDom.clientHeight;
3495
+ if (height === lastHeight) {
3496
+ return;
3497
+ }
3498
+ lastHeight = height;
3499
+ this.echartsInstance.resize();
3500
+ this.chartHelpersService.reapplyAlarmEventRowOffsets(this.echartsInstance, this.echartsOptionsService.isAlarmEventOnlyChart);
3501
+ });
3502
+ resizeObserver.observe(chartDom);
3503
+ this.destroyRef.onDestroy(() => resizeObserver.disconnect());
3504
+ }
3505
+ /**
3506
+ * Finds the one alarm series that currently shows a markArea and builds the `setOption` patch that
3507
+ * removes it, restoring that series' default mark-line. Only one area is ever active, so there is
3508
+ * nothing to iterate; the event lines live on their own series and are left untouched.
3509
+ * @returns The clear patch, or null when no alarm area is active.
3510
+ */
3511
+ getActiveAlarmAreaClearPatch() {
3512
+ const series = (this.echartsInstance.getOption().series ?? []);
3513
+ const activeArea = series.find(s => s.typeOfSeries === 'alarm' && (s.markArea?.data?.length ?? 0) > 0);
3514
+ if (!activeArea) {
3515
+ return null;
3516
+ }
3517
+ const type = String(activeArea.id).split('/')[0];
3518
+ const alarmsOfType = this.alarms.filter(alarm => alarm.type === type);
3519
+ return {
3520
+ id: activeArea.id,
3521
+ markArea: { data: [] },
3522
+ markLine: {
3523
+ showSymbol: false,
3524
+ symbol: ['none', 'none'],
3525
+ data: this.echartsOptionsService.getDefaultMarkLineData(alarmsOfType, 'alarm')
3526
+ }
3527
+ };
3528
+ }
3324
3529
  getMarkedAreaData(clickedAlarms) {
3325
3530
  const timeRange = this.getTimeRange();
3326
3531
  const clearedAlarmColor = 'rgba(221,255,221,1.00)';
@@ -3401,7 +3606,8 @@ class ChartsComponent {
3401
3606
  if ((this.activeDatapoints || this.config.alarmsEventsConfigs?.length > 0) &&
3402
3607
  this.config.realtime &&
3403
3608
  this.echartsInstance) {
3404
- const showOnlyAlarmsOrEvents = this.config.alarmsEventsConfigs?.length > 0 && this.activeDatapoints?.length === 0;
3609
+ const showOnlyAlarmsOrEvents = this.config.alarmsEventsConfigs?.length > 0 &&
3610
+ (!this.activeDatapoints || this.activeDatapoints.length === 0);
3405
3611
  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
3612
  ...this.displayOptions,
3407
3613
  showOnlyAlarmsOrEvents