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,

Reply via email to