This is an automated email from the ASF dual-hosted git repository.

riemer pushed a commit to branch improve-indicator-widget
in repository https://gitbox.apache.org/repos/asf/streampipes.git

commit c10a42d678daa4e8e80d99f79ac5e98603b61668
Author: Dominik Riemer <[email protected]>
AuthorDate: Sun Mar 1 10:54:43 2026 +0100

    feat: Improve indicator widget
---
 .../support/utils/dataExplorer/DataExplorerBtns.ts |  32 ++
 .../tests/dataExplorer/charts/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-chart-widget-config.component.html   |  31 +-
 .../indicator-chart-widget-config.component.ts     |  18 +-
 .../indicator/indicator-group-card.component.html  |  85 ++++
 .../indicator/indicator-group-card.component.scss  | 154 ++++++
 .../indicator/indicator-group-card.component.ts    | 154 ++++++
 .../charts/indicator/indicator-renderer.service.ts | 125 -----
 .../indicator/indicator-widget.component.html      |  90 ++++
 .../indicator/indicator-widget.component.scss      |  71 +++
 .../charts/indicator/indicator-widget.component.ts | 542 +++++++++++++++++++++
 .../model/indicator-chart-widget.model.ts          |   2 +
 .../table/config/table-widget-config.component.ts  |   3 +-
 .../registry/chart-registry.service.ts             |  10 +-
 ui/src/app/pipelines/pipelines.component.ts        |   2 -
 19 files changed, 1195 insertions(+), 152 deletions(-)

