This is an automated email from the ASF dual-hosted git repository.
riemer pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git
The following commit(s) were added to refs/heads/dev by this push:
new 9bbfee0992 feat: Improve indicator widget (#4211)
9bbfee0992 is described below
commit 9bbfee0992b5702333a3a129903b4c7b29b31235
Author: Dominik Riemer <[email protected]>
AuthorDate: Sat Mar 21 21:52:20 2026 +0100
feat: Improve indicator widget (#4211)
---
ui/cypress/support/utils/chart/ChartBtns.ts | 32 ++
.../tests/chart/chart-types/indicator.spec.ts | 20 +-
.../asset-browser-filter-asset-model.component.ts | 2 -
.../asset-browser-filter-labels.component.ts | 2 -
.../asset-browser-filter-sites.component.ts | 2 -
.../asset-browser-filter-type.component.ts | 2 -
.../indicator-appearance-config.component.html | 45 ++
.../indicator-appearance-config.component.ts | 85 ++++
.../indicator-chart-widget-config.component.html | 31 +-
.../indicator-chart-widget-config.component.ts | 18 +-
.../indicator/indicator-group-card.component.html | 78 +++
.../indicator/indicator-group-card.component.scss | 138 ++++++
.../indicator/indicator-group-card.component.ts | 160 +++++++
.../charts/indicator/indicator-renderer.service.ts | 125 -----
.../indicator/indicator-widget.component.html | 93 ++++
.../indicator/indicator-widget.component.scss | 71 +++
.../charts/indicator/indicator-widget.component.ts | 530 +++++++++++++++++++++
.../model/indicator-chart-widget.model.ts | 12 +-
.../table/config/table-widget-config.component.ts | 3 +-
.../registry/chart-registry.service.ts | 11 +-
ui/src/app/pipelines/pipelines.component.ts | 2 -
21 files changed, 1310 insertions(+), 152 deletions(-)
diff --git a/ui/cypress/support/utils/chart/ChartBtns.ts
b/ui/cypress/support/utils/chart/ChartBtns.ts
index 8c0c5bb2cc..95c09cd292 100644
--- a/ui/cypress/support/utils/chart/ChartBtns.ts
+++ b/ui/cypress/support/utils/chart/ChartBtns.ts
@@ -150,6 +150,38 @@ export class ChartBtns {
return cy.dataCy('chart-data-preview-empty');
}
+ public static indicatorChart() {
+ return cy.dataCy('indicator-chart');
+ }
+
+ public static indicatorChartValue() {
+ return cy.dataCy('indicator-chart-value');
+ }
+
+ public static indicatorChartDelta() {
+ return cy.dataCy('indicator-chart-delta');
+ }
+
+ public static indicatorChartTitle() {
+ return cy.dataCy('indicator-chart-title');
+ }
+
+ public static indicatorChartDescription() {
+ return cy.dataCy('indicator-chart-description');
+ }
+
+ public static indicatorChartTitleInput() {
+ return cy.dataCy('data-explorer-indicator-title-input');
+ }
+
+ public static indicatorChartDescriptionInput() {
+ return cy.dataCy('data-explorer-indicator-description-input');
+ }
+
+ public static indicatorChartDeltaCheckbox() {
+ return cy.dataCy('data-explorer-select-delta-checkbox');
+ }
+
public static addNewWidgetBtn() {
return cy.dataCy('add-new-widget');
}
diff --git a/ui/cypress/tests/chart/chart-types/indicator.spec.ts
b/ui/cypress/tests/chart/chart-types/indicator.spec.ts
index 2589b40e45..9c58af858e 100644
--- a/ui/cypress/tests/chart/chart-types/indicator.spec.ts
+++ b/ui/cypress/tests/chart/chart-types/indicator.spec.ts
@@ -18,6 +18,7 @@
import { ChartUtils } from '../../../support/utils/chart/ChartUtils';
import { PrepareTestDataUtils } from
'../../../support/utils/PrepareTestDataUtils';
+import { ChartBtns } from '../../../support/utils/chart/ChartBtns';
describe('Test Indicator View in Charts', () => {
beforeEach('Setup Test', () => {
@@ -31,10 +32,23 @@ describe('Test Indicator View in Charts', () => {
'indicator-chart',
);
- // Check checkbox
ChartUtils.openVisualizationConfig();
- cy.dataCy('data-explorer-select-delta-checkbox').click();
+ ChartBtns.indicatorChartDeltaCheckbox().click();
+ ChartBtns.indicatorChartTitleInput().type('Current Metric');
+ ChartBtns.indicatorChartDescriptionInput().type(
+ 'Live value compared to the previous event.',
+ );
- cy.dataCy('indicator-chart').should('be.visible');
+ ChartBtns.indicatorChart().should('be.visible');
+ ChartBtns.indicatorChartTitle().should(
+ 'contain.text',
+ 'Current Metric',
+ );
+ ChartBtns.indicatorChartDescription().should(
+ 'contain.text',
+ 'Live value compared to the previous event.',
+ );
+ ChartBtns.indicatorChartValue().should('not.be.empty');
+ ChartBtns.indicatorChartDelta().should('be.visible');
});
});
diff --git
a/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-asset-model/asset-browser-filter-asset-model.component.ts
b/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-asset-model/asset-browser-filter-asset-model.component.ts
index 66764f9e4d..067d59a6d0 100644
---
a/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-asset-model/asset-browser-filter-asset-model.component.ts
+++
b/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-asset-model/asset-browser-filter-asset-model.component.ts
@@ -23,7 +23,6 @@ import { AssetBrowserFilterOuterComponent } from
'../asset-browser-filter-outer/
import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect } from '@angular/material/select';
import { FormsModule } from '@angular/forms';
-import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'sp-asset-browser-filter-asset-model',
@@ -35,7 +34,6 @@ import { TranslatePipe } from '@ngx-translate/core';
MatSelect,
FormsModule,
MatOption,
- TranslatePipe,
],
})
export class AssetBrowserFilterAssetModelComponent {
diff --git
a/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-labels/asset-browser-filter-labels.component.ts
b/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-labels/asset-browser-filter-labels.component.ts
index 4c89c0a05c..1ff907baab 100644
---
a/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-labels/asset-browser-filter-labels.component.ts
+++
b/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-labels/asset-browser-filter-labels.component.ts
@@ -24,7 +24,6 @@ import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect } from '@angular/material/select';
import { FormsModule } from '@angular/forms';
import { SpLabelComponent } from '../../../../sp-label/sp-label.component';
-import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'sp-asset-browser-filter-labels',
@@ -37,7 +36,6 @@ import { TranslatePipe } from '@ngx-translate/core';
FormsModule,
MatOption,
SpLabelComponent,
- TranslatePipe,
],
})
export class AssetBrowserFilterLabelsComponent {
diff --git
a/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-sites/asset-browser-filter-sites.component.ts
b/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-sites/asset-browser-filter-sites.component.ts
index 8c3c042ba7..a20ec0195c 100644
---
a/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-sites/asset-browser-filter-sites.component.ts
+++
b/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-sites/asset-browser-filter-sites.component.ts
@@ -23,7 +23,6 @@ import { AssetBrowserFilterOuterComponent } from
'../asset-browser-filter-outer/
import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect } from '@angular/material/select';
import { FormsModule } from '@angular/forms';
-import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'sp-asset-browser-filter-sites',
@@ -35,7 +34,6 @@ import { TranslatePipe } from '@ngx-translate/core';
MatSelect,
FormsModule,
MatOption,
- TranslatePipe,
],
})
export class AssetBrowserFilterSitesComponent {
diff --git
a/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-type/asset-browser-filter-type.component.ts
b/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-type/asset-browser-filter-type.component.ts
index 2afb724894..8e2a518828 100644
---
a/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-type/asset-browser-filter-type.component.ts
+++
b/ui/projects/streampipes/shared-ui/src/lib/components/asset-browser/asset-browser-toolbar/asset-browser-filter/asset-browser-filter-type/asset-browser-filter-type.component.ts
@@ -26,7 +26,6 @@ import { AssetBrowserFilterOuterComponent } from
'../asset-browser-filter-outer/
import { MatFormField } from '@angular/material/form-field';
import { MatOption, MatSelect } from '@angular/material/select';
import { FormsModule } from '@angular/forms';
-import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'sp-asset-browser-filter-type',
@@ -38,7 +37,6 @@ import { TranslatePipe } from '@ngx-translate/core';
MatSelect,
FormsModule,
MatOption,
- TranslatePipe,
],
})
export class AssetBrowserFilterTypeComponent implements OnInit {
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/appearance-config/indicator-appearance-config.component.html
b/ui/src/app/chart-shared/components/charts/indicator/appearance-config/indicator-appearance-config.component.html
new file mode 100644
index 0000000000..80abcad800
--- /dev/null
+++
b/ui/src/app/chart-shared/components/charts/indicator/appearance-config/indicator-appearance-config.component.html
@@ -0,0 +1,45 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ ~
+ -->
+
+<sp-split-section [level]="3" [title]="'Typography' | translate">
+ <sp-form-field [level]="3" [label]="'Indicator font size' | translate">
+ <mat-form-field appearance="outline" color="accent" fxFlex="100">
+ <input
+ matInput
+ type="number"
+ min="1"
+ [ngModel]="config.valueFontSize"
+ (ngModelChange)="updateFontSize('valueFontSize', $event)"
+ placeholder="{{ 'Auto' | translate }}"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field [level]="3" [label]="'Delta font size' | translate">
+ <mat-form-field appearance="outline" color="accent" fxFlex="100">
+ <input
+ matInput
+ type="number"
+ min="1"
+ [ngModel]="config.deltaFontSize"
+ (ngModelChange)="updateFontSize('deltaFontSize', $event)"
+ placeholder="{{ 'Auto' | translate }}"
+ />
+ </mat-form-field>
+ </sp-form-field>
+</sp-split-section>
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/appearance-config/indicator-appearance-config.component.ts
b/ui/src/app/chart-shared/components/charts/indicator/appearance-config/indicator-appearance-config.component.ts
new file mode 100644
index 0000000000..ceaf45c7e3
--- /dev/null
+++
b/ui/src/app/chart-shared/components/charts/indicator/appearance-config/indicator-appearance-config.component.ts
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { Component, Input, OnInit } from '@angular/core';
+import { ChartConfigurationService } from
'../../../../services/chart-configuration.service';
+import { IndicatorAppearanceConfig } from
'../model/indicator-chart-widget.model';
+import {
+ FormFieldComponent,
+ SplitSectionComponent,
+} from '@streampipes/shared-ui';
+import { MatFormField } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { FormsModule } from '@angular/forms';
+import { FlexDirective } from '@ngbracket/ngx-layout/flex';
+import { TranslatePipe } from '@ngx-translate/core';
+
+@Component({
+ selector: 'sp-indicator-appearance-config',
+ templateUrl: './indicator-appearance-config.component.html',
+ imports: [
+ SplitSectionComponent,
+ FormFieldComponent,
+ MatFormField,
+ MatInput,
+ FormsModule,
+ FlexDirective,
+ TranslatePipe,
+ ],
+})
+export class IndicatorAppearanceConfigComponent implements OnInit {
+ @Input()
+ appearanceConfig: IndicatorAppearanceConfig;
+
+ constructor(
+ private widgetConfigurationService: ChartConfigurationService,
+ ) {}
+
+ ngOnInit(): void {
+ this.ensureAppearanceConfig();
+ }
+
+ get config(): IndicatorAppearanceConfig {
+ return this.ensureAppearanceConfig();
+ }
+
+ updateFontSize(
+ key: 'valueFontSize' | 'deltaFontSize',
+ value: number | null,
+ ): void {
+ this.config[key] =
+ value === null || value === undefined || value <= 0
+ ? undefined
+ : value;
+
+ this.widgetConfigurationService.notify({
+ refreshView: true,
+ refreshData: false,
+ });
+ }
+
+ private ensureAppearanceConfig(): IndicatorAppearanceConfig {
+ this.appearanceConfig ??= {
+ backgroundColor: 'var(--color-bg-0)',
+ textColor: 'var(--color-default-text)',
+ widgetTitle: '',
+ };
+
+ return this.appearanceConfig;
+ }
+}
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/config/indicator-chart-widget-config.component.html
b/ui/src/app/chart-shared/components/charts/indicator/config/indicator-chart-widget-config.component.html
index 941eec9754..97f269dbd0 100644
---
a/ui/src/app/chart-shared/components/charts/indicator/config/indicator-chart-widget-config.component.html
+++
b/ui/src/app/chart-shared/components/charts/indicator/config/indicator-chart-widget-config.component.html
@@ -34,11 +34,40 @@
<sp-split-section [level]="3" [title]="'Settings' | translate">
<mat-checkbox
data-cy="data-explorer-select-delta-checkbox"
- (change)="updateDelta($event)"
+ (change)="updateDelta()"
[(ngModel)]="
currentlyConfiguredWidget.visualizationConfig.showDelta
"
>{{ 'Show delta indicator' | translate }}
</mat-checkbox>
</sp-split-section>
+ <sp-split-section [level]="3" [title]="'Content' | translate">
+ <sp-form-field [level]="3" [label]="'Title' | translate">
+ <mat-form-field fxFlex="100">
+ <input
+ data-cy="data-explorer-indicator-title-input"
+ matInput
+ type="text"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig.title
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+ <sp-form-field [level]="3" [label]="'Description' | translate">
+ <mat-form-field fxFlex="100">
+ <textarea
+ data-cy="data-explorer-indicator-description-input"
+ matInput
+ rows="3"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .description
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ ></textarea>
+ </mat-form-field>
+ </sp-form-field>
+ </sp-split-section>
</sp-visualization-config-outer>
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/config/indicator-chart-widget-config.component.ts
b/ui/src/app/chart-shared/components/charts/indicator/config/indicator-chart-widget-config.component.ts
index ee46ca678f..c86462b791 100644
---
a/ui/src/app/chart-shared/components/charts/indicator/config/indicator-chart-widget-config.component.ts
+++
b/ui/src/app/chart-shared/components/charts/indicator/config/indicator-chart-widget-config.component.ts
@@ -23,12 +23,18 @@ import {
IndicatorChartWidgetModel,
} from '../model/indicator-chart-widget.model';
import { DataExplorerField } from '@streampipes/platform-services';
-import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
+import { MatCheckbox } from '@angular/material/checkbox';
import { SpVisualizationConfigOuterComponent } from
'../../../chart-config/visualization-config-outer/visualization-config-outer.component';
-import { SplitSectionComponent } from '@streampipes/shared-ui';
+import {
+ FormFieldComponent,
+ SplitSectionComponent,
+} from '@streampipes/shared-ui';
import { SelectSinglePropertyConfigComponent } from
'../../../chart-config/select-single-property-config/select-single-property-config.component';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '@ngx-translate/core';
+import { MatFormField } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { FlexDirective } from '@ngbracket/ngx-layout/flex';
@Component({
selector: 'sp-data-explorer-indicator-chart-widget-config',
@@ -36,8 +42,12 @@ import { TranslatePipe } from '@ngx-translate/core';
imports: [
SpVisualizationConfigOuterComponent,
SplitSectionComponent,
+ FormFieldComponent,
SelectSinglePropertyConfigComponent,
MatCheckbox,
+ MatFormField,
+ MatInput,
+ FlexDirective,
FormsModule,
TranslatePipe,
],
@@ -51,7 +61,7 @@ export class IndicatorWidgetConfigComponent extends
BaseWidgetConfig<
this.triggerViewRefresh();
}
- updateDelta(event: MatCheckboxChange) {
+ updateDelta() {
this.triggerViewRefresh();
}
@@ -66,6 +76,8 @@ export class IndicatorWidgetConfigComponent extends
BaseWidgetConfig<
},
);
config.showDelta ??= false;
+ config.title ??= '';
+ config.description ??= '';
}
protected requiredFieldsForChartPresent(): boolean {
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.html
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.html
new file mode 100644
index 0000000000..73d18db8b5
--- /dev/null
+++
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.html
@@ -0,0 +1,78 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ ~
+ -->
+
+<div
+ class="indicator-card"
+ fxLayout="column"
+ [ngStyle]="cardStyles"
+ [class.grouped]="grouped"
+ data-cy="indicator-chart-card"
+>
+ @if (card.label) {
+ <div class="indicator-card-copy" fxLayout="column">
+ <div class="indicator-card-label">{{ card.label }}</div>
+ </div>
+ }
+
+ <div
+ class="indicator-card-value-panel"
+ fxLayout="column"
+ fxLayoutAlign="center center"
+ fxFlex
+ >
+ <div class="indicator-card-value" data-cy="indicator-chart-value">
+ {{ card.displayValue }}
+ </div>
+ </div>
+
+ @if (card.deltaView) {
+ <div
+ class="indicator-card-delta"
+ fxLayout="row"
+ fxLayoutAlign="start center"
+ data-cy="indicator-chart-delta"
+ >
+ <div
+ class="indicator-card-delta-marker"
+ fxLayout="row"
+ fxLayoutAlign="center center"
+ >
+ <mat-icon class="indicator-card-delta-icon">
+ {{ card.deltaView.icon }}
+ </mat-icon>
+ </div>
+ <div class="indicator-card-delta-copy" fxLayout="column">
+ <div class="indicator-card-delta-label">
+ <span class="indicator-card-delta-value">
+ {{ card.deltaView.label }}
+ </span>
+ @if (card.deltaView.detail) {
+ <span class="indicator-card-delta-detail">
+ {{ card.deltaView.detail }}
+ </span>
+ }
+ </div>
+ @if (card.deltaView.meta) {
+ <div class="indicator-card-delta-meta">
+ {{ card.deltaView.meta }}
+ </div>
+ }
+ </div>
+ </div>
+ }
+</div>
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.scss
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.scss
new file mode 100644
index 0000000000..da10e8c70d
--- /dev/null
+++
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.scss
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+:host {
+ display: block;
+ height: 100%;
+ min-height: 0;
+}
+
+.indicator-card {
+ background: color-mix(
+ in srgb,
+ var(--indicator-selected-background) 88%,
+ white
+ );
+ border: 1px solid
+ color-mix(in srgb, var(--indicator-selected-background) 78%, white);
+ border-radius: calc(var(--space-md) * 1.2);
+ box-sizing: border-box;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ padding: var(--indicator-card-padding);
+}
+
+.indicator-card.grouped {
+ background: color-mix(
+ in srgb,
+ var(--indicator-selected-background) 86%,
+ white
+ );
+}
+
+.indicator-card-copy {
+ margin-bottom: var(--indicator-card-copy-gap);
+ min-height: 0;
+ min-width: 0;
+}
+
+.indicator-card-label {
+ color: color-mix(in srgb, currentColor 82%, transparent);
+ font-size: var(--indicator-card-label-size);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ line-height: 1.1;
+ overflow: hidden;
+ overflow-wrap: anywhere;
+ text-wrap: balance;
+}
+
+.indicator-card-value-panel {
+ margin-bottom: var(--indicator-card-value-gap);
+ min-height: 0;
+ text-align: center;
+}
+
+.indicator-card-value {
+ font-size: var(--indicator-card-value-size);
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ line-height: 0.95;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+}
+
+.indicator-card-delta {
+ border-radius: calc(var(--space-md) * 1.1);
+ box-sizing: border-box;
+ gap: calc(var(--space-sm) * 0.8);
+ margin-inline: auto;
+ max-width: 100%;
+ min-height: var(--indicator-card-delta-height);
+ padding: var(--space-2xs) var(--space-sm) var(--space-2xs)
var(--space-2xs);
+}
+
+.indicator-card-delta-marker {
+ background: color-mix(in srgb, currentColor 12%, transparent);
+ border-radius: 999px;
+ color: inherit;
+ flex: 0 0 auto;
+ height: calc(var(--indicator-card-delta-size) * 1.55);
+ width: calc(var(--indicator-card-delta-size) * 1.55);
+}
+
+.indicator-card-delta-icon {
+ color: inherit;
+ font-size: calc(var(--indicator-card-delta-size) * 0.82);
+ height: calc(var(--indicator-card-delta-size) * 0.82);
+ width: calc(var(--indicator-card-delta-size) * 0.82);
+}
+
+.indicator-card-delta-copy {
+ flex: 1 1 auto;
+ min-height: 0;
+ min-width: 0;
+}
+
+.indicator-card-delta-label {
+ align-items: baseline;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: var(--indicator-card-delta-size);
+ font-weight: 600;
+ gap: calc(var(--space-xs) * 0.8);
+ line-height: 0.95;
+ overflow: hidden;
+}
+
+.indicator-card-delta-value {
+ font-weight: 700;
+}
+
+.indicator-card-delta-detail {
+ font-size: calc(var(--indicator-card-delta-size) * 0.84);
+ font-weight: 600;
+}
+
+.indicator-card-delta-meta {
+ font-size: var(--indicator-card-delta-meta-size);
+ line-height: 1.1;
+ overflow: hidden;
+ overflow-wrap: anywhere;
+}
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.ts
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.ts
new file mode 100644
index 0000000000..6294976c63
--- /dev/null
+++
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.ts
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { NgStyle } from '@angular/common';
+import { Component, Input } from '@angular/core';
+import { MatIcon } from '@angular/material/icon';
+import {
+ FlexDirective,
+ LayoutAlignDirective,
+ LayoutDirective,
+} from '@ngbracket/ngx-layout/flex';
+
+export type IndicatorDeltaTone = 'positive' | 'negative' | 'neutral';
+
+export interface IndicatorDeltaView {
+ icon: string;
+ label: string;
+ meta?: string;
+ detail?: string;
+ tone: IndicatorDeltaTone;
+}
+
+export interface IndicatorGroupCardView {
+ id: string;
+ label?: string;
+ displayValue: string;
+ deltaView?: IndicatorDeltaView;
+}
+
+@Component({
+ selector: 'sp-indicator-group-card',
+ templateUrl: './indicator-group-card.component.html',
+ styleUrls: ['./indicator-group-card.component.scss'],
+ imports: [
+ LayoutDirective,
+ LayoutAlignDirective,
+ FlexDirective,
+ NgStyle,
+ MatIcon,
+ ],
+})
+export class IndicatorGroupCardComponent {
+ private static readonly REFERENCE_CARD_WIDTH = 560;
+ private static readonly REFERENCE_CARD_HEIGHT = 320;
+ private static readonly MIN_FONT_SCALE = 0.15;
+ private static readonly MAX_FONT_SCALE = 1;
+
+ @Input({ required: true }) card: IndicatorGroupCardView;
+ @Input() cardWidth = 320;
+ @Input() cardHeight = 240;
+ @Input() grouped = false;
+ @Input() manualValueFontSize?: number;
+ @Input() manualDeltaFontSize?: number;
+ @Input() scaleManualFonts = false;
+
+ get cardStyles(): Record<string, string> {
+ const tinyMode = this.cardWidth < 220 || this.cardHeight < 150;
+ const compactMode =
+ this.grouped || this.cardWidth < 320 || this.cardHeight < 220;
+
+ const defaults = tinyMode
+ ? {
+ padding: 8,
+ copyGap: 3,
+ valueGap: 2,
+ labelSize: 11,
+ valueSize: 34,
+ deltaSize: 13,
+ deltaMetaSize: 9,
+ deltaHeight: 24,
+ }
+ : compactMode
+ ? {
+ padding: 10,
+ copyGap: 4,
+ valueGap: 4,
+ labelSize: 12,
+ valueSize: 48,
+ deltaSize: 16,
+ deltaMetaSize: 10,
+ deltaHeight: 30,
+ }
+ : {
+ padding: 16,
+ copyGap: 6,
+ valueGap: 6,
+ labelSize: 14,
+ valueSize: 72,
+ deltaSize: 20,
+ deltaMetaSize: 12,
+ deltaHeight: 38,
+ };
+
+ return {
+ '--indicator-card-padding': `${defaults.padding}px`,
+ '--indicator-card-copy-gap': `${defaults.copyGap}px`,
+ '--indicator-card-value-gap': `${defaults.valueGap}px`,
+ '--indicator-card-label-size': `${defaults.labelSize}px`,
+ '--indicator-card-value-size': `${this.resolveManualFontSize(
+ this.manualValueFontSize,
+ defaults.valueSize,
+ )}px`,
+ '--indicator-card-delta-size': `${this.resolveManualFontSize(
+ this.manualDeltaFontSize,
+ defaults.deltaSize,
+ )}px`,
+ '--indicator-card-delta-meta-size': `${defaults.deltaMetaSize}px`,
+ '--indicator-card-delta-height': `${defaults.deltaHeight}px`,
+ };
+ }
+
+ private resolveManualFontSize(
+ manualSize: number | undefined,
+ fallbackSize: number,
+ ): number {
+ if (
+ manualSize === undefined ||
+ manualSize === null ||
+ Number.isNaN(Number(manualSize)) ||
+ manualSize <= 0
+ ) {
+ return fallbackSize;
+ }
+
+ if (!this.scaleManualFonts) {
+ return manualSize;
+ }
+
+ const widthScale =
+ this.cardWidth / IndicatorGroupCardComponent.REFERENCE_CARD_WIDTH;
+ const heightScale =
+ this.cardHeight /
IndicatorGroupCardComponent.REFERENCE_CARD_HEIGHT;
+ const scale = this.clamp(
+ Math.min(widthScale, heightScale),
+ IndicatorGroupCardComponent.MIN_FONT_SCALE,
+ IndicatorGroupCardComponent.MAX_FONT_SCALE,
+ );
+
+ return Math.round(manualSize * scale * 10) / 10;
+ }
+
+ private clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+ }
+}
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/indicator-renderer.service.ts
b/ui/src/app/chart-shared/components/charts/indicator/indicator-renderer.service.ts
deleted file mode 100644
index 9c19c9b4f2..0000000000
---
a/ui/src/app/chart-shared/components/charts/indicator/indicator-renderer.service.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-
-import { Injectable } from '@angular/core';
-import { SpBaseEchartsRenderer } from
'../../../echarts-renderer/base-echarts-renderer';
-import { IndicatorChartWidgetModel } from
'./model/indicator-chart-widget.model';
-import { GeneratedDataset, WidgetSize } from '../../../models/dataset.model';
-import { EChartsOption, GraphicComponentOption } from 'echarts';
-import { FieldUpdateInfo } from '../../../models/field-update.model';
-
-@Injectable({ providedIn: 'root' })
-export class SpIndicatorRendererService extends
SpBaseEchartsRenderer<IndicatorChartWidgetModel> {
- applyOptions(
- generatedDataset: GeneratedDataset,
- options: EChartsOption,
- widgetConfig: IndicatorChartWidgetModel,
- widgetSize: WidgetSize,
- ): void {
- const field = widgetConfig.visualizationConfig.valueField;
- const datasetOption = this.datasetUtilsService.findPreparedDataset(
- generatedDataset,
- field.sourceIndex,
- ).rawDataset;
- const fieldIndex = datasetOption.dimensions.indexOf(field.fullDbName);
- const datasetSize = datasetOption.source.length as number;
- const value = (datasetOption.source as any)[datasetSize - 1][
- fieldIndex
- ];
- const graphicElements: GraphicComponentOption[] = [];
- let previousValue = undefined;
- graphicElements.push(this.makeCurrentValue(value, widgetConfig));
- if (datasetSize > 1 && widgetConfig.visualizationConfig.showDelta) {
- previousValue = (datasetOption.source as any)[datasetSize - 2][
- fieldIndex
- ];
- graphicElements.push(
- this.makeDelta(value, previousValue, widgetConfig),
- );
- }
-
- Object.assign(options, {
- graphic: {
- elements: graphicElements,
- },
- });
- }
-
- public handleUpdatedFields(
- fieldUpdateInfo: FieldUpdateInfo,
- widgetConfig: IndicatorChartWidgetModel,
- ): void {
- widgetConfig.visualizationConfig.valueField =
- this.fieldUpdateService.updateSingleField(
- widgetConfig.visualizationConfig.valueField,
- fieldUpdateInfo.fieldProvider.allFields,
- fieldUpdateInfo,
- field => true,
- );
- }
-
- makeCurrentValue(
- value: any,
- widgetConfig: IndicatorChartWidgetModel,
- ): GraphicComponentOption {
- return {
- type: 'text',
- left: 'center',
- top: '30%',
- style: this.makeTextStyle(value, 80, widgetConfig),
- };
- }
-
- makeDelta(
- value: any,
- previousValue: any,
- widgetConfig: IndicatorChartWidgetModel,
- ): GraphicComponentOption {
- const delta = value - previousValue;
- return {
- type: 'text',
- left: 'center',
- top: '50%',
- style: this.makeTextStyle(delta, 50, widgetConfig),
- };
- }
-
- makeTextStyle(
- textContent: any,
- fontSize: number,
- widgetConfig: IndicatorChartWidgetModel,
- ) {
- return {
- text: this.formatValue(textContent),
- fontSize,
- fontWeight: 'bold',
- lineDash: [0, 200],
- lineDashOffset: 0,
- fill: widgetConfig.baseAppearanceConfig.textColor,
- lineWidth: 1,
- };
- }
-
- formatValue(value: any): any {
- if (typeof value === 'number') {
- return parseFloat(value.toFixed(3));
- } else {
- return value;
- }
- }
-}
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.html
b/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.html
new file mode 100644
index 0000000000..dc55ff9a5f
--- /dev/null
+++
b/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.html
@@ -0,0 +1,93 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ ~
+ -->
+
+<div
+ class="indicator-widget"
+ fxLayout="column"
+ fxFlex="100"
+ [ngStyle]="widgetStyles"
+ data-cy="indicator-chart"
+>
+ @if (showNoDataInDateRange) {
+ <sp-no-data-in-date-range [viewDateRange]="timeSettings" class="h-100">
+ </sp-no-data-in-date-range>
+ }
+
+ @if (showTooMuchData) {
+ <sp-too-much-data
+ [amountOfEvents]="amountOfTooMuchEvents"
+ (loadDataWithTooManyEventsEmitter)="loadDataWithTooManyEvents()"
+ class="h-100"
+ >
+ </sp-too-much-data>
+ }
+
+ @if (showInvalidConfiguration) {
+ <sp-invalid-configuration
+ [widgetTypeLabel]="widgetTypeLabel"
+ class="h-100"
+ >
+ </sp-invalid-configuration>
+ }
+
+ @if (showData) {
+ <div class="indicator-shell" fxLayout="column" fxFlex="100">
+ @if (titleText || descriptionText) {
+ <div class="indicator-copy" fxLayout="column">
+ @if (titleText) {
+ <div
+ class="indicator-title"
+ data-cy="indicator-chart-title"
+ >
+ {{ titleText }}
+ </div>
+ }
+
+ @if (descriptionText) {
+ <div
+ class="indicator-description"
+ data-cy="indicator-chart-description"
+ >
+ {{ descriptionText }}
+ </div>
+ }
+ </div>
+ }
+
+ <div
+ class="indicator-grid"
+ fxFlex
+ [class.grouped]="hasMultipleCards"
+ [ngStyle]="gridStyles"
+ >
+ @for (card of indicatorCards; track trackCard($index, card)) {
+ <sp-indicator-group-card
+ [card]="card"
+ [cardWidth]="cardWidth"
+ [cardHeight]="cardHeight"
+ [grouped]="hasMultipleCards"
+ [manualValueFontSize]="appearanceConfig.valueFontSize"
+ [manualDeltaFontSize]="appearanceConfig.deltaFontSize"
+ [scaleManualFonts]="!dataViewMode"
+ >
+ </sp-indicator-group-card>
+ }
+ </div>
+ </div>
+ }
+</div>
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.scss
b/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.scss
new file mode 100644
index 0000000000..1036ea7710
--- /dev/null
+++
b/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.scss
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+:host {
+ display: block;
+ height: 100%;
+}
+
+.h-100 {
+ height: 100%;
+}
+
+.indicator-widget {
+ box-sizing: border-box;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ padding: var(--indicator-padding);
+}
+
+.indicator-shell {
+ gap: var(--indicator-gap);
+ min-height: 0;
+}
+
+.indicator-copy {
+ gap: calc(var(--indicator-gap) * 0.45);
+}
+
+.indicator-title {
+ font-size: var(--indicator-title-size);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ line-height: 1.1;
+}
+
+.indicator-description {
+ color: color-mix(in srgb, currentColor 72%, transparent);
+ font-size: var(--indicator-description-size);
+ line-height: 1.35;
+ max-width: 42ch;
+}
+
+.indicator-grid {
+ display: grid;
+ gap: var(--indicator-gap);
+ grid-template-columns: repeat(
+ var(--indicator-grid-columns),
+ minmax(0, 1fr)
+ );
+ min-height: 0;
+}
+
+.indicator-grid.grouped {
+ align-content: start;
+}
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.ts
b/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.ts
new file mode 100644
index 0000000000..5582955692
--- /dev/null
+++
b/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.ts
@@ -0,0 +1,530 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { NgStyle } from '@angular/common';
+import { Component, inject, LOCALE_ID, OnInit } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { FlexDirective, LayoutDirective } from '@ngbracket/ngx-layout/flex';
+import {
+ DataExplorerField,
+ DataSeries,
+ SpQueryResult,
+} from '@streampipes/platform-services';
+import { BaseDataExplorerWidgetDirective } from
'../base/base-data-explorer-widget.directive';
+import { SpInvalidConfigurationComponent } from
'../base/invalid-configuration/invalid-configuration.component';
+import { NoDataInDateRangeComponent } from
'../base/no-data/no-data-in-date-range.component';
+import { TooMuchDataComponent } from
'../base/too-much-data/too-much-data.component';
+import {
+ IndicatorDeltaView,
+ IndicatorGroupCardComponent,
+ IndicatorGroupCardView,
+} from './indicator-group-card.component';
+import {
+ IndicatorAppearanceConfig,
+ IndicatorChartWidgetModel,
+} from './model/indicator-chart-widget.model';
+
+@Component({
+ selector: 'sp-data-explorer-indicator-widget',
+ templateUrl: './indicator-widget.component.html',
+ styleUrls: ['./indicator-widget.component.scss'],
+ imports: [
+ LayoutDirective,
+ FlexDirective,
+ NgStyle,
+ NoDataInDateRangeComponent,
+ TooMuchDataComponent,
+ SpInvalidConfigurationComponent,
+ IndicatorGroupCardComponent,
+ ],
+})
+export class IndicatorWidgetComponent
+ extends BaseDataExplorerWidgetDirective<IndicatorChartWidgetModel>
+ implements OnInit
+{
+ indicatorCards: IndicatorGroupCardView[] = [];
+ widgetTypeLabel: string;
+
+ private hasReceivedData = false;
+ private latestData: SpQueryResult[] = [];
+ private readonly locale = inject(LOCALE_ID);
+ private readonly translateService = inject(TranslateService);
+
+ ngOnInit(): void {
+ super.ngOnInit();
+ this.widgetTypeLabel = this.widgetRegistryService.getChartTemplate(
+ this.dataExplorerWidget.widgetType,
+ ).label;
+ }
+
+ get widgetStyles(): Record<string, string> {
+ const compactMode =
+ this.hasMultipleCards || (this.currentWidth ?? 0) < 640;
+ const appearanceConfig = this.appearanceConfig;
+
+ return {
+ 'background': appearanceConfig.backgroundColor,
+ 'color': appearanceConfig.textColor,
+ '--indicator-selected-background':
appearanceConfig.backgroundColor,
+ '--indicator-padding': compactMode ? '12px' : '18px',
+ '--indicator-gap': compactMode ? '10px' : '16px',
+ '--indicator-title-size': compactMode ? '18px' : '24px',
+ '--indicator-description-size': compactMode ? '12px' : '14px',
+ };
+ }
+
+ get gridStyles(): Record<string, string> {
+ return {
+ '--indicator-grid-columns': `${this.gridColumnCount}`,
+ };
+ }
+
+ get appearanceConfig(): IndicatorAppearanceConfig {
+ this.dataExplorerWidget.baseAppearanceConfig ??= {
+ backgroundColor: 'var(--color-bg-0)',
+ textColor: 'var(--color-default-text)',
+ widgetTitle: '',
+ };
+
+ return this.dataExplorerWidget
+ .baseAppearanceConfig as IndicatorAppearanceConfig;
+ }
+
+ get titleText(): string {
+ return this.dataExplorerWidget.visualizationConfig.title?.trim() ?? '';
+ }
+
+ get descriptionText(): string {
+ return (
+ this.dataExplorerWidget.visualizationConfig.description?.trim() ??
+ ''
+ );
+ }
+
+ get hasMultipleCards(): boolean {
+ return this.indicatorCards.length > 1;
+ }
+
+ get cardWidth(): number {
+ const width = this.currentWidth ?? 0;
+ const gap = this.estimatedGap;
+ return Math.max(
+ (width - gap * Math.max(this.gridColumnCount - 1, 0)) /
+ this.gridColumnCount,
+ 180,
+ );
+ }
+
+ get cardHeight(): number {
+ const availableHeight =
+ (this.currentHeight ?? 0) -
+ this.estimatedCopyHeight -
+ this.estimatedPadding * 2 -
+ this.estimatedGap * Math.max(this.gridRowCount - 1, 0);
+
+ return Math.max(
+ availableHeight / this.gridRowCount,
+ this.hasMultipleCards ? 92 : 120,
+ );
+ }
+
+ beforeDataFetched(): void {
+ this.setShownComponents(false, false, true, false);
+ }
+
+ onDataReceived(spQueryResults: SpQueryResult[]): void {
+ this.hasReceivedData = true;
+ this.latestData = spQueryResults;
+ this.updateIndicator();
+ }
+
+ onResize(_width: number, _height: number): void {
+ this.refreshView();
+ }
+
+ refreshView(): void {
+ this.updateIndicator();
+ }
+
+ handleUpdatedFields(
+ addedFields: DataExplorerField[],
+ removedFields: DataExplorerField[],
+ ): void {
+ const fieldUpdateInfo = {
+ addedFields,
+ removedFields,
+ fieldProvider: this.fieldProvider,
+ };
+
+ this.dataExplorerWidget.visualizationConfig.valueField =
+ this.fieldUpdateService.updateSingleField(
+ this.dataExplorerWidget.visualizationConfig.valueField,
+ fieldUpdateInfo.fieldProvider.allFields,
+ fieldUpdateInfo,
+ () => true,
+ );
+ this.dataExplorerWidget.visualizationConfig.deltaField =
+ this.fieldUpdateService.updateSingleField(
+ this.dataExplorerWidget.visualizationConfig.deltaField,
+ fieldUpdateInfo.fieldProvider.allFields,
+ fieldUpdateInfo,
+ () => true,
+ );
+ this.refreshView();
+ }
+
+ trackCard(_index: number, card: IndicatorGroupCardView): string {
+ return card.id;
+ }
+
+ private updateIndicator(): void {
+ if (
+ this.dataExplorerWidget.visualizationConfig.configurationValid ===
+ false
+ ) {
+ this.showInvalidConfiguration = true;
+ this.indicatorCards = [];
+ this.setShownComponents(false, false, false, false);
+ return;
+ }
+
+ this.showInvalidConfiguration = false;
+ if (!this.hasReceivedData && this.latestData.length === 0) {
+ return;
+ }
+
+ const valueField =
+ this.dataExplorerWidget.visualizationConfig.valueField;
+ this.indicatorCards = this.buildCards(valueField);
+
+ if (this.indicatorCards.length === 0) {
+ this.setShownComponents(true, false, false, false);
+ return;
+ }
+
+ this.setShownComponents(false, true, false, false);
+ }
+
+ private buildCards(
+ valueField: DataExplorerField | undefined,
+ ): IndicatorGroupCardView[] {
+ if (!valueField) {
+ return [];
+ }
+
+ const result = this.findQueryResult(valueField.sourceIndex);
+ if (!result) {
+ return [];
+ }
+
+ const seriesList = result?.allDataSeries ?? [];
+
+ return seriesList
+ .map((series, index) =>
+ this.createCard(valueField, result, series, index),
+ )
+ .filter(
+ (card): card is IndicatorGroupCardView => card !== undefined,
+ );
+ }
+
+ private createCard(
+ valueField: DataExplorerField,
+ result: SpQueryResult,
+ series: DataSeries,
+ index: number,
+ ): IndicatorGroupCardView | undefined {
+ const currentValue = this.getSeriesFieldValue(
+ result,
+ series,
+ valueField,
+ 0,
+ );
+
+ if (currentValue === undefined) {
+ return undefined;
+ }
+
+ const groupInfo = this.makeGroupInfo(series.tags ?? {});
+
+ return {
+ id: `${result.sourceIndex}-${index}-${this.makeTagSignature(
+ series.tags ?? {},
+ )}`,
+ label: groupInfo.label,
+ displayValue: this.formatValue(currentValue),
+ deltaView: this.buildDelta(valueField, series, currentValue),
+ };
+ }
+
+ private buildDelta(
+ valueField: DataExplorerField,
+ series: DataSeries,
+ currentValue: unknown,
+ ): IndicatorDeltaView | undefined {
+ if (!this.dataExplorerWidget.visualizationConfig.showDelta) {
+ return undefined;
+ }
+
+ const deltaField =
+ this.dataExplorerWidget.visualizationConfig.deltaField;
+ const referenceValue = deltaField
+ ? this.getMatchingGroupValue(deltaField, series.tags ?? {})
+ : this.getSeriesFieldValue(
+ this.findQueryResult(valueField.sourceIndex),
+ series,
+ valueField,
+ 1,
+ );
+
+ if (referenceValue === undefined) {
+ return undefined;
+ }
+
+ const referenceLabel = deltaField ? deltaField.runtimeName : undefined;
+
+ if (
+ typeof currentValue === 'number' &&
+ typeof referenceValue === 'number'
+ ) {
+ const delta = this.normalizeNumericDelta(
+ currentValue - referenceValue,
+ );
+ const percentDelta =
+ referenceValue === 0
+ ? undefined
+ : this.normalizeNumericDelta(
+ delta / Math.abs(referenceValue),
+ );
+
+ return {
+ icon:
+ delta > 0
+ ? 'trending_up'
+ : delta < 0
+ ? 'trending_down'
+ : 'trending_flat',
+ label: this.formatSignedNumber(delta),
+ detail:
+ percentDelta !== undefined
+ ? this.formatSignedPercent(percentDelta)
+ : undefined,
+ meta: referenceLabel,
+ tone:
+ delta > 0 ? 'positive' : delta < 0 ? 'negative' :
'neutral',
+ };
+ }
+
+ const changed = currentValue !== referenceValue;
+
+ return {
+ icon: changed ? 'compare_arrows' : 'horizontal_rule',
+ label: this.translateService.instant(
+ changed ? 'Changed' : 'No change',
+ ),
+ meta: referenceLabel,
+ tone: 'neutral',
+ };
+ }
+
+ private getMatchingGroupValue(
+ field: DataExplorerField,
+ tags: Record<string, string>,
+ ): unknown | undefined {
+ const result = this.findQueryResult(field.sourceIndex);
+ if (!result) {
+ return undefined;
+ }
+
+ const matchingSeries = this.findMatchingSeries(result, tags);
+ return this.getSeriesFieldValue(result, matchingSeries, field, 0);
+ }
+
+ private findMatchingSeries(
+ result: SpQueryResult,
+ tags: Record<string, string>,
+ ): DataSeries | undefined {
+ const targetSignature = this.makeTagSignature(tags);
+ return (
+ result.allDataSeries.find(
+ series =>
+ this.makeTagSignature(series.tags ?? {}) ===
+ targetSignature,
+ ) ??
+ (result.allDataSeries.length === 1
+ ? result.allDataSeries[0]
+ : undefined)
+ );
+ }
+
+ private findQueryResult(sourceIndex: number): SpQueryResult | undefined {
+ return (
+ this.latestData.find(item => item.sourceIndex === sourceIndex) ??
+ this.latestData[sourceIndex]
+ );
+ }
+
+ private getSeriesFieldValue(
+ result: SpQueryResult | undefined,
+ series: DataSeries | undefined,
+ field: DataExplorerField,
+ rowIndex: number,
+ ): unknown | undefined {
+ const row = series?.rows?.[rowIndex];
+ if (!row) {
+ return undefined;
+ }
+
+ const fieldIndex = this.findFieldIndex(
+ result?.headers ?? series.headers,
+ field,
+ );
+
+ return fieldIndex >= 0 ? row[fieldIndex] : undefined;
+ }
+
+ private findFieldIndex(
+ headers: string[] | undefined,
+ field: DataExplorerField,
+ ): number {
+ if (!headers) {
+ return -1;
+ }
+
+ return headers.findIndex(
+ header =>
+ header === field.fullDbName || header === field.runtimeName,
+ );
+ }
+
+ private makeGroupInfo(tags: Record<string, string>): {
+ label?: string;
+ } {
+ const entries = Object.entries(tags);
+
+ if (entries.length === 0) {
+ return {};
+ }
+
+ if (entries.length === 1) {
+ const [, value] = entries[0];
+ return {
+ label: value,
+ };
+ }
+
+ return {
+ label: entries
+ .map(([key, value]) => `${key}: ${value}`)
+ .join(' · '),
+ };
+ }
+
+ private makeTagSignature(tags: Record<string, string>): string {
+ return JSON.stringify(
+ Object.entries(tags).sort(([left], [right]) =>
+ left.localeCompare(right),
+ ),
+ );
+ }
+
+ private formatValue(value: unknown): string {
+ if (typeof value === 'number') {
+ return new Intl.NumberFormat(this.locale, {
+ maximumFractionDigits: 3,
+ }).format(value);
+ }
+
+ if (typeof value === 'boolean') {
+ return this.translateService.instant(value ? 'True' : 'False');
+ }
+
+ if (value === null || value === undefined || value === '') {
+ return '—';
+ }
+
+ return `${value}`;
+ }
+
+ private formatSignedNumber(value: number): string {
+ return new Intl.NumberFormat(this.locale, {
+ maximumFractionDigits: 3,
+ signDisplay: 'exceptZero',
+ }).format(value);
+ }
+
+ private formatSignedPercent(value: number): string {
+ return new Intl.NumberFormat(this.locale, {
+ style: 'percent',
+ maximumFractionDigits: 1,
+ signDisplay: 'exceptZero',
+ }).format(value);
+ }
+
+ private normalizeNumericDelta(value: number): number {
+ return Math.abs(value) < 0.000_000_1 ? 0 : value;
+ }
+
+ private get gridColumnCount(): number {
+ if (this.indicatorCards.length <= 1) {
+ return 1;
+ }
+
+ return Math.min(
+ this.indicatorCards.length,
+ Math.max(1, Math.floor((this.currentWidth ?? 0) / 220)),
+ );
+ }
+
+ private get gridRowCount(): number {
+ return Math.max(
+ 1,
+ Math.ceil(this.indicatorCards.length / this.gridColumnCount),
+ );
+ }
+
+ private get estimatedPadding(): number {
+ return this.hasMultipleCards || (this.currentWidth ?? 0) < 640
+ ? 12
+ : 18;
+ }
+
+ private get estimatedGap(): number {
+ return this.hasMultipleCards || (this.currentWidth ?? 0) < 640
+ ? 10
+ : 16;
+ }
+
+ private get estimatedCopyHeight(): number {
+ let height = 0;
+
+ if (this.titleText) {
+ height += this.hasMultipleCards ? 26 : 34;
+ }
+
+ if (this.descriptionText) {
+ height += this.hasMultipleCards ? 20 : 28;
+ }
+
+ if (height > 0) {
+ height += this.estimatedGap;
+ }
+
+ return height;
+ }
+}
diff --git
a/ui/src/app/chart-shared/components/charts/indicator/model/indicator-chart-widget.model.ts
b/ui/src/app/chart-shared/components/charts/indicator/model/indicator-chart-widget.model.ts
index a43726f407..dc462922fc 100644
---
a/ui/src/app/chart-shared/components/charts/indicator/model/indicator-chart-widget.model.ts
+++
b/ui/src/app/chart-shared/components/charts/indicator/model/indicator-chart-widget.model.ts
@@ -21,12 +21,22 @@ import {
DataExplorerField,
DataExplorerWidgetModel,
} from '@streampipes/platform-services';
-import { DataExplorerVisConfig } from
'../../../../models/dataview-dashboard.model';
+import {
+ DataExplorerVisConfig,
+ WidgetBaseAppearanceConfig,
+} from '../../../../models/dataview-dashboard.model';
export interface IndicatorChartVisConfig extends DataExplorerVisConfig {
valueField?: DataExplorerField;
deltaField?: DataExplorerField;
showDelta?: boolean;
+ title?: string;
+ description?: string;
+}
+
+export interface IndicatorAppearanceConfig extends WidgetBaseAppearanceConfig {
+ valueFontSize?: number;
+ deltaFontSize?: number;
}
export interface IndicatorChartWidgetModel extends DataExplorerWidgetModel {
diff --git
a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.ts
b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.ts
index d374a7fc31..2b311bdfde 100644
---
a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.ts
+++
b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.ts
@@ -29,7 +29,7 @@ import {
SpAlertBannerComponent,
SplitSectionComponent,
} from '@streampipes/shared-ui';
-import { MatFormField, MatLabel } from '@angular/material/form-field';
+import { MatFormField } from '@angular/material/form-field';
import {
FlexDirective,
LayoutAlignDirective,
@@ -52,7 +52,6 @@ import { MatCheckbox } from '@angular/material/checkbox';
SplitSectionComponent,
MatFormField,
FlexDirective,
- MatLabel,
MatInput,
FormsModule,
MatSelect,
diff --git a/ui/src/app/chart-shared/registry/chart-registry.service.ts
b/ui/src/app/chart-shared/registry/chart-registry.service.ts
index a114037ad1..5b16d99c08 100644
--- a/ui/src/app/chart-shared/registry/chart-registry.service.ts
+++ b/ui/src/app/chart-shared/registry/chart-registry.service.ts
@@ -28,6 +28,7 @@ import { TimeSeriesChartWidgetConfigComponent } from
'../components/charts/time-
import { ImageWidgetConfigComponent } from
'../components/charts/image/config/image-widget-config.component';
import { ImageWidgetComponent } from
'../components/charts/image/image-widget.component';
import { IndicatorWidgetConfigComponent } from
'../components/charts/indicator/config/indicator-chart-widget-config.component';
+import { IndicatorAppearanceConfigComponent } from
'../components/charts/indicator/appearance-config/indicator-appearance-config.component';
import { CorrelationWidgetConfigComponent } from
'../components/charts/correlation-chart/config/correlation-chart-widget-config.component';
import { SpEchartsWidgetComponent } from
'../components/charts/base/echarts-widget.component';
import { HeatmapWidgetModel } from
'../components/charts/heatmap/model/heatmap-widget.model';
@@ -46,8 +47,6 @@ import { SpValueHeatmapRendererService } from
'../components/charts/value-heatma
import { CorrelationChartWidgetModel } from
'../components/charts/correlation-chart/model/correlation-chart-widget.model';
import { SpScatterRendererService } from
'../components/charts/scatter/scatter-renderer.service';
import { SpDensityRendererService } from
'../components/charts/density/density-renderer.service';
-import { IndicatorChartWidgetModel } from
'../components/charts/indicator/model/indicator-chart-widget.model';
-import { SpIndicatorRendererService } from
'../components/charts/indicator/indicator-renderer.service';
import { TimeSeriesChartWidgetModel } from
'../components/charts/time-series-chart/model/time-series-chart-widget.model';
import { SpTimeseriesRendererService } from
'../components/charts/time-series-chart/sp-timeseries-renderer.service';
import { SpEchartsWidgetAppearanceConfigComponent } from
'../components/chart-config/echarts-widget-appearance-config/echarts-widget-appearance-config.component';
@@ -59,6 +58,7 @@ import { TrafficLightWidgetConfigComponent } from
'../components/charts/traffic-
import { TrafficLightWidgetComponent } from
'../components/charts/traffic-light/traffic-light-widget.component';
import { StatusWidgetConfigComponent } from
'../components/charts/status/config/status-widget-config.component';
import { StatusWidgetComponent } from
'../components/charts/status/status-widget.component';
+import { IndicatorWidgetComponent } from
'../components/charts/indicator/indicator-widget.component';
import { TranslateService } from '@ngx-translate/core';
@Injectable({ providedIn: 'root' })
@@ -74,7 +74,6 @@ export class ChartRegistry {
private valueHeatmapRenderer: SpValueHeatmapRendererService,
private scatterRenderer: SpScatterRendererService,
private densityRenderer: SpDensityRendererService,
- private indicatorRenderer: SpIndicatorRendererService,
private timeseriesRenderer: SpTimeseriesRendererService,
private translateService: TranslateService,
) {
@@ -187,11 +186,9 @@ export class ChartRegistry {
id: 'indicator-chart',
label: this.translateService.instant('Indicator'),
widgetAppearanceConfigurationComponent:
- SpEchartsWidgetAppearanceConfigComponent,
+ IndicatorAppearanceConfigComponent,
widgetConfigurationComponent: IndicatorWidgetConfigComponent,
- widgetComponent:
- SpEchartsWidgetComponent<IndicatorChartWidgetModel>,
- chartRenderer: this.indicatorRenderer,
+ widgetComponent: IndicatorWidgetComponent,
icon: '123',
description: this.translateService.instant(
'The current value displayed as a number',
diff --git a/ui/src/app/pipelines/pipelines.component.ts
b/ui/src/app/pipelines/pipelines.component.ts
index 71cf62a2f9..233cdc3302 100644
--- a/ui/src/app/pipelines/pipelines.component.ts
+++ b/ui/src/app/pipelines/pipelines.component.ts
@@ -48,7 +48,6 @@ import {
LayoutGapDirective,
} from '@ngbracket/ngx-layout/flex';
import { MatButton, MatIconButton } from '@angular/material/button';
-import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { PipelineOverviewComponent } from
'./components/pipeline-overview/pipeline-overview.component';
import { FunctionsOverviewComponent } from
'./components/functions-overview/functions-overview.component';
@@ -65,7 +64,6 @@ import { TranslatePipe } from '@ngx-translate/core';
LayoutDirective,
LayoutGapDirective,
MatButton,
- MatIcon,
MatIconButton,
MatTooltip,
SpBasicHeaderTitleComponent,