diff --git a/ui/cypress/support/utils/dataExplorer/DataExplorerBtns.ts 
b/ui/cypress/support/utils/dataExplorer/DataExplorerBtns.ts
index 741f17d019..ad82b997af 100644
--- a/ui/cypress/support/utils/dataExplorer/DataExplorerBtns.ts
+++ b/ui/cypress/support/utils/dataExplorer/DataExplorerBtns.ts
@@ -150,6 +150,38 @@ export class DataExplorerBtns {
         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/dataExplorer/charts/indicator.spec.ts 
b/ui/cypress/tests/dataExplorer/charts/indicator.spec.ts
index 3fd353f5eb..0f5716379b 100644
--- a/ui/cypress/tests/dataExplorer/charts/indicator.spec.ts
+++ b/ui/cypress/tests/dataExplorer/charts/indicator.spec.ts
@@ -18,6 +18,7 @@
 
 import { DataExplorerUtils } from 
'../../../support/utils/dataExplorer/DataExplorerUtils';
 import { PrepareTestDataUtils } from 
'../../../support/utils/PrepareTestDataUtils';
+import { DataExplorerBtns } from 
'../../../support/utils/dataExplorer/DataExplorerBtns';
 
 describe('Test Indicator View in Data Explorer', () => {
     beforeEach('Setup Test', () => {
@@ -31,10 +32,23 @@ describe('Test Indicator View in Data Explorer', () => {
             'indicator-chart',
         );
 
-        // Check checkbox
         DataExplorerUtils.openVisualizationConfig();
-        cy.dataCy('data-explorer-select-delta-checkbox').click();
+        DataExplorerBtns.indicatorChartDeltaCheckbox().click();
+        DataExplorerBtns.indicatorChartTitleInput().type('Current Metric');
+        DataExplorerBtns.indicatorChartDescriptionInput().type(
+            'Live value compared to the previous event.',
+        );
 
-        cy.dataCy('indicator-chart').should('be.visible');
+        DataExplorerBtns.indicatorChart().should('be.visible');
+        DataExplorerBtns.indicatorChartTitle().should(
+            'contain.text',
+            'Current Metric',
+        );
+        DataExplorerBtns.indicatorChartDescription().should(
+            'contain.text',
+            'Live value compared to the previous event.',
+        );
+        DataExplorerBtns.indicatorChartValue().should('not.be.empty');
+        DataExplorerBtns.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/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..f3cc13e924
--- /dev/null
+++ 
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.html
@@ -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.
+  ~
+  -->
+
+<div
+    class="indicator-card"
+    fxLayout="column"
+    [ngStyle]="cardStyles"
+    [class.grouped]="grouped"
+    data-cy="indicator-chart-card"
+>
+    @if (card.label || card.detail) {
+        <div class="indicator-card-copy" fxLayout="column">
+            @if (card.label) {
+                <div class="indicator-card-label">{{ card.label }}</div>
+            }
+
+            @if (card.detail) {
+                <div class="indicator-card-detail">{{ card.detail }}</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"
+            [ngClass]="'indicator-card-delta-' + card.deltaView.tone"
+            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..a4bc0ed4db
--- /dev/null
+++ 
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.scss
@@ -0,0 +1,154 @@
+/*
+ * 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 {
+    --indicator-delta-accent: currentColor;
+
+    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 {
+    gap: calc(var(--indicator-card-copy-gap) * 0.4);
+    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-detail {
+    color: color-mix(in srgb, currentColor 60%, transparent);
+    font-size: var(--indicator-card-detail-size);
+    line-height: 1.25;
+    overflow: hidden;
+    overflow-wrap: anywhere;
+}
+
+.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);
+    max-width: 100%;
+    margin-left: auto;
+    margin-right: auto;
+    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,
+        var(--indicator-delta-accent) 16%,
+        var(--color-bg-0)
+    );
+    border-radius: 999px;
+    color: var(--indicator-delta-accent);
+    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..3de323d01c
--- /dev/null
+++ 
b/ui/src/app/chart-shared/components/charts/indicator/indicator-group-card.component.ts
@@ -0,0 +1,154 @@
+/*
+ * 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 { NgClass, 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;
+    detail?: 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,
+        NgClass,
+        MatIcon,
+    ],
+})
+export class IndicatorGroupCardComponent {
+    @Input({ required: true }) card: IndicatorGroupCardView;
+    @Input() cardWidth = 320;
+    @Input() cardHeight = 240;
+    @Input() grouped = false;
+
+    get cardStyles(): Record<string, string> {
+        const minDimension = Math.max(
+            Math.min(this.cardWidth, this.cardHeight),
+            1,
+        );
+        const hasSupport = !!this.card.label || !!this.card.detail;
+        const hasDelta = !!this.card.deltaView;
+        const compactMode = this.grouped || this.cardWidth < 320;
+
+        const padding = this.clamp(
+            minDimension * (compactMode ? 0.055 : 0.07),
+            8,
+            20,
+        );
+        const sectionGap = this.clamp(
+            minDimension * (compactMode ? 0.018 : 0.028),
+            4,
+            12,
+        );
+        const copyGap = this.clamp(sectionGap * 0.7, 2, 8);
+        const valueGap = this.clamp(sectionGap * 0.45, 2, 6);
+        const labelSize = this.clamp(minDimension * 0.07, 11, 18);
+        const detailSize = this.clamp(minDimension * 0.055, 10, 14);
+        const deltaSize = this.clamp(
+            minDimension * (compactMode ? 0.072 : 0.07),
+            12,
+            19,
+        );
+        const deltaMetaSize = this.clamp(
+            minDimension * (compactMode ? 0.04 : 0.044),
+            9,
+            12,
+        );
+        const deltaHeight = this.clamp(
+            minDimension * (compactMode ? 0.18 : 0.2),
+            30,
+            52,
+        );
+        const supportHeight =
+            (this.card.label ? labelSize * 1.25 : 0) +
+            (this.card.detail ? detailSize * 1.35 : 0) +
+            (hasSupport ? copyGap : 0);
+        const availableValueHeight =
+            this.cardHeight -
+            padding * 2 -
+            supportHeight -
+            (hasDelta ? deltaHeight + valueGap : 0);
+        const valueSize = this.clamp(
+            Math.min(
+                this.cardWidth * (compactMode ? 0.19 : 0.22),
+                availableValueHeight *
+                    (compactMode
+                        ? hasSupport && hasDelta
+                            ? 0.72
+                            : 0.78
+                        : hasSupport && hasDelta
+                          ? 0.76
+                          : 0.82),
+            ),
+            compactMode ? 20 : 24,
+            compactMode ? 20 : 112,
+        );
+        const deltaSizeAdjusted = this.clamp(
+            Math.min(
+                deltaSize,
+                this.cardWidth * (compactMode ? 0.072 : 0.082),
+                deltaHeight * (compactMode ? 0.32 : 0.36),
+            ),
+            10,
+            18,
+        );
+
+        return {
+            '--indicator-card-padding': `${padding}px`,
+            '--indicator-card-gap': `${sectionGap}px`,
+            '--indicator-card-copy-gap': `${copyGap}px`,
+            '--indicator-card-value-gap': `${valueGap}px`,
+            '--indicator-card-label-size': `${labelSize}px`,
+            '--indicator-card-detail-size': `${detailSize}px`,
+            '--indicator-card-value-size': `${valueSize}px`,
+            '--indicator-card-delta-size': `${deltaSizeAdjusted}px`,
+            '--indicator-card-delta-meta-size': `${deltaMetaSize}px`,
+            '--indicator-card-delta-height': `${deltaHeight}px`,
+        };
+    }
+
+    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..3d841a182d
--- /dev/null
+++ 
b/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.html
@@ -0,0 +1,90 @@
+<!--
+  ~ 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"
+                    >
+                    </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..84b843a036
--- /dev/null
+++ 
b/ui/src/app/chart-shared/components/charts/indicator/indicator-widget.component.ts
@@ -0,0 +1,542 @@
+/*
+ * 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 { 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 minDimension = Math.max(
+            Math.min(this.currentWidth ?? 0, this.currentHeight ?? 0),
+            1,
+        );
+
+        return {
+            'background':
+                this.dataExplorerWidget.baseAppearanceConfig.backgroundColor,
+            'color': this.dataExplorerWidget.baseAppearanceConfig.textColor,
+            '--indicator-selected-background':
+                this.dataExplorerWidget.baseAppearanceConfig.backgroundColor,
+            '--indicator-padding': `${this.clamp(
+                minDimension * 0.08,
+                12,
+                28,
+            )}px`,
+            '--indicator-gap': `${this.clamp(minDimension * 0.045, 8, 20)}px`,
+            '--indicator-title-size': `${this.clamp(
+                minDimension * 0.082,
+                14,
+                30,
+            )}px`,
+            '--indicator-description-size': `${this.clamp(
+                minDimension * 0.055,
+                12,
+                18,
+            )}px`,
+        };
+    }
+
+    get gridStyles(): Record<string, string> {
+        return {
+            '--indicator-grid-columns': `${this.gridColumnCount}`,
+        };
+    }
+
+    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,
+            detail: groupInfo.detail,
+            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;
+        detail?: string;
+    } {
+        const entries = Object.entries(tags);
+
+        if (entries.length === 0) {
+            return {};
+        }
+
+        if (entries.length === 1) {
+            const [key, value] = entries[0];
+            return {
+                label: value,
+                detail: key,
+            };
+        }
+
+        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 {
+        const minDimension = Math.max(
+            Math.min(this.currentWidth ?? 0, this.currentHeight ?? 0),
+            1,
+        );
+        return this.clamp(minDimension * 0.08, 12, 28);
+    }
+
+    private get estimatedGap(): number {
+        const minDimension = Math.max(
+            Math.min(this.currentWidth ?? 0, this.currentHeight ?? 0),
+            1,
+        );
+        return this.clamp(minDimension * 0.045, 8, 20);
+    }
+
+    private get estimatedCopyHeight(): number {
+        let height = 0;
+
+        if (this.titleText) {
+            height += this.clamp((this.currentHeight ?? 0) * 0.11, 28, 52);
+        }
+
+        if (this.descriptionText) {
+            height += this.clamp((this.currentHeight ?? 0) * 0.1, 24, 56);
+        }
+
+        if (height > 0) {
+            height += this.estimatedGap;
+        }
+
+        return height;
+    }
+
+    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/model/indicator-chart-widget.model.ts
 
b/ui/src/app/chart-shared/components/charts/indicator/model/indicator-chart-widget.model.ts
index a43726f407..ce8589c1aa 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
@@ -27,6 +27,8 @@ export interface IndicatorChartVisConfig extends 
DataExplorerVisConfig {
     valueField?: DataExplorerField;
     deltaField?: DataExplorerField;
     showDelta?: boolean;
+    title?: string;
+    description?: string;
 }
 
 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..6d6103b417 100644
--- a/ui/src/app/chart-shared/registry/chart-registry.service.ts
+++ b/ui/src/app/chart-shared/registry/chart-registry.service.ts
@@ -46,8 +46,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 +57,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 +73,6 @@ export class ChartRegistry {
         private valueHeatmapRenderer: SpValueHeatmapRendererService,
         private scatterRenderer: SpScatterRendererService,
         private densityRenderer: SpDensityRendererService,
-        private indicatorRenderer: SpIndicatorRendererService,
         private timeseriesRenderer: SpTimeseriesRendererService,
         private translateService: TranslateService,
     ) {
@@ -186,12 +184,8 @@ export class ChartRegistry {
             {
                 id: 'indicator-chart',
                 label: this.translateService.instant('Indicator'),
-                widgetAppearanceConfigurationComponent:
-                    SpEchartsWidgetAppearanceConfigComponent,
                 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 3d2dae8ac8..40269529ba 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