You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@streampipes.apache.org by ri...@apache.org on 2026/06/15 07:24:10 UTC

(streampipes) branch add-visualization-options created (now 8557e9b9d1)

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

dominikriemer pushed a change to branch add-visualization-options
in repository https://gitbox.apache.org/repos/asf/streampipes.git


      at 8557e9b9d1 Add progress bar chart

This branch includes the following new commits:

     new 709feb459c Add central display name configuration
     new 73442dfe0f feat: Add value card widget
     new 8557e9b9d1 Add progress bar chart

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(streampipes) 01/03: Add central display name configuration

Posted by ri...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dominikriemer pushed a commit to branch add-visualization-options
in repository https://gitbox.apache.org/repos/asf/streampipes.git

commit 709feb459c8415344ecf99913a8ad1b6105f941c
Author: Dominik Riemer <do...@gmail.com>
AuthorDate: Sun Jun 14 19:09:23 2026 +0200

    Add central display name configuration
---
 .../model/datalake/data-lake-query-config.model.ts |  1 +
 .../time-series-item-config.component.html         |  5 +-
 .../time-series-item-config.component.ts           | 26 +++++-
 .../config/gauge-widget-config.component.html      |  7 +-
 .../gauge/config/gauge-widget-config.component.ts  | 50 ++++++++++++
 .../charts/gauge/gauge-renderer.service.ts         | 17 ++--
 .../charts/table/table-widget.component.ts         | 58 +++++++++++++-
 .../sp-timeseries-renderer.service.ts              | 13 ++-
 .../chart-shared/services/result-label.service.ts  | 70 ++++++++++++++++
 .../chart-data-settings.component.html             |  5 ++
 .../data-settings/chart-data-settings.component.ts |  3 +
 .../result-label-configuration.component.html      | 49 ++++++++++++
 .../result-label-configuration.component.scss      | 17 ++++
 .../result-label-configuration.component.ts        | 93 ++++++++++++++++++++++
 14 files changed, 394 insertions(+), 20 deletions(-)

diff --git a/ui/projects/streampipes/platform-services/src/lib/model/datalake/data-lake-query-config.model.ts b/ui/projects/streampipes/platform-services/src/lib/model/datalake/data-lake-query-config.model.ts
index 60a3957286..fa3f72f93e 100644
--- a/ui/projects/streampipes/platform-services/src/lib/model/datalake/data-lake-query-config.model.ts
+++ b/ui/projects/streampipes/platform-services/src/lib/model/datalake/data-lake-query-config.model.ts
@@ -69,6 +69,7 @@ export interface QueryConfig {
     selectedFilters: SelectedFilter[];
     filterExpression?: FilterExpressionGroup;
     fields?: FieldConfig[];
+    resultLabelOverrides?: Record<string, string>;
     groupBy?: FieldConfig[];
     limit?: number;
     page?: number;
diff --git a/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.html b/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.html
index 1a64d5092d..ba578876af 100644
--- a/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.html
+++ b/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.html
@@ -91,10 +91,7 @@
                         class="w-100"
                     >
                         <input
-                            [(ngModel)]="
-                                currentlyConfiguredWidget.visualizationConfig
-                                    .displayName[getFieldKey(field)]
-                            "
+                            [ngModel]="getDisplayName(field)"
                             matInput
                             (input)="
                                 onDisplayNameChange($event.target.value, field)
diff --git a/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.ts b/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.ts
index fc23ef2c40..30ea20f873 100644
--- a/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.ts
+++ b/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.ts
@@ -41,6 +41,7 @@ import { MatOption, MatSelect } from '@angular/material/select';
 import { TranslatePipe } from '@ngx-translate/core';
 import { ColorMappingOptionsConfigComponent } from '../../color-mapping-options-config/color-mapping-options-config.component';
 import { LayoutDirective } from '@ngbracket/ngx-layout';
+import { ResultLabelService } from '../../../../services/result-label.service';
 
 @Component({
     selector: 'sp-time-series-item-config',
@@ -68,6 +69,8 @@ import { LayoutDirective } from '@ngbracket/ngx-layout';
     ],
 })
 export class SpTimeseriesItemConfigComponent {
+    constructor(private resultLabelService: ResultLabelService) {}
+
     @Input()
     field: DataExplorerField;
 
@@ -124,9 +127,14 @@ export class SpTimeseriesItemConfigComponent {
     }
 
     onDisplayNameChange(searchValue: string, field: DataExplorerField): void {
-        this.currentlyConfiguredWidget.visualizationConfig.displayName[
-            field.fullDbName + field.sourceIndex.toString()
-        ] = searchValue;
+        this.resultLabelService.setOverride(
+            this.currentlyConfiguredWidget.dataConfig.sourceConfigs[
+                field.sourceIndex
+            ].queryConfig,
+            field,
+            searchValue,
+            field.fullDbName,
+        );
         this.viewRefreshEmitter.emit();
     }
 
@@ -134,6 +142,18 @@ export class SpTimeseriesItemConfigComponent {
         return field.fullDbName + field.sourceIndex.toString();
     }
 
+    getDisplayName(field: DataExplorerField): string {
+        return this.resultLabelService.resolveLabel(
+            this.currentlyConfiguredWidget.dataConfig.sourceConfigs[
+                field.sourceIndex
+            ].queryConfig,
+            field,
+            this.currentlyConfiguredWidget.visualizationConfig.displayName[
+                this.getFieldKey(field)
+            ],
+        );
+    }
+
     hasGrouping(field: DataExplorerField): boolean {
         return (
             this.currentlyConfiguredWidget.dataConfig.sourceConfigs[
diff --git a/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.html b/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.html
index 389465b05c..70b38520ec 100644
--- a/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.html
+++ b/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.html
@@ -83,11 +83,8 @@
                     <input
                         matInput
                         type="text"
-                        [(ngModel)]="
-                            currentlyConfiguredWidget.visualizationConfig
-                                .displayName
-                        "
-                        (ngModelChange)="triggerViewRefresh()"
+                        [ngModel]="getDisplayName()"
+                        (ngModelChange)="onDisplayNameChange($event)"
                     />
                 </mat-form-field>
             </sp-form-field>
diff --git a/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.ts b/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.ts
index 5fc0916abc..db4057351a 100644
--- a/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.ts
+++ b/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.ts
@@ -32,6 +32,7 @@ import { MatInput } from '@angular/material/input';
 import { FormsModule } from '@angular/forms';
 import { TranslatePipe } from '@ngx-translate/core';
 import { MatCheckbox } from '@angular/material/checkbox';
+import { ResultLabelService } from '../../../../services/result-label.service';
 
 @Component({
     selector: 'sp-data-explorer-gauge-widget-config',
@@ -53,9 +54,58 @@ export class GaugeWidgetConfigComponent extends BaseWidgetConfig<
     GaugeWidgetModel,
     GaugeVisConfig
 > {
+    constructor(private resultLabelService: ResultLabelService) {
+        super();
+    }
+
     setSelectedProperty(field: DataExplorerField) {
         this.currentlyConfiguredWidget.visualizationConfig.selectedProperty =
             field;
+        this.currentlyConfiguredWidget.visualizationConfig.displayName =
+            this.resultLabelService.getOverride(
+                this.currentlyConfiguredWidget.dataConfig.sourceConfigs[
+                    field.sourceIndex
+                ].queryConfig,
+                field,
+            ) ??
+            field.runtimeName ??
+            field.fullDbName;
+        this.triggerViewRefresh();
+    }
+
+    getDisplayName(): string {
+        const selectedProperty =
+            this.currentlyConfiguredWidget.visualizationConfig.selectedProperty;
+
+        if (!selectedProperty) {
+            return '';
+        }
+
+        return this.resultLabelService.resolveLabel(
+            this.currentlyConfiguredWidget.dataConfig.sourceConfigs[
+                selectedProperty.sourceIndex
+            ].queryConfig,
+            selectedProperty,
+            this.currentlyConfiguredWidget.visualizationConfig.displayName,
+        );
+    }
+
+    onDisplayNameChange(label: string): void {
+        const selectedProperty =
+            this.currentlyConfiguredWidget.visualizationConfig.selectedProperty;
+
+        if (!selectedProperty) {
+            return;
+        }
+
+        this.resultLabelService.setOverride(
+            this.currentlyConfiguredWidget.dataConfig.sourceConfigs[
+                selectedProperty.sourceIndex
+            ].queryConfig,
+            selectedProperty,
+            label,
+            selectedProperty.fullDbName,
+        );
         this.triggerViewRefresh();
     }
 
diff --git a/ui/src/app/chart-shared/components/charts/gauge/gauge-renderer.service.ts b/ui/src/app/chart-shared/components/charts/gauge/gauge-renderer.service.ts
index b833ceba59..619c9c3b0d 100644
--- a/ui/src/app/chart-shared/components/charts/gauge/gauge-renderer.service.ts
+++ b/ui/src/app/chart-shared/components/charts/gauge/gauge-renderer.service.ts
@@ -31,6 +31,7 @@ import {
 import { WidgetSize } from '../../../models/dataset.model';
 import { EchartsBasicOptionsGeneratorService } from '../../../echarts-renderer/echarts-basic-options-generator.service';
 import { SpFieldUpdateService } from '../../../services/field-update.service';
+import { ResultLabelService } from '../../../services/result-label.service';
 
 @Injectable({ providedIn: 'root' })
 export class SpGaugeRendererService implements SpEchartsRenderer<GaugeWidgetModel> {
@@ -38,10 +39,11 @@ export class SpGaugeRendererService implements SpEchartsRenderer<GaugeWidgetMode
     protected echartsBaseOptionsGenerator = inject(
         EchartsBasicOptionsGeneratorService,
     );
+    protected resultLabelService = inject(ResultLabelService);
 
     makeSeriesItem(
         seriesName: string,
-        fieldName: string,
+        selectedField: DataExplorerField,
         value: number,
         decimals: number | undefined,
         widgetConfig: GaugeWidgetModel,
@@ -51,9 +53,14 @@ export class SpGaugeRendererService implements SpEchartsRenderer<GaugeWidgetMode
         const visConfig = widgetConfig.visualizationConfig;
         const clamp = this.getSizeClamp(widgetSize);
         const useThresholdColors = !!visConfig.enableThresholdColors;
-        const displayName = this.makeDisplayName(
-            visConfig.displayName,
-            fieldName,
+        const displayName = this.resultLabelService.resolveLabel(
+            widgetConfig.dataConfig.sourceConfigs[selectedField.sourceIndex]
+                .queryConfig,
+            selectedField,
+            this.makeDisplayName(
+                visConfig.displayName,
+                selectedField.fullDbName,
+            ),
         );
 
         const series: GaugeSeriesOption = {
@@ -172,7 +179,7 @@ export class SpGaugeRendererService implements SpEchartsRenderer<GaugeWidgetMode
             },
             series: this.makeSeriesItem(
                 '',
-                selectedField.fullDbName,
+                selectedField,
                 data,
                 decimals,
                 widgetConfig,
diff --git a/ui/src/app/chart-shared/components/charts/table/table-widget.component.ts b/ui/src/app/chart-shared/components/charts/table/table-widget.component.ts
index 5a176f73f9..a6061a67d6 100644
--- a/ui/src/app/chart-shared/components/charts/table/table-widget.component.ts
+++ b/ui/src/app/chart-shared/components/charts/table/table-widget.component.ts
@@ -46,6 +46,7 @@ import { NoDataInDateRangeComponent } from '../base/no-data/no-data-in-date-rang
 import { TooMuchDataComponent } from '../base/too-much-data/too-much-data.component';
 import { TranslatePipe } from '@ngx-translate/core';
 import { WidgetNumberAppearanceConfig } from '../../../models/dataview-dashboard.model';
+import { ResultLabelService } from '../../../services/result-label.service';
 
 type SortDirection = 'asc' | 'desc' | '';
 
@@ -120,6 +121,7 @@ const TIMESTAMP_MASK = 'yyyy-mm-dd HH:mm:ss.SSS';
 })
 export class TableWidgetComponent extends BaseDataExplorerWidgetDirective<TableWidgetModel> {
     private elRef = inject(ElementRef);
+    private resultLabelService = inject(ResultLabelService);
 
     private static readonly DEFAULT_PAGE_SIZE = 20;
 
@@ -865,8 +867,22 @@ export class TableWidgetComponent extends BaseDataExplorerWidgetDirective<TableW
         return Math.max(0, normalizedIndex - firstReorderableIndex);
     }
 
-    headerLabel = (column: string): string =>
-        column === 'time' ? 'Time' : column;
+    headerLabel = (column: string): string => {
+        if (column === 'time') {
+            return 'Time';
+        }
+
+        const field = this.getFieldByColumnName(column);
+        if (!field) {
+            return column;
+        }
+
+        return this.resultLabelService.resolveLabel(
+            this.dataExplorerWidget.dataConfig.sourceConfigs[field.sourceIndex]
+                .queryConfig,
+            field,
+        );
+    };
 
     formatCellValue(column: string, value: unknown): unknown {
         if (column === 'time') {
@@ -1203,6 +1219,44 @@ export class TableWidgetComponent extends BaseDataExplorerWidgetDirective<TableW
             : (n - stats.min) / (stats.max - stats.min);
     }
 
+    private getFieldByColumnName(
+        column: string,
+    ): DataExplorerField | undefined {
+        const selectedField = (
+            this.dataExplorerWidget.visualizationConfig.selectedColumns ?? []
+        ).find(f => f.fullDbName === column);
+
+        if (selectedField) {
+            return selectedField;
+        }
+
+        for (const [
+            sourceIndex,
+            sourceConfig,
+        ] of this.dataExplorerWidget.dataConfig.sourceConfigs.entries()) {
+            const groupedField = sourceConfig.queryConfig.groupBy
+                ?.filter(groupBy => groupBy.selected)
+                .find(groupBy => groupBy.runtimeName === column);
+
+            if (groupedField) {
+                return {
+                    runtimeName: groupedField.runtimeName,
+                    measure: sourceConfig.measureName,
+                    fullDbName: groupedField.runtimeName,
+                    sourceIndex,
+                    fieldCharacteristics: {
+                        dimension: true,
+                        numeric: false,
+                        binary: false,
+                        semanticTypes: [],
+                    },
+                };
+            }
+        }
+
+        return undefined;
+    }
+
     private toNumber(value: unknown): number | undefined {
         if (typeof value === 'number' && Number.isFinite(value)) return value;
         if (typeof value === 'string' && value.trim() !== '') {
diff --git a/ui/src/app/chart-shared/components/charts/time-series-chart/sp-timeseries-renderer.service.ts b/ui/src/app/chart-shared/components/charts/time-series-chart/sp-timeseries-renderer.service.ts
index ebd417bfa5..b094faedd8 100644
--- a/ui/src/app/chart-shared/components/charts/time-series-chart/sp-timeseries-renderer.service.ts
+++ b/ui/src/app/chart-shared/components/charts/time-series-chart/sp-timeseries-renderer.service.ts
@@ -27,6 +27,7 @@ import { Injectable } from '@angular/core';
 import { TimeSeriesChartWidgetModel } from './model/time-series-chart-widget.model';
 import { DataExplorerField } from '@streampipes/platform-services';
 import { SpBaseEchartsRenderer } from '../../../echarts-renderer/base-echarts-renderer';
+import { ResultLabelService } from '../../../services/result-label.service';
 import {
     GeneratedDataset,
     TagValue,
@@ -44,6 +45,10 @@ import type { FieldUpdateInfo } from '../../../models/field-update.model';
 
 @Injectable({ providedIn: 'root' })
 export class SpTimeseriesRendererService extends SpBaseEchartsRenderer<TimeSeriesChartWidgetModel> {
+    constructor(private resultLabelService: ResultLabelService) {
+        super();
+    }
+
     applyOptions(
         generatedDataset: GeneratedDataset,
         options: EChartsOption,
@@ -71,10 +76,16 @@ export class SpTimeseriesRendererService extends SpBaseEchartsRenderer<TimeSerie
                     const rawDatasetDimensions = dataset.rawDataset.dimensions;
                     const groupIndex = i - dataset.meta.preparedDataStartIndex;
                     const tag = dataset.tagValues[groupIndex];
-                    const displayName =
+                    const legacyDisplayName =
                         widgetConfig.visualizationConfig.displayName[
                             field.fullDbName + field.sourceIndex
                         ];
+                    const displayName = this.resultLabelService.resolveLabel(
+                        widgetConfig.dataConfig.sourceConfigs[field.sourceIndex]
+                            .queryConfig,
+                        field,
+                        legacyDisplayName,
+                    );
                     const mappedGroupLabel = this.colorizationService.findLabel(
                         widgetConfig.visualizationConfig,
                         field,
diff --git a/ui/src/app/chart-shared/services/result-label.service.ts b/ui/src/app/chart-shared/services/result-label.service.ts
new file mode 100644
index 0000000000..875ebe2b94
--- /dev/null
+++ b/ui/src/app/chart-shared/services/result-label.service.ts
@@ -0,0 +1,70 @@
+/*
+ * 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 { DataExplorerField, QueryConfig } from '@streampipes/platform-services';
+
+@Injectable({ providedIn: 'root' })
+export class ResultLabelService {
+    makeFieldKey(field: Pick<DataExplorerField, 'fullDbName' | 'sourceIndex'>) {
+        return `${field.fullDbName}:${field.sourceIndex}`;
+    }
+
+    getOverrides(queryConfig: QueryConfig): Record<string, string> {
+        queryConfig.resultLabelOverrides ??= {};
+        return queryConfig.resultLabelOverrides;
+    }
+
+    getOverride(
+        queryConfig: QueryConfig,
+        field: Pick<DataExplorerField, 'fullDbName' | 'sourceIndex'>,
+    ): string | undefined {
+        return this.getOverrides(queryConfig)[this.makeFieldKey(field)];
+    }
+
+    resolveLabel(
+        queryConfig: QueryConfig,
+        field: Pick<DataExplorerField, 'fullDbName' | 'sourceIndex'>,
+        legacyLabel?: string,
+    ): string {
+        return (
+            this.getOverride(queryConfig, field) ??
+            legacyLabel ??
+            field.fullDbName
+        );
+    }
+
+    setOverride(
+        queryConfig: QueryConfig,
+        field: Pick<DataExplorerField, 'fullDbName' | 'sourceIndex'>,
+        label: string,
+        fallbackLabel?: string,
+    ): void {
+        const normalizedLabel = label.trim();
+        const fallback = fallbackLabel ?? field.fullDbName;
+        const overrides = this.getOverrides(queryConfig);
+        const key = this.makeFieldKey(field);
+
+        if (!normalizedLabel || normalizedLabel === fallback) {
+            delete overrides[key];
+            return;
+        }
+
+        overrides[key] = normalizedLabel;
+    }
+}
diff --git a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.html b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.html
index af89a9f414..bb64ea35d8 100644
--- a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.html
+++ b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.html
@@ -410,6 +410,11 @@
                                 #groupSelectionPanel
                                 [sourceConfig]="sourceConfig"
                             ></sp-group-selection-panel>
+                            <sp-result-label-configuration
+                                [sourceConfig]="sourceConfig"
+                                [sourceIndex]="0"
+                            >
+                            </sp-result-label-configuration>
                             <sp-order-selection-panel
                                 [sourceConfig]="sourceConfig"
                             >
diff --git a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.ts b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.ts
index 5502cff7be..2875af0354 100644
--- a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.ts
+++ b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.ts
@@ -77,6 +77,7 @@ import { AggregateConfigurationComponent } from './aggregate-configuration/aggre
 import { FillConfigurationComponent } from './fill-configuration/fill-configuration.component';
 import { FilterSelectionPanelComponent } from './filter-selection-panel/filter-selection-panel.component';
 import { OrderSelectionPanelComponent } from './order-selection-panel/order-selection-panel.component';
+import { ResultLabelConfigurationComponent } from './result-label-configuration/result-label-configuration.component';
 import { TranslatePipe } from '@ngx-translate/core';
 import {
     MatAutocomplete,
@@ -122,6 +123,7 @@ import {
         FilterSelectionPanelComponent,
         GroupSelectionPanelComponent,
         OrderSelectionPanelComponent,
+        ResultLabelConfigurationComponent,
         TranslatePipe,
     ],
 })
@@ -330,6 +332,7 @@ export class ChartDataSettingsComponent implements OnInit {
             measureName,
             queryConfig: {
                 selectedFilters: [],
+                resultLabelOverrides: {},
                 limit: 100,
                 page: 1,
                 aggregationTimeUnit: 'd',
diff --git a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.html b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.html
new file mode 100644
index 0000000000..f4dbb538c0
--- /dev/null
+++ b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.html
@@ -0,0 +1,49 @@
+<!--
+~ 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.
+~
+-->
+
+@if (selectedResultFields.length > 0) {
+    <sp-split-section [level]="3" [title]="'Result Labels' | translate">
+        <div fxLayout="column" fxLayoutGap="10px">
+            @for (field of selectedResultFields; track field.fullDbName) {
+                <div
+                    fxLayout="row"
+                    fxLayoutAlign="start center"
+                    fxLayoutGap="10px"
+                >
+                    <div fxFlex="35" class="text-xs">
+                        {{ field.fullDbName }}
+                    </div>
+                    <div fxFlex="65">
+                        <mat-form-field
+                            appearance="outline"
+                            color="accent"
+                            class="w-100 form-field-small"
+                        >
+                            <input
+                                matInput
+                                [ngModel]="getLabel(field)"
+                                (ngModelChange)="updateLabel(field, $event)"
+                                [placeholder]="field.fullDbName"
+                            />
+                        </mat-form-field>
+                    </div>
+                </div>
+            }
+        </div>
+    </sp-split-section>
+}
diff --git a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.scss b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.scss
new file mode 100644
index 0000000000..13cbc4aacb
--- /dev/null
+++ b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.scss
@@ -0,0 +1,17 @@
+/*
+ * 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.
+ *
+ */
diff --git a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.ts b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.ts
new file mode 100644
index 0000000000..9bb57c0ce2
--- /dev/null
+++ b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/result-label-configuration/result-label-configuration.component.ts
@@ -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.
+ *
+ */
+
+import { Component, Input, inject } from '@angular/core';
+import {
+    DataExplorerField,
+    SourceConfig,
+} from '@streampipes/platform-services';
+import { ChartFieldProviderService } from '../../../../../../chart-shared/services/chart-field-provider.service';
+import { ResultLabelService } from '../../../../../../chart-shared/services/result-label.service';
+import { ChartConfigurationService } from '../../../../../../chart-shared/services/chart-configuration.service';
+import { SplitSectionComponent } from '@streampipes/shared-ui';
+import {
+    FlexDirective,
+    LayoutAlignDirective,
+    LayoutDirective,
+    LayoutGapDirective,
+} from '@ngbracket/ngx-layout/flex';
+import { MatFormField } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { FormsModule } from '@angular/forms';
+import { TranslatePipe } from '@ngx-translate/core';
+
+@Component({
+    selector: 'sp-result-label-configuration',
+    templateUrl: './result-label-configuration.component.html',
+    styleUrls: ['./result-label-configuration.component.scss'],
+    imports: [
+        SplitSectionComponent,
+        LayoutDirective,
+        LayoutAlignDirective,
+        LayoutGapDirective,
+        FlexDirective,
+        MatFormField,
+        MatInput,
+        FormsModule,
+        TranslatePipe,
+    ],
+})
+export class ResultLabelConfigurationComponent {
+    private fieldProvider = inject(ChartFieldProviderService);
+    private resultLabelService = inject(ResultLabelService);
+    private widgetConfigService = inject(ChartConfigurationService);
+
+    @Input() sourceConfig: SourceConfig;
+    @Input() sourceIndex = 0;
+
+    get selectedResultFields(): DataExplorerField[] {
+        if (!this.sourceConfig?.measure) {
+            return [];
+        }
+
+        return this.fieldProvider
+            .generateFieldLists([this.sourceConfig])
+            .allFields.sort((a, b) => a.fullDbName.localeCompare(b.fullDbName));
+    }
+
+    getLabel(field: DataExplorerField): string {
+        return this.resultLabelService.resolveLabel(
+            this.sourceConfig.queryConfig,
+            { ...field, sourceIndex: this.sourceIndex },
+        );
+    }
+
+    updateLabel(field: DataExplorerField, label: string): void {
+        this.resultLabelService.setOverride(
+            this.sourceConfig.queryConfig,
+            { ...field, sourceIndex: this.sourceIndex },
+            label,
+            field.fullDbName,
+        );
+
+        this.widgetConfigService.notify({
+            refreshData: false,
+            refreshView: true,
+        });
+    }
+}


(streampipes) 03/03: Add progress bar chart

Posted by ri...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dominikriemer pushed a commit to branch add-visualization-options
in repository https://gitbox.apache.org/repos/asf/streampipes.git

commit 8557e9b9d12cdbfe92a4b460ba5b7bb1ecb5da23
Author: Dominik Riemer <do...@gmail.com>
AuthorDate: Mon Jun 15 08:15:15 2026 +0200

    Add progress bar chart
---
 .../progress-bar-appearance-config.component.html  |  46 +++
 .../progress-bar-appearance-config.component.scss  |  34 ++
 .../progress-bar-appearance-config.component.ts    |  88 +++++
 .../progress-bar-widget-config.component.html      | 159 +++++++++
 .../config/progress-bar-widget-config.component.ts | 154 +++++++++
 .../model/progress-bar-widget.model.ts             |  54 +++
 .../progress-bar-widget.component.html             | 112 ++++++
 .../progress-bar-widget.component.scss             | 107 ++++++
 .../progress-bar/progress-bar-widget.component.ts  | 376 +++++++++++++++++++++
 .../value-card/value-card-widget.component.scss    |   5 +-
 .../value-card/value-card-widget.component.ts      |  91 ++---
 .../registry/chart-registry.service.ts             |  15 +
 .../chart-shared/services/widget-render.utils.ts   |  79 +++++
 13 files changed, 1264 insertions(+), 56 deletions(-)

diff --git a/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.html b/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.html
new file mode 100644
index 0000000000..1abed96706
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.html
@@ -0,0 +1,46 @@
+<!--
+  ~ 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]="'Bar settings' | translate">
+    <div fxLayout="column" class="progress-color-list">
+        <sp-form-field [level]="3" [label]="'Progress color' | translate">
+            <input
+                class="progress-color-picker"
+                [colorPicker]="config.progressColor"
+                [style.background]="config.progressColor"
+                [cpPosition]="'auto'"
+                [cpPresetColors]="presetColors"
+                (colorPickerChange)="setColor('progressColor', $event)"
+            />
+        </sp-form-field>
+
+        <sp-form-field [level]="3" [label]="'Track color' | translate">
+            <input
+                class="progress-color-picker"
+                [colorPicker]="config.trackColor"
+                [style.background]="config.trackColor"
+                [cpPosition]="'auto'"
+                [cpPresetColors]="presetColors"
+                (colorPickerChange)="setColor('trackColor', $event)"
+            />
+        </sp-form-field>
+    </div>
+
+    <sp-number-format-config [appearanceConfig]="appearanceConfig">
+    </sp-number-format-config>
+</sp-split-section>
diff --git a/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.scss b/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.scss
new file mode 100644
index 0000000000..ddc3d05f1e
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.scss
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ *
+ */
+
+.progress-color-list {
+    gap: var(--space-sm);
+}
+
+.progress-color-picker {
+    display: block;
+    width: calc(var(--space-md) * 2.4);
+    height: calc(var(--space-md) * 2.4);
+    padding: 0;
+    border: 1px solid var(--color-tab-border);
+    border-radius: var(--button-border-radius);
+    cursor: pointer;
+    appearance: none;
+    -webkit-appearance: none;
+    background-clip: padding-box;
+}
diff --git a/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.ts b/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.ts
new file mode 100644
index 0000000000..606fb8b18e
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component.ts
@@ -0,0 +1,88 @@
+/*
+ * 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, inject } from '@angular/core';
+import {
+    FormFieldComponent,
+    SplitSectionComponent,
+} from '@streampipes/shared-ui';
+import { TranslatePipe } from '@ngx-translate/core';
+import { SpNumberFormatConfigComponent } from '../../../chart-config/number-format-config/number-format-config.component';
+import { ProgressBarAppearanceConfig } from '../model/progress-bar-widget.model';
+import { ChartConfigurationService } from '../../../../services/chart-configuration.service';
+import { ColorPickerDirective } from 'ngx-color-picker';
+import { FlexDirective } from '@ngbracket/ngx-layout/flex';
+
+@Component({
+    selector: 'sp-progress-bar-widget-appearance-config',
+    templateUrl: './progress-bar-appearance-config.component.html',
+    styleUrls: ['./progress-bar-appearance-config.component.scss'],
+    imports: [
+        SplitSectionComponent,
+        FormFieldComponent,
+        TranslatePipe,
+        SpNumberFormatConfigComponent,
+        ColorPickerDirective,
+        FlexDirective,
+    ],
+})
+export class ProgressBarWidgetAppearanceConfigComponent implements OnInit {
+    private widgetConfigurationService = inject(ChartConfigurationService);
+
+    @Input()
+    appearanceConfig: ProgressBarAppearanceConfig;
+
+    readonly presetColors = [
+        '#2563EB',
+        '#16A34A',
+        '#F59E0B',
+        '#DC2626',
+        '#0F766E',
+        '#9333EA',
+    ];
+
+    ngOnInit(): void {
+        this.ensureAppearanceConfig();
+    }
+
+    get config(): ProgressBarAppearanceConfig {
+        return this.ensureAppearanceConfig();
+    }
+
+    setColor(key: 'progressColor' | 'trackColor', color: string): void {
+        this.config[key] = color;
+        this.widgetConfigurationService.notify({
+            refreshView: true,
+            refreshData: false,
+        });
+    }
+
+    private ensureAppearanceConfig(): ProgressBarAppearanceConfig {
+        this.appearanceConfig ??= {
+            backgroundColor: 'var(--color-bg-0)',
+            textColor: 'var(--color-default-text)',
+            widgetTitle: '',
+            numberFormat: {},
+        };
+
+        this.appearanceConfig.progressColor ??= '#2563EB';
+        this.appearanceConfig.trackColor ??= 'rgba(148, 163, 184, 0.24)';
+
+        return this.appearanceConfig;
+    }
+}
diff --git a/ui/src/app/chart-shared/components/charts/progress-bar/config/progress-bar-widget-config.component.html b/ui/src/app/chart-shared/components/charts/progress-bar/config/progress-bar-widget-config.component.html
new file mode 100644
index 0000000000..07eef853c4
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/config/progress-bar-widget-config.component.html
@@ -0,0 +1,159 @@
+<!--
+  ~ 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-visualization-config-outer
+    [configurationValid]="
+        currentlyConfiguredWidget.visualizationConfig.configurationValid
+    "
+>
+    <sp-split-section [level]="3" [title]="'Values' | translate">
+        <sp-form-field [level]="3" [label]="'Current value field' | translate">
+            <sp-select-single-property-config
+                [availableProperties]="availableNumericFields"
+                [selectedProperty]="
+                    currentlyConfiguredWidget.visualizationConfig
+                        .currentValueField
+                "
+                (changeSelectedProperty)="setCurrentValueField($event)"
+            >
+            </sp-select-single-property-config>
+        </sp-form-field>
+
+        <sp-form-field [level]="3" [label]="'Target source' | translate">
+            <mat-radio-group
+                [ngModel]="
+                    currentlyConfiguredWidget.visualizationConfig.targetSource
+                "
+                (ngModelChange)="setTargetSource($event)"
+            >
+                <mat-radio-button [value]="'fixed'">
+                    {{ 'Fixed value' | translate }}
+                </mat-radio-button>
+                <mat-radio-button [value]="'field'">
+                    {{ 'Field' | translate }}
+                </mat-radio-button>
+            </mat-radio-group>
+        </sp-form-field>
+
+        @if (
+            currentlyConfiguredWidget.visualizationConfig.targetSource ===
+            'field'
+        ) {
+            <sp-form-field [level]="3" [label]="'Target field' | translate">
+                <sp-select-single-property-config
+                    [availableProperties]="availableNumericFields"
+                    [selectedProperty]="
+                        currentlyConfiguredWidget.visualizationConfig
+                            .targetField
+                    "
+                    (changeSelectedProperty)="setTargetField($event)"
+                >
+                </sp-select-single-property-config>
+            </sp-form-field>
+        } @else {
+            <sp-form-field [level]="3" [label]="'Target value' | translate">
+                <mat-form-field fxFlex="100">
+                    <input
+                        matInput
+                        type="number"
+                        [ngModel]="
+                            currentlyConfiguredWidget.visualizationConfig
+                                .targetValue
+                        "
+                        (ngModelChange)="setTargetValue($event)"
+                    />
+                </mat-form-field>
+            </sp-form-field>
+        }
+    </sp-split-section>
+
+    <sp-split-section [level]="3" [title]="'Behavior' | translate">
+        <sp-form-field [level]="3" [label]="'Display mode' | translate">
+            <mat-radio-group
+                [ngModel]="
+                    currentlyConfiguredWidget.visualizationConfig.displayMode
+                "
+                (ngModelChange)="setDisplayMode($event)"
+            >
+                <mat-radio-button [value]="'percent'">
+                    {{ 'Percent only' | translate }}
+                </mat-radio-button>
+                <mat-radio-button [value]="'value'">
+                    {{ 'Current / Target' | translate }}
+                </mat-radio-button>
+                <mat-radio-button [value]="'percent-and-value'">
+                    {{ 'Percent + Current / Target' | translate }}
+                </mat-radio-button>
+            </mat-radio-group>
+        </sp-form-field>
+
+        <mat-checkbox
+            [checked]="currentlyConfiguredWidget.visualizationConfig.showLabel"
+            (change)="setShowLabel($event.checked)"
+        >
+            {{ 'Show progress label' | translate }}
+        </mat-checkbox>
+
+        <mat-checkbox
+            [checked]="
+                currentlyConfiguredWidget.visualizationConfig.invertProgress
+            "
+            (change)="setInvertProgress($event.checked)"
+        >
+            {{ 'Invert progress semantics' | translate }}
+        </mat-checkbox>
+
+        <mat-checkbox
+            [checked]="
+                currentlyConfiguredWidget.visualizationConfig.clampProgress
+            "
+            (change)="setClampProgress($event.checked)"
+        >
+            {{ 'Clamp to 0-100%' | 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
+                    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
+                    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/progress-bar/config/progress-bar-widget-config.component.ts b/ui/src/app/chart-shared/components/charts/progress-bar/config/progress-bar-widget-config.component.ts
new file mode 100644
index 0000000000..bea77b375f
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/config/progress-bar-widget-config.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 { Component } from '@angular/core';
+import { BaseWidgetConfig } from '../../base/base-widget-config';
+import {
+    ProgressBarDisplayMode,
+    ProgressBarTargetSource,
+    ProgressBarVisConfig,
+    ProgressBarWidgetModel,
+} from '../model/progress-bar-widget.model';
+import { DataExplorerField } from '@streampipes/platform-services';
+import { SpVisualizationConfigOuterComponent } from '../../../chart-config/visualization-config-outer/visualization-config-outer.component';
+import {
+    FormFieldComponent,
+    SplitSectionComponent,
+} from '@streampipes/shared-ui';
+import { SelectSinglePropertyConfigComponent } from '../../../chart-config/select-single-property-config/select-single-property-config.component';
+import { MatFormField } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { FormsModule } from '@angular/forms';
+import { MatCheckbox } from '@angular/material/checkbox';
+import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
+import { FlexDirective } from '@ngbracket/ngx-layout/flex';
+import { TranslatePipe } from '@ngx-translate/core';
+
+@Component({
+    selector: 'sp-data-explorer-progress-bar-widget-config',
+    templateUrl: './progress-bar-widget-config.component.html',
+    imports: [
+        SpVisualizationConfigOuterComponent,
+        SplitSectionComponent,
+        FormFieldComponent,
+        SelectSinglePropertyConfigComponent,
+        MatFormField,
+        MatInput,
+        MatCheckbox,
+        MatRadioGroup,
+        MatRadioButton,
+        FlexDirective,
+        FormsModule,
+        TranslatePipe,
+    ],
+})
+export class ProgressBarWidgetConfigComponent extends BaseWidgetConfig<
+    ProgressBarWidgetModel,
+    ProgressBarVisConfig
+> {
+    get availableNumericFields(): DataExplorerField[] {
+        const primarySourceFields = this.fieldProvider.numericFields.filter(
+            field => field.sourceIndex === 0,
+        );
+        return primarySourceFields.length > 0
+            ? primarySourceFields
+            : this.fieldProvider.numericFields;
+    }
+
+    setCurrentValueField(field: DataExplorerField): void {
+        this.currentlyConfiguredWidget.visualizationConfig.currentValueField =
+            field;
+        this.triggerViewRefresh();
+    }
+
+    setTargetSource(targetSource: ProgressBarTargetSource): void {
+        this.currentlyConfiguredWidget.visualizationConfig.targetSource =
+            targetSource;
+
+        if (
+            targetSource === 'field' &&
+            !this.currentlyConfiguredWidget.visualizationConfig.targetField
+        ) {
+            this.currentlyConfiguredWidget.visualizationConfig.targetField =
+                this.availableNumericFields[0];
+        }
+
+        this.triggerViewRefresh();
+    }
+
+    setTargetField(field: DataExplorerField): void {
+        this.currentlyConfiguredWidget.visualizationConfig.targetField = field;
+        this.triggerViewRefresh();
+    }
+
+    setTargetValue(targetValue: string | number): void {
+        const numericValue = Number(String(targetValue).replace(',', '.'));
+        this.currentlyConfiguredWidget.visualizationConfig.targetValue =
+            Number.isFinite(numericValue) ? numericValue : undefined;
+        this.triggerViewRefresh();
+    }
+
+    setInvertProgress(invertProgress: boolean): void {
+        this.currentlyConfiguredWidget.visualizationConfig.invertProgress =
+            invertProgress;
+        this.triggerViewRefresh();
+    }
+
+    setClampProgress(clampProgress: boolean): void {
+        this.currentlyConfiguredWidget.visualizationConfig.clampProgress =
+            clampProgress;
+        this.triggerViewRefresh();
+    }
+
+    setDisplayMode(displayMode: ProgressBarDisplayMode): void {
+        this.currentlyConfiguredWidget.visualizationConfig.displayMode =
+            displayMode;
+        this.triggerViewRefresh();
+    }
+
+    setShowLabel(showLabel: boolean): void {
+        this.currentlyConfiguredWidget.visualizationConfig.showLabel =
+            showLabel;
+        this.triggerViewRefresh();
+    }
+
+    protected applyWidgetConfig(config: ProgressBarVisConfig): void {
+        config.currentValueField = this.fieldService.getSelectedField(
+            config.currentValueField,
+            this.availableNumericFields,
+            () => this.availableNumericFields[0],
+        );
+        config.targetSource ??= 'fixed';
+        config.targetField = this.fieldService.getSelectedField(
+            config.targetField,
+            this.availableNumericFields,
+            () => this.availableNumericFields[0],
+        );
+        config.targetValue ??= 100;
+        config.invertProgress ??= false;
+        config.clampProgress ??= true;
+        config.displayMode ??= 'percent-and-value';
+        config.showLabel ??= true;
+        config.title ??= '';
+        config.description ??= '';
+    }
+
+    protected requiredFieldsForChartPresent(): boolean {
+        return this.availableNumericFields.length > 0;
+    }
+}
diff --git a/ui/src/app/chart-shared/components/charts/progress-bar/model/progress-bar-widget.model.ts b/ui/src/app/chart-shared/components/charts/progress-bar/model/progress-bar-widget.model.ts
new file mode 100644
index 0000000000..678980565e
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/model/progress-bar-widget.model.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 {
+    DataExplorerDataConfig,
+    DataExplorerField,
+    DataExplorerWidgetModel,
+} from '@streampipes/platform-services';
+import {
+    DataExplorerVisConfig,
+    WidgetNumberAppearanceConfig,
+} from '../../../../models/dataview-dashboard.model';
+
+export type ProgressBarTargetSource = 'fixed' | 'field';
+export type ProgressBarDisplayMode = 'percent' | 'value' | 'percent-and-value';
+
+export interface ProgressBarVisConfig extends DataExplorerVisConfig {
+    currentValueField?: DataExplorerField;
+    targetSource?: ProgressBarTargetSource;
+    targetField?: DataExplorerField;
+    targetValue?: number;
+    invertProgress?: boolean;
+    clampProgress?: boolean;
+    displayMode?: ProgressBarDisplayMode;
+    showLabel?: boolean;
+    title?: string;
+    description?: string;
+}
+
+export interface ProgressBarAppearanceConfig extends WidgetNumberAppearanceConfig {
+    progressColor?: string;
+    trackColor?: string;
+}
+
+export interface ProgressBarWidgetModel extends DataExplorerWidgetModel {
+    dataConfig: DataExplorerDataConfig;
+    visualizationConfig: ProgressBarVisConfig;
+    baseAppearanceConfig: ProgressBarAppearanceConfig;
+}
diff --git a/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.html b/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.html
new file mode 100644
index 0000000000..e5dc11771b
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.html
@@ -0,0 +1,112 @@
+<!--
+  ~ 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="progress-widget"
+    fxLayout="column"
+    fxFlex="100"
+    [ngStyle]="widgetStyles"
+    data-cy="progress-bar-widget"
+>
+    @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 && progressView) {
+        <div class="progress-shell" fxLayout="column" fxFlex="100">
+            @if (titleText || descriptionText) {
+                <div class="progress-copy" fxLayout="column">
+                    @if (titleText) {
+                        <div class="progress-title">
+                            {{ titleText }}
+                        </div>
+                    }
+
+                    @if (descriptionText) {
+                        <div class="progress-description">
+                            {{ descriptionText }}
+                        </div>
+                    }
+                </div>
+            }
+
+            <div
+                class="progress-meta"
+                fxLayout="row"
+                fxLayoutAlign="space-between center"
+            >
+                <span class="progress-status">{{
+                    progressView.statusLabel
+                }}</span>
+                @if (showLabel) {
+                    <span class="progress-percent">{{
+                        progressView.percentLabel
+                    }}</span>
+                }
+            </div>
+
+            <div class="progress-track">
+                <div class="progress-fill" [style.width]="progressWidth"></div>
+            </div>
+
+            @if (showLabel) {
+                <div class="progress-labels" fxLayout="column">
+                    @if (
+                        dataExplorerWidget.visualizationConfig.displayMode ===
+                            'percent' ||
+                        dataExplorerWidget.visualizationConfig.displayMode ===
+                            'percent-and-value'
+                    ) {
+                        <div class="progress-primary-label">
+                            {{ progressView.percentLabel }}
+                        </div>
+                    }
+
+                    @if (
+                        dataExplorerWidget.visualizationConfig.displayMode ===
+                            'value' ||
+                        dataExplorerWidget.visualizationConfig.displayMode ===
+                            'percent-and-value'
+                    ) {
+                        <div class="progress-secondary-label">
+                            {{ progressView.valueLabel }}
+                        </div>
+                    }
+                </div>
+            }
+        </div>
+    }
+</div>
diff --git a/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.scss b/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.scss
new file mode 100644
index 0000000000..b25c8eee61
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.scss
@@ -0,0 +1,107 @@
+/*
+ * 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%;
+}
+
+.progress-widget {
+    box-sizing: border-box;
+    height: 100%;
+    min-height: 0;
+    overflow: hidden;
+    padding: var(--progress-widget-padding);
+}
+
+.progress-shell {
+    gap: var(--progress-shell-gap);
+    min-height: 0;
+}
+
+.progress-copy {
+    gap: 0.35rem;
+}
+
+.progress-title {
+    font-size: var(--progress-title-size);
+    font-weight: 700;
+    letter-spacing: -0.03em;
+    line-height: 1.08;
+}
+
+.progress-description {
+    color: color-mix(in srgb, currentColor 70%, transparent);
+    font-size: var(--progress-description-size);
+    line-height: 1.35;
+    max-width: 48ch;
+}
+
+.progress-meta {
+    column-gap: 1rem;
+}
+
+.progress-status {
+    color: color-mix(in srgb, currentColor 68%, transparent);
+    font-size: var(--progress-percent-size);
+    font-weight: 700;
+}
+
+.progress-percent {
+    font-size: var(--progress-percent-size);
+    font-weight: 700;
+}
+
+.progress-track {
+    background: var(--progress-bar-track-color);
+    border-radius: 999px;
+    flex-shrink: 0;
+    height: var(--progress-track-height);
+    overflow: hidden;
+    position: relative;
+}
+
+.progress-fill {
+    background: var(--progress-bar-fill-color);
+    border-radius: inherit;
+    height: 100%;
+    min-width: 0;
+    transition: width 220ms ease-out;
+}
+
+.progress-labels {
+    gap: 0.2rem;
+}
+
+.progress-primary-label {
+    font-size: var(--progress-primary-label-size);
+    font-weight: 700;
+    letter-spacing: -0.04em;
+    line-height: 1;
+}
+
+.progress-secondary-label {
+    color: color-mix(in srgb, currentColor 70%, transparent);
+    font-size: var(--progress-secondary-label-size);
+    font-weight: 600;
+    line-height: 1.3;
+}
diff --git a/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.ts b/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.ts
new file mode 100644
index 0000000000..4d42c12d6a
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/progress-bar/progress-bar-widget.component.ts
@@ -0,0 +1,376 @@
+/*
+ * 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, LOCALE_ID, OnInit, inject } from '@angular/core';
+import {
+    DataExplorerField,
+    SpQueryResult,
+} from '@streampipes/platform-services';
+import { FlexDirective, LayoutDirective } from '@ngbracket/ngx-layout/flex';
+import { BaseDataExplorerWidgetDirective } from '../base/base-data-explorer-widget.directive';
+import { NoDataInDateRangeComponent } from '../base/no-data/no-data-in-date-range.component';
+import { TooMuchDataComponent } from '../base/too-much-data/too-much-data.component';
+import { SpInvalidConfigurationComponent } from '../base/invalid-configuration/invalid-configuration.component';
+import { ChartRegistry } from '../../../registry/chart-registry.service';
+import {
+    ProgressBarAppearanceConfig,
+    ProgressBarTargetSource,
+    ProgressBarWidgetModel,
+} from './model/progress-bar-widget.model';
+import {
+    clampValue,
+    formatWidgetNumber,
+    scaleResponsiveValue,
+} from '../../../services/widget-render.utils';
+
+interface ProgressBarViewModel {
+    ratio: number;
+    percentLabel: string;
+    valueLabel: string;
+    statusLabel: string;
+}
+
+@Component({
+    selector: 'sp-data-explorer-progress-bar-widget',
+    templateUrl: './progress-bar-widget.component.html',
+    styleUrls: ['./progress-bar-widget.component.scss'],
+    imports: [
+        LayoutDirective,
+        FlexDirective,
+        NgStyle,
+        NoDataInDateRangeComponent,
+        TooMuchDataComponent,
+        SpInvalidConfigurationComponent,
+    ],
+})
+export class ProgressBarWidgetComponent
+    extends BaseDataExplorerWidgetDirective<ProgressBarWidgetModel>
+    implements OnInit
+{
+    widgetTypeLabel: string;
+    progressView?: ProgressBarViewModel;
+
+    private hasReceivedData = false;
+    private latestData: SpQueryResult[] = [];
+    private readonly locale = inject(LOCALE_ID);
+    private readonly chartRegistry = inject(ChartRegistry);
+
+    ngOnInit(): void {
+        super.ngOnInit();
+        this.widgetTypeLabel = this.chartRegistry.getChartTemplate(
+            this.dataExplorerWidget.widgetType,
+        ).label;
+    }
+
+    get appearanceConfig(): ProgressBarAppearanceConfig {
+        this.dataExplorerWidget.baseAppearanceConfig ??= {
+            backgroundColor: 'var(--color-bg-0)',
+            textColor: 'var(--color-default-text)',
+            widgetTitle: '',
+            numberFormat: {},
+            progressColor: '#2563EB',
+            trackColor: 'rgba(148, 163, 184, 0.24)',
+        };
+
+        return this.dataExplorerWidget
+            .baseAppearanceConfig as ProgressBarAppearanceConfig;
+    }
+
+    get widgetStyles(): Record<string, string> {
+        const compactMode =
+            (this.currentWidth ?? 0) < 520 || (this.currentHeight ?? 0) < 220;
+        const fontScale = this.getFontScale();
+
+        return {
+            'background': this.appearanceConfig.backgroundColor,
+            'color': this.appearanceConfig.textColor,
+            '--progress-widget-padding': compactMode
+                ? '0.75rem 0.85rem'
+                : '1rem 1.1rem',
+            '--progress-shell-gap': `${scaleResponsiveValue(
+                8,
+                16,
+                fontScale,
+                0.55,
+                1.35,
+            )}px`,
+            '--progress-title-size': `${scaleResponsiveValue(
+                16,
+                20,
+                fontScale,
+                0.55,
+                1.35,
+            )}px`,
+            '--progress-description-size': `${scaleResponsiveValue(
+                12,
+                14,
+                fontScale,
+                0.55,
+                1.35,
+            )}px`,
+            '--progress-status-size': `${scaleResponsiveValue(
+                10,
+                13,
+                fontScale,
+                0.55,
+                1.35,
+            )}px`,
+            '--progress-percent-size': `${scaleResponsiveValue(
+                12,
+                15,
+                fontScale,
+                0.55,
+                1.35,
+            )}px`,
+            '--progress-track-height': `${scaleResponsiveValue(
+                10,
+                16,
+                fontScale,
+                0.55,
+                1.35,
+            )}px`,
+            '--progress-primary-label-size': `${scaleResponsiveValue(
+                18,
+                38,
+                fontScale,
+                0.55,
+                1.35,
+            )}px`,
+            '--progress-secondary-label-size': `${scaleResponsiveValue(
+                12,
+                15,
+                fontScale,
+                0.55,
+                1.35,
+            )}px`,
+            '--progress-bar-fill-color':
+                this.appearanceConfig.progressColor ?? '#2563EB',
+            '--progress-bar-track-color':
+                this.appearanceConfig.trackColor ?? 'rgba(148, 163, 184, 0.24)',
+        };
+    }
+
+    get titleText(): string {
+        return this.dataExplorerWidget.visualizationConfig.title?.trim() ?? '';
+    }
+
+    get descriptionText(): string {
+        return (
+            this.dataExplorerWidget.visualizationConfig.description?.trim() ??
+            ''
+        );
+    }
+
+    get showLabel(): boolean {
+        return !!this.dataExplorerWidget.visualizationConfig.showLabel;
+    }
+
+    get progressWidth(): string {
+        return `${clampValue((this.progressView?.ratio ?? 0) * 100, 0, 100)}%`;
+    }
+
+    beforeDataFetched(): void {
+        if (this.progressView) {
+            this.setShownComponents(false, true, false, false);
+            return;
+        }
+
+        this.setShownComponents(false, false, true, false);
+    }
+
+    onDataReceived(spQueryResults: SpQueryResult[]): void {
+        this.hasReceivedData = true;
+        this.latestData = spQueryResults;
+        this.refreshView();
+    }
+
+    onResize(_width: number, _height: number): void {
+        this.refreshView();
+    }
+
+    refreshView(): void {
+        this.updateProgressView();
+    }
+
+    handleUpdatedFields(
+        addedFields: DataExplorerField[],
+        removedFields: DataExplorerField[],
+    ): void {
+        const fieldUpdateInfo = {
+            addedFields,
+            removedFields,
+            fieldProvider: this.fieldProvider,
+        };
+
+        this.dataExplorerWidget.visualizationConfig.currentValueField =
+            this.fieldUpdateService.updateNumericField(
+                this.dataExplorerWidget.visualizationConfig.currentValueField,
+                fieldUpdateInfo,
+            );
+        this.dataExplorerWidget.visualizationConfig.targetField =
+            this.fieldUpdateService.updateNumericField(
+                this.dataExplorerWidget.visualizationConfig.targetField,
+                fieldUpdateInfo,
+            );
+        this.refreshView();
+    }
+
+    private updateProgressView(): void {
+        if (!this.isConfigurationValid()) {
+            this.showInvalidConfiguration = true;
+            this.progressView = undefined;
+            this.setShownComponents(false, false, false, false);
+            return;
+        }
+
+        this.showInvalidConfiguration = false;
+        if (!this.hasReceivedData && this.latestData.length === 0) {
+            return;
+        }
+
+        const firstQueryResult = this.latestData[0];
+        const firstRow = this.getFirstRow(firstQueryResult);
+
+        if (!firstQueryResult?.headers?.length || !firstRow) {
+            this.progressView = undefined;
+            this.setShownComponents(true, false, false, false);
+            return;
+        }
+
+        const currentValue = this.getFieldValue(
+            firstQueryResult,
+            firstRow,
+            this.dataExplorerWidget.visualizationConfig.currentValueField,
+        );
+        const targetValue = this.getTargetValue(firstQueryResult, firstRow);
+
+        if (
+            currentValue === undefined ||
+            targetValue === undefined ||
+            !Number.isFinite(currentValue) ||
+            !Number.isFinite(targetValue) ||
+            targetValue <= 0
+        ) {
+            this.showInvalidConfiguration = true;
+            this.progressView = undefined;
+            this.setShownComponents(false, false, false, false);
+            return;
+        }
+
+        const rawRatio = currentValue / targetValue;
+        const inverted =
+            this.dataExplorerWidget.visualizationConfig.invertProgress;
+        const unclampedRatio = inverted ? 1 - rawRatio : rawRatio;
+        const ratio = this.dataExplorerWidget.visualizationConfig.clampProgress
+            ? clampValue(unclampedRatio, 0, 1)
+            : unclampedRatio;
+
+        this.progressView = {
+            ratio,
+            percentLabel: `${formatWidgetNumber(
+                ratio * 100,
+                this.locale,
+                this.appearanceConfig.numberFormat?.decimals,
+            )}%`,
+            valueLabel: `${formatWidgetNumber(
+                currentValue,
+                this.locale,
+                this.appearanceConfig.numberFormat?.decimals,
+            )} / ${formatWidgetNumber(
+                targetValue,
+                this.locale,
+                this.appearanceConfig.numberFormat?.decimals,
+            )}`,
+            statusLabel: inverted ? 'Remaining' : 'Completed',
+        };
+
+        this.setShownComponents(false, true, false, false);
+    }
+
+    private isConfigurationValid(): boolean {
+        const config = this.dataExplorerWidget.visualizationConfig;
+
+        if (!config.currentValueField) {
+            return false;
+        }
+
+        if (config.targetSource === 'field') {
+            return !!config.targetField;
+        }
+
+        return config.targetValue !== undefined && config.targetValue !== null;
+    }
+
+    private getTargetValue(
+        queryResult: SpQueryResult,
+        row: unknown[],
+    ): number | undefined {
+        const targetSource = this.dataExplorerWidget.visualizationConfig
+            .targetSource as ProgressBarTargetSource;
+
+        if (targetSource === 'field') {
+            return this.getFieldValue(
+                queryResult,
+                row,
+                this.dataExplorerWidget.visualizationConfig.targetField,
+            );
+        }
+
+        return this.dataExplorerWidget.visualizationConfig.targetValue;
+    }
+
+    private getFieldValue(
+        queryResult: SpQueryResult,
+        row: unknown[],
+        field?: DataExplorerField,
+    ): number | undefined {
+        if (!field) {
+            return undefined;
+        }
+
+        const fieldIndex = queryResult.headers.findIndex(
+            header => header === field.fullDbName,
+        );
+        if (fieldIndex < 0) {
+            return undefined;
+        }
+
+        const value = Number(row[fieldIndex]);
+        return Number.isFinite(value) ? value : undefined;
+    }
+
+    private getFirstRow(queryResult?: SpQueryResult): unknown[] | undefined {
+        return queryResult?.allDataSeries.find(series => series.rows.length > 0)
+            ?.rows[0];
+    }
+
+    private getFontScale(): number {
+        const width = this.currentWidth ?? 0;
+        const height = this.currentHeight ?? 0;
+
+        if (width <= 0 || height <= 0) {
+            return 0.85;
+        }
+
+        const widthScale = clampValue(width / 420, 0.6, 1.35);
+        const heightScale = clampValue(height / 260, 0.55, 1.35);
+
+        return clampValue(Math.min(widthScale, heightScale), 0.55, 1.35);
+    }
+}
diff --git a/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.scss b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.scss
index a7c09985e7..703a33ca15 100644
--- a/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.scss
+++ b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.scss
@@ -70,17 +70,18 @@
     border-radius: 5px;
     min-height: 0;
     overflow: hidden;
-    padding: 1.1rem 1.2rem;
+    padding: 0 1.2rem;
 }
 
 .value-card-list {
     display: grid;
+    align-content: start;
     gap: 0;
     grid-template-columns: repeat(
         var(--value-card-list-columns),
         minmax(0, 1fr)
     );
-    min-height: 100%;
+    min-height: 0;
 }
 
 .value-card-item {
diff --git a/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.ts b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.ts
index 3816e58927..380ce0dad6 100644
--- a/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.ts
+++ b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.ts
@@ -34,6 +34,12 @@ import {
     ValueCardWidgetModel,
 } from './model/value-card-widget.model';
 import { ResultLabelService } from '../../../services/result-label.service';
+import {
+    clampValue,
+    formatWidgetNumber,
+    resolveResponsiveFontSize,
+    scaleResponsiveValue,
+} from '../../../services/widget-render.utils';
 
 interface ValueCardView {
     id: string;
@@ -99,20 +105,36 @@ export class ValueCardWidgetComponent
             '--value-card-gap': compactMode ? '12px' : '18px',
             '--value-card-title-size': compactMode ? '18px' : '24px',
             '--value-card-description-size': compactMode ? '12px' : '14px',
-            '--value-card-row-padding': `${this.scaleValue(12, 22, fontScale)}px`,
-            '--value-card-label-size': `${this.resolveFontSize(
+            '--value-card-row-padding': `${scaleResponsiveValue(
+                12,
+                22,
+                fontScale,
+                0.7,
+                1.45,
+            )}px`,
+            '--value-card-label-size': `${resolveResponsiveFontSize(
                 this.appearanceConfig.labelFontSize,
                 11,
                 16,
                 fontScale,
+                0.7,
+                1.45,
             )}px`,
-            '--value-card-value-size': `${this.resolveFontSize(
+            '--value-card-value-size': `${resolveResponsiveFontSize(
                 this.appearanceConfig.valueFontSize,
                 20,
                 42,
                 fontScale,
+                0.7,
+                1.45,
+            )}px`,
+            '--value-card-row-gap': `${scaleResponsiveValue(
+                4,
+                10,
+                fontScale,
+                0.7,
+                1.45,
             )}px`,
-            '--value-card-row-gap': `${this.scaleValue(4, 10, fontScale)}px`,
             '--value-card-border-color':
                 'color-mix(in srgb, currentColor 10%, transparent)',
             '--value-card-divider-color':
@@ -145,6 +167,11 @@ export class ValueCardWidgetComponent
     }
 
     beforeDataFetched(): void {
+        if (this.valueCards.length > 0) {
+            this.setShownComponents(false, true, false, false);
+            return;
+        }
+
         this.setShownComponents(false, false, true, false);
     }
 
@@ -264,14 +291,11 @@ export class ValueCardWidgetComponent
         }
 
         if (typeof value === 'number' && Number.isFinite(value)) {
-            const decimals = this.normalizeDecimals(
+            return formatWidgetNumber(
+                value,
+                this.locale,
                 this.appearanceConfig.numberFormat?.decimals,
             );
-
-            return new Intl.NumberFormat(this.locale, {
-                maximumFractionDigits: decimals ?? 20,
-                minimumFractionDigits: decimals ?? 0,
-            }).format(value);
         }
 
         if (typeof value === 'boolean') {
@@ -281,19 +305,6 @@ export class ValueCardWidgetComponent
         return String(value);
     }
 
-    private normalizeDecimals(decimals: unknown): number | undefined {
-        if (decimals === null || decimals === undefined || decimals === '') {
-            return undefined;
-        }
-
-        const parsedValue = Number(decimals);
-        if (!Number.isFinite(parsedValue)) {
-            return undefined;
-        }
-
-        return Math.min(10, Math.max(0, Math.round(parsedValue)));
-    }
-
     private getLatestTimestampLabel(): string | undefined {
         const firstQueryResult = this.latestData.find(
             queryResult => !!this.getFirstRow(queryResult),
@@ -343,43 +354,15 @@ export class ValueCardWidgetComponent
             return 0.8;
         }
 
-        const widthScale = this.clamp(width / 640, 0.7, 1.45);
+        const widthScale = clampValue(width / 640, 0.7, 1.45);
         const availableHeight = Math.max(height - 110, 120);
-        const heightPerRowScale = this.clamp(
+        const heightPerRowScale = clampValue(
             availableHeight / (rowCount * 84),
             0.7,
             1.45,
         );
 
-        return this.clamp(Math.min(widthScale, heightPerRowScale), 0.7, 1.45);
-    }
-
-    private scaleValue(min: number, max: number, scale: number): number {
-        const normalizedScale = (scale - 0.7) / 0.75;
-        const scaledValue = min + (max - min) * normalizedScale;
-        return Math.round(this.clamp(scaledValue, min, max));
-    }
-
-    private resolveFontSize(
-        manualSize: number | undefined,
-        min: number,
-        max: number,
-        scale: number,
-    ): number {
-        if (
-            manualSize === undefined ||
-            manualSize === null ||
-            Number.isNaN(Number(manualSize)) ||
-            manualSize <= 0
-        ) {
-            return this.scaleValue(min, max, scale);
-        }
-
-        return Math.round(Math.max(1, manualSize) * scale * 10) / 10;
-    }
-
-    private clamp(value: number, min: number, max: number): number {
-        return Math.min(Math.max(value, min), max);
+        return clampValue(Math.min(widthScale, heightPerRowScale), 0.7, 1.45);
     }
 
     get valueListColumns(): number {
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 b2438b7926..d1d32dd65d 100644
--- a/ui/src/app/chart-shared/registry/chart-registry.service.ts
+++ b/ui/src/app/chart-shared/registry/chart-registry.service.ts
@@ -64,6 +64,9 @@ import { TranslateService } from '@ngx-translate/core';
 import { ValueCardWidgetComponent } from '../components/charts/value-card/value-card-widget.component';
 import { ValueCardWidgetConfigComponent } from '../components/charts/value-card/config/value-card-widget-config.component';
 import { ValueCardWidgetAppearanceConfigComponent } from '../components/charts/value-card/appearance-config/value-card-appearance-config.component';
+import { ProgressBarWidgetComponent } from '../components/charts/progress-bar/progress-bar-widget.component';
+import { ProgressBarWidgetConfigComponent } from '../components/charts/progress-bar/config/progress-bar-widget-config.component';
+import { ProgressBarWidgetAppearanceConfigComponent } from '../components/charts/progress-bar/appearance-config/progress-bar-appearance-config.component';
 
 @Injectable({ providedIn: 'root' })
 export class ChartRegistry {
@@ -200,6 +203,18 @@ export class ChartRegistry {
                     'Display the latest values of multiple selected fields in a single card',
                 ),
             },
+            {
+                id: 'progress-bar',
+                label: this.translateService.instant('Progress Bar'),
+                widgetAppearanceConfigurationComponent:
+                    ProgressBarWidgetAppearanceConfigComponent,
+                widgetConfigurationComponent: ProgressBarWidgetConfigComponent,
+                widgetComponent: ProgressBarWidgetComponent,
+                icon: 'linear_scale',
+                description: this.translateService.instant(
+                    'Display the progress of a numeric value against a target',
+                ),
+            },
             {
                 id: 'indicator-chart',
                 label: this.translateService.instant('Indicator'),
diff --git a/ui/src/app/chart-shared/services/widget-render.utils.ts b/ui/src/app/chart-shared/services/widget-render.utils.ts
new file mode 100644
index 0000000000..02d8acb9dc
--- /dev/null
+++ b/ui/src/app/chart-shared/services/widget-render.utils.ts
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ *
+ */
+
+export function clampValue(value: number, min: number, max: number): number {
+    return Math.min(Math.max(value, min), max);
+}
+
+export function normalizeWidgetDecimals(decimals: unknown): number | undefined {
+    if (decimals === null || decimals === undefined || decimals === '') {
+        return undefined;
+    }
+
+    const parsedValue = Number(decimals);
+    if (!Number.isFinite(parsedValue)) {
+        return undefined;
+    }
+
+    return Math.min(10, Math.max(0, Math.round(parsedValue)));
+}
+
+export function formatWidgetNumber(
+    value: number,
+    locale: string,
+    decimals: unknown,
+): string {
+    const normalizedDecimals = normalizeWidgetDecimals(decimals);
+
+    return new Intl.NumberFormat(locale, {
+        maximumFractionDigits: normalizedDecimals ?? 20,
+        minimumFractionDigits: normalizedDecimals ?? 0,
+    }).format(value);
+}
+
+export function scaleResponsiveValue(
+    min: number,
+    max: number,
+    scale: number,
+    scaleMin: number,
+    scaleMax: number,
+): number {
+    const normalizedScale = (scale - scaleMin) / (scaleMax - scaleMin);
+    const scaledValue = min + (max - min) * normalizedScale;
+    return Math.round(clampValue(scaledValue, min, max));
+}
+
+export function resolveResponsiveFontSize(
+    manualSize: number | undefined,
+    min: number,
+    max: number,
+    scale: number,
+    scaleMin: number,
+    scaleMax: number,
+): number {
+    if (
+        manualSize === undefined ||
+        manualSize === null ||
+        Number.isNaN(Number(manualSize)) ||
+        manualSize <= 0
+    ) {
+        return scaleResponsiveValue(min, max, scale, scaleMin, scaleMax);
+    }
+
+    return Math.round(Math.max(1, manualSize) * scale * 10) / 10;
+}


(streampipes) 02/03: feat: Add value card widget

Posted by ri...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dominikriemer pushed a commit to branch add-visualization-options
in repository https://gitbox.apache.org/repos/asf/streampipes.git

commit 73442dfe0fce899a08f33f0a696718b73aa650ea
Author: Dominik Riemer <do...@gmail.com>
AuthorDate: Sun Jun 14 20:51:09 2026 +0200

    feat: Add value card widget
---
 .../field-order-config.component.html              |  90 +++++
 .../field-order-config.component.scss}             |  49 +--
 .../field-order-config.component.ts                | 105 ++++++
 .../config/table-widget-config.component.html      |  88 +----
 .../config/table-widget-config.component.scss      |  53 ---
 .../table/config/table-widget-config.component.ts  |  69 +---
 .../value-card-appearance-config.component.html    |  50 +++
 .../value-card-appearance-config.component.ts      |  84 +++++
 .../config/value-card-widget-config.component.html |  86 +++++
 .../config/value-card-widget-config.component.ts   | 133 +++++++
 .../value-card/model/value-card-widget.model.ts    |  45 +++
 .../value-card/value-card-widget.component.html    |  91 +++++
 .../value-card/value-card-widget.component.scss    | 117 +++++++
 .../value-card/value-card-widget.component.ts      | 388 +++++++++++++++++++++
 .../registry/chart-registry.service.ts             |  15 +
 15 files changed, 1223 insertions(+), 240 deletions(-)

diff --git a/ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.component.html b/ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.component.html
new file mode 100644
index 0000000000..561fe4b053
--- /dev/null
+++ b/ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.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.
+  ~
+  -->
+
+<sp-split-section [level]="3" [title]="sectionTitle | translate">
+    <div
+        fxLayout="column"
+        class="selected-field-list"
+        cdkDropList
+        [cdkDropListData]="selectedFields"
+        (cdkDropListDropped)="dropSelectedField($event)"
+    >
+        @for (field of selectedFields; track fieldKey(field); let i = $index) {
+            <div
+                class="selected-field-row"
+                fxLayout="row"
+                fxLayoutAlign="space-between center"
+                cdkDrag
+            >
+                <div
+                    class="selected-field-main"
+                    fxLayout="row"
+                    fxLayoutAlign="start center"
+                >
+                    <button
+                        mat-icon-button
+                        type="button"
+                        class="selected-field-drag-handle"
+                        cdkDragHandle
+                        [attr.aria-label]="
+                            'Reorder ' + field.runtimeName | translate
+                        "
+                    >
+                        <mat-icon>drag_indicator</mat-icon>
+                    </button>
+                    <div class="selected-field-label">
+                        {{ selectedFieldLabel(field) }}
+                    </div>
+                </div>
+                <div
+                    class="selected-field-actions"
+                    fxLayout="row"
+                    fxLayoutAlign="end center"
+                >
+                    <button
+                        mat-icon-button
+                        type="button"
+                        [disabled]="!canMoveSelectedFieldUp(i)"
+                        [attr.aria-label]="
+                            'Move ' + field.runtimeName + ' up' | translate
+                        "
+                        (click)="moveSelectedField(i, -1)"
+                    >
+                        <mat-icon>arrow_upward</mat-icon>
+                    </button>
+                    <button
+                        mat-icon-button
+                        type="button"
+                        [disabled]="!canMoveSelectedFieldDown(i)"
+                        [attr.aria-label]="
+                            'Move ' + field.runtimeName + ' down' | translate
+                        "
+                        (click)="moveSelectedField(i, 1)"
+                    >
+                        <mat-icon>arrow_downward</mat-icon>
+                    </button>
+                </div>
+            </div>
+        } @empty {
+            <sp-alert-banner
+                type="info"
+                [title]="emptyMessage | translate"
+            ></sp-alert-banner>
+        }
+    </div>
+</sp-split-section>
diff --git a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss b/ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.component.scss
similarity index 65%
copy from ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
copy to ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.component.scss
index e926be0642..94f2bc6b41 100644
--- a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
+++ b/ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.component.scss
@@ -16,11 +16,11 @@
  *
  */
 
-.selected-column-list {
+.selected-field-list {
     gap: var(--space-sm);
 }
 
-.selected-column-row {
+.selected-field-row {
     gap: var(--space-sm);
     padding: var(--space-xs) var(--space-sm);
     border: 1px solid var(--color-border-subtle, var(--color-bg-3));
@@ -28,22 +28,22 @@
     background: var(--color-bg-1, var(--color-bg-0));
 }
 
-.selected-column-main {
+.selected-field-main {
     min-width: 0;
     gap: var(--space-xs);
     flex: 1;
 }
 
-.selected-column-drag-handle {
+.selected-field-drag-handle {
     cursor: grab;
     color: var(--color-secondary-text);
 }
 
-.selected-column-drag-handle:active {
+.selected-field-drag-handle:active {
     cursor: grabbing;
 }
 
-.selected-column-label {
+.selected-field-label {
     min-width: 0;
     flex: 1;
     font-size: var(--font-size-sm);
@@ -52,7 +52,7 @@
     white-space: nowrap;
 }
 
-.selected-column-actions {
+.selected-field-actions {
     flex-shrink: 0;
     gap: var(--space-xxs);
 }
@@ -68,38 +68,3 @@
 .cdk-drag-placeholder {
     opacity: 0.35;
 }
-
-.highlight-color-list {
-    gap: var(--space-sm);
-}
-
-.highlight-color-label {
-    display: flex;
-    flex-direction: column;
-    min-width: 0;
-    font-size: var(--font-size-sm);
-}
-
-.highlight-color-measure {
-    color: var(--color-secondary-text);
-    font-size: var(--font-size-xs);
-}
-
-.highlight-color-picker {
-    margin-left: auto;
-    display: block;
-    width: calc(var(--space-md) * 2.4);
-    height: calc(var(--space-md) * 2.4);
-    padding: 0;
-    border: 1px solid var(--color-tab-border);
-    border-radius: var(--button-border-radius);
-    cursor: pointer;
-    appearance: none;
-    -webkit-appearance: none;
-    background-clip: padding-box;
-}
-
-.highlight-color-picker:disabled {
-    cursor: default;
-    opacity: 0.45;
-}
diff --git a/ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.component.ts b/ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.component.ts
new file mode 100644
index 0000000000..b9ce496a2c
--- /dev/null
+++ b/ui/src/app/chart-shared/components/chart-config/field-order-config/field-order-config.component.ts
@@ -0,0 +1,105 @@
+/*
+ * 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, EventEmitter, Input, Output } from '@angular/core';
+import { DataExplorerField } from '@streampipes/platform-services';
+import {
+    CdkDrag,
+    CdkDragDrop,
+    CdkDragHandle,
+    CdkDropList,
+    moveItemInArray,
+} from '@angular/cdk/drag-drop';
+import {
+    LayoutAlignDirective,
+    LayoutDirective,
+} from '@ngbracket/ngx-layout/flex';
+import { MatIcon } from '@angular/material/icon';
+import { MatIconButton } from '@angular/material/button';
+import { SpAlertBannerComponent } from '@streampipes/shared-ui';
+import { TranslatePipe } from '@ngx-translate/core';
+import { SplitSectionComponent } from '@streampipes/shared-ui';
+
+@Component({
+    selector: 'sp-field-order-config',
+    templateUrl: './field-order-config.component.html',
+    styleUrls: ['./field-order-config.component.scss'],
+    imports: [
+        SplitSectionComponent,
+        CdkDropList,
+        CdkDrag,
+        CdkDragHandle,
+        LayoutDirective,
+        LayoutAlignDirective,
+        MatIcon,
+        MatIconButton,
+        SpAlertBannerComponent,
+        TranslatePipe,
+    ],
+})
+export class FieldOrderConfigComponent {
+    @Input() selectedFields: DataExplorerField[] = [];
+    @Input() sectionTitle = 'Field Order';
+    @Input() emptyMessage = 'No fields selected.';
+
+    @Output() selectedFieldsChange = new EventEmitter<DataExplorerField[]>();
+
+    dropSelectedField(event: CdkDragDrop<DataExplorerField[]>): void {
+        if (event.previousIndex === event.currentIndex) {
+            return;
+        }
+
+        const fields = [...(this.selectedFields ?? [])];
+        moveItemInArray(fields, event.previousIndex, event.currentIndex);
+        this.selectedFieldsChange.emit(fields);
+    }
+
+    moveSelectedField(fromIndex: number, offset: number): void {
+        const fields = [...(this.selectedFields ?? [])];
+        const targetIndex = fromIndex + offset;
+
+        if (
+            fromIndex < 0 ||
+            targetIndex < 0 ||
+            fromIndex >= fields.length ||
+            targetIndex >= fields.length
+        ) {
+            return;
+        }
+
+        const [movedField] = fields.splice(fromIndex, 1);
+        fields.splice(targetIndex, 0, movedField);
+        this.selectedFieldsChange.emit(fields);
+    }
+
+    canMoveSelectedFieldUp(index: number): boolean {
+        return index > 0;
+    }
+
+    canMoveSelectedFieldDown(index: number): boolean {
+        return index < (this.selectedFields?.length ?? 0) - 1;
+    }
+
+    selectedFieldLabel(field: DataExplorerField): string {
+        return `${field.runtimeName} (${field.measure})`;
+    }
+
+    fieldKey(field: DataExplorerField): string {
+        return `${field.fullDbName}:${field.sourceIndex}`;
+    }
+}
diff --git a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.html b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.html
index fa8808c9e9..7b0a9e1772 100644
--- a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.html
+++ b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.html
@@ -31,86 +31,14 @@
         "
     >
     </sp-select-properties-config>
-    <sp-split-section [level]="3" [title]="'Column Order' | translate">
-        <div
-            fxLayout="column"
-            class="selected-column-list"
-            cdkDropList
-            [cdkDropListData]="
-                currentlyConfiguredWidget.visualizationConfig.selectedColumns
-            "
-            (cdkDropListDropped)="dropSelectedColumn($event)"
-        >
-            @for (
-                field of currentlyConfiguredWidget.visualizationConfig
-                    .selectedColumns;
-                track colorKey(field);
-                let i = $index
-            ) {
-                <div
-                    class="selected-column-row"
-                    fxLayout="row"
-                    fxLayoutAlign="space-between center"
-                    cdkDrag
-                >
-                    <div
-                        class="selected-column-main"
-                        fxLayout="row"
-                        fxLayoutAlign="start center"
-                    >
-                        <button
-                            mat-icon-button
-                            type="button"
-                            class="selected-column-drag-handle"
-                            cdkDragHandle
-                            [attr.aria-label]="
-                                'Reorder ' + field.runtimeName | translate
-                            "
-                        >
-                            <mat-icon>drag_indicator</mat-icon>
-                        </button>
-                        <div class="selected-column-label">
-                            {{ selectedColumnLabel(field) }}
-                        </div>
-                    </div>
-                    <div
-                        class="selected-column-actions"
-                        fxLayout="row"
-                        fxLayoutAlign="end center"
-                    >
-                        <button
-                            mat-icon-button
-                            type="button"
-                            [disabled]="!canMoveSelectedColumnUp(i)"
-                            [attr.aria-label]="
-                                'Move ' + field.runtimeName + ' up' | translate
-                            "
-                            (click)="moveSelectedColumn(i, -1)"
-                        >
-                            <mat-icon>arrow_upward</mat-icon>
-                        </button>
-                        <button
-                            mat-icon-button
-                            type="button"
-                            [disabled]="!canMoveSelectedColumnDown(i)"
-                            [attr.aria-label]="
-                                'Move ' + field.runtimeName + ' down'
-                                    | translate
-                            "
-                            (click)="moveSelectedColumn(i, 1)"
-                        >
-                            <mat-icon>arrow_downward</mat-icon>
-                        </button>
-                    </div>
-                </div>
-            } @empty {
-                <sp-alert-banner
-                    type="info"
-                    [title]="'No fields selected.' | translate"
-                ></sp-alert-banner>
-            }
-        </div>
-    </sp-split-section>
+    <sp-field-order-config
+        [sectionTitle]="'Column Order'"
+        [selectedFields]="
+            currentlyConfiguredWidget.visualizationConfig.selectedColumns
+        "
+        (selectedFieldsChange)="setSelectedColumnOrder($event)"
+    >
+    </sp-field-order-config>
     <sp-split-section [level]="3" [title]="'Settings' | translate">
         <sp-form-field [level]="3" [label]="'Filter' | translate">
             <mat-form-field fxFlex="100">
diff --git a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
index e926be0642..2bc4d58126 100644
--- a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
+++ b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
@@ -16,59 +16,6 @@
  *
  */
 
-.selected-column-list {
-    gap: var(--space-sm);
-}
-
-.selected-column-row {
-    gap: var(--space-sm);
-    padding: var(--space-xs) var(--space-sm);
-    border: 1px solid var(--color-border-subtle, var(--color-bg-3));
-    border-radius: var(--button-border-radius);
-    background: var(--color-bg-1, var(--color-bg-0));
-}
-
-.selected-column-main {
-    min-width: 0;
-    gap: var(--space-xs);
-    flex: 1;
-}
-
-.selected-column-drag-handle {
-    cursor: grab;
-    color: var(--color-secondary-text);
-}
-
-.selected-column-drag-handle:active {
-    cursor: grabbing;
-}
-
-.selected-column-label {
-    min-width: 0;
-    flex: 1;
-    font-size: var(--font-size-sm);
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-}
-
-.selected-column-actions {
-    flex-shrink: 0;
-    gap: var(--space-xxs);
-}
-
-.cdk-drag-preview {
-    box-sizing: border-box;
-    border-radius: var(--button-border-radius);
-    border: 1px solid var(--color-border-subtle, var(--color-bg-3));
-    background: var(--color-bg-1, var(--color-bg-0));
-    box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.18);
-}
-
-.cdk-drag-placeholder {
-    opacity: 0.35;
-}
-
 .highlight-color-list {
     gap: var(--space-sm);
 }
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 957db58dfc..6b31df9c59 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
@@ -39,15 +39,7 @@ import { TranslatePipe } from '@ngx-translate/core';
 import { MatOption, MatSelect } from '@angular/material/select';
 import { ColorPickerDirective } from 'ngx-color-picker';
 import { MatCheckbox } from '@angular/material/checkbox';
-import {
-    CdkDrag,
-    CdkDragHandle,
-    CdkDropList,
-    CdkDragDrop,
-    moveItemInArray,
-} from '@angular/cdk/drag-drop';
-import { MatIcon } from '@angular/material/icon';
-import { MatIconButton } from '@angular/material/button';
+import { FieldOrderConfigComponent } from '../../../chart-config/field-order-config/field-order-config.component';
 
 @Component({
     selector: 'sp-data-explorer-table-widget-config',
@@ -67,11 +59,7 @@ import { MatIconButton } from '@angular/material/button';
         LayoutDirective,
         LayoutAlignDirective,
         MatCheckbox,
-        CdkDropList,
-        CdkDrag,
-        CdkDragHandle,
-        MatIcon,
-        MatIconButton,
+        FieldOrderConfigComponent,
         TranslatePipe,
         FormFieldComponent,
         SpAlertBannerComponent,
@@ -104,41 +92,9 @@ export class TableWidgetConfigComponent extends BaseWidgetConfig<
         this.triggerViewRefresh();
     }
 
-    moveSelectedColumn(fromIndex: number, offset: number): void {
-        const columns = [
-            ...(this.currentlyConfiguredWidget.visualizationConfig
-                .selectedColumns ?? []),
-        ];
-        const targetIndex = fromIndex + offset;
-
-        if (
-            fromIndex < 0 ||
-            targetIndex < 0 ||
-            fromIndex >= columns.length ||
-            targetIndex >= columns.length
-        ) {
-            return;
-        }
-
-        const [movedColumn] = columns.splice(fromIndex, 1);
-        columns.splice(targetIndex, 0, movedColumn);
-        this.currentlyConfiguredWidget.visualizationConfig.selectedColumns =
-            columns;
-        this.triggerViewRefresh();
-    }
-
-    dropSelectedColumn(event: CdkDragDrop<DataExplorerField[]>): void {
-        if (event.previousIndex === event.currentIndex) {
-            return;
-        }
-
-        const columns = [
-            ...(this.currentlyConfiguredWidget.visualizationConfig
-                .selectedColumns ?? []),
-        ];
-        moveItemInArray(columns, event.previousIndex, event.currentIndex);
+    setSelectedColumnOrder(selectedColumns: DataExplorerField[]): void {
         this.currentlyConfiguredWidget.visualizationConfig.selectedColumns =
-            columns;
+            selectedColumns;
         this.triggerViewRefresh();
     }
 
@@ -247,23 +203,6 @@ export class TableWidgetConfigComponent extends BaseWidgetConfig<
         return true;
     }
 
-    canMoveSelectedColumnUp(index: number): boolean {
-        return index > 0;
-    }
-
-    canMoveSelectedColumnDown(index: number): boolean {
-        return (
-            index <
-            (this.currentlyConfiguredWidget.visualizationConfig.selectedColumns
-                ?.length ?? 0) -
-                1
-        );
-    }
-
-    selectedColumnLabel(field: DataExplorerField): string {
-        return `${field.runtimeName} (${field.measure})`;
-    }
-
     private syncHighlightColorMap(): void {
         const activeColorKeys = new Set(
             (
diff --git a/ui/src/app/chart-shared/components/charts/value-card/appearance-config/value-card-appearance-config.component.html b/ui/src/app/chart-shared/components/charts/value-card/appearance-config/value-card-appearance-config.component.html
new file mode 100644
index 0000000000..1e2c42bd62
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/value-card/appearance-config/value-card-appearance-config.component.html
@@ -0,0 +1,50 @@
+<!--
+  ~ 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]="'Card settings' | translate">
+    <sp-number-format-config [appearanceConfig]="appearanceConfig">
+    </sp-number-format-config>
+</sp-split-section>
+
+<sp-split-section [level]="3" [title]="'Typography' | translate">
+    <sp-form-field [level]="3" [label]="'Label font size' | translate">
+        <mat-form-field appearance="outline" color="accent" fxFlex="100">
+            <input
+                matInput
+                type="number"
+                min="1"
+                [ngModel]="config.labelFontSize"
+                (ngModelChange)="updateFontSize('labelFontSize', $event)"
+                placeholder="{{ 'Auto' | translate }}"
+            />
+        </mat-form-field>
+    </sp-form-field>
+
+    <sp-form-field [level]="3" [label]="'Value 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-split-section>
diff --git a/ui/src/app/chart-shared/components/charts/value-card/appearance-config/value-card-appearance-config.component.ts b/ui/src/app/chart-shared/components/charts/value-card/appearance-config/value-card-appearance-config.component.ts
new file mode 100644
index 0000000000..388438ace4
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/value-card/appearance-config/value-card-appearance-config.component.ts
@@ -0,0 +1,84 @@
+/*
+ * 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, inject } from '@angular/core';
+import { SplitSectionComponent } from '@streampipes/shared-ui';
+import { TranslatePipe } from '@ngx-translate/core';
+import { SpNumberFormatConfigComponent } from '../../../chart-config/number-format-config/number-format-config.component';
+import { ValueCardAppearanceConfig } from '../model/value-card-widget.model';
+import { FormFieldComponent } 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 { ChartConfigurationService } from '../../../../services/chart-configuration.service';
+
+@Component({
+    selector: 'sp-value-card-widget-appearance-config',
+    templateUrl: './value-card-appearance-config.component.html',
+    imports: [
+        SplitSectionComponent,
+        TranslatePipe,
+        SpNumberFormatConfigComponent,
+        FormFieldComponent,
+        MatFormField,
+        MatInput,
+        FormsModule,
+        FlexDirective,
+    ],
+})
+export class ValueCardWidgetAppearanceConfigComponent implements OnInit {
+    private widgetConfigurationService = inject(ChartConfigurationService);
+
+    @Input()
+    appearanceConfig: ValueCardAppearanceConfig;
+
+    ngOnInit(): void {
+        this.ensureAppearanceConfig();
+    }
+
+    get config(): ValueCardAppearanceConfig {
+        return this.ensureAppearanceConfig();
+    }
+
+    updateFontSize(
+        key: 'labelFontSize' | 'valueFontSize',
+        value: number | null,
+    ): void {
+        this.config[key] =
+            value === null || value === undefined || value <= 0
+                ? undefined
+                : value;
+
+        this.widgetConfigurationService.notify({
+            refreshView: true,
+            refreshData: false,
+        });
+    }
+
+    private ensureAppearanceConfig(): ValueCardAppearanceConfig {
+        this.appearanceConfig ??= {
+            backgroundColor: 'var(--color-bg-0)',
+            textColor: 'var(--color-default-text)',
+            widgetTitle: '',
+            numberFormat: {},
+        };
+
+        return this.appearanceConfig;
+    }
+}
diff --git a/ui/src/app/chart-shared/components/charts/value-card/config/value-card-widget-config.component.html b/ui/src/app/chart-shared/components/charts/value-card/config/value-card-widget-config.component.html
new file mode 100644
index 0000000000..6f5acf1961
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/value-card/config/value-card-widget-config.component.html
@@ -0,0 +1,86 @@
+<!--
+  ~ 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-visualization-config-outer
+    [configurationValid]="
+        currentlyConfiguredWidget.visualizationConfig.configurationValid
+    "
+>
+    <sp-select-properties-config
+        [title]="'Fields' | translate"
+        [availableProperties]="fieldProvider.allFields"
+        [selectedProperties]="
+            currentlyConfiguredWidget.visualizationConfig.selectedFields
+        "
+        (changeSelectedProperties)="updateSelectedFields($event)"
+    >
+    </sp-select-properties-config>
+
+    <sp-field-order-config
+        [sectionTitle]="'Field Order'"
+        [selectedFields]="
+            currentlyConfiguredWidget.visualizationConfig.selectedFields
+        "
+        (selectedFieldsChange)="setSelectedFieldOrder($event)"
+    >
+    </sp-field-order-config>
+
+    <sp-split-section [level]="3" [title]="'Content' | translate">
+        <sp-form-field [level]="3" [label]="'Timestamp' | translate">
+            <mat-checkbox
+                [checked]="
+                    currentlyConfiguredWidget.visualizationConfig.showTimestamp
+                "
+                (change)="
+                    currentlyConfiguredWidget.visualizationConfig.showTimestamp =
+                        $event.checked;
+                    triggerViewRefresh()
+                "
+            >
+                {{ 'Show latest time above card' | translate }}
+            </mat-checkbox>
+        </sp-form-field>
+
+        <sp-form-field [level]="3" [label]="'Title' | translate">
+            <mat-form-field fxFlex="100">
+                <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
+                    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/value-card/config/value-card-widget-config.component.ts b/ui/src/app/chart-shared/components/charts/value-card/config/value-card-widget-config.component.ts
new file mode 100644
index 0000000000..3fec8dee87
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/value-card/config/value-card-widget-config.component.ts
@@ -0,0 +1,133 @@
+/*
+ * 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 } from '@angular/core';
+import { BaseWidgetConfig } from '../../base/base-widget-config';
+import {
+    ValueCardVisConfig,
+    ValueCardWidgetModel,
+} from '../model/value-card-widget.model';
+import { DataExplorerField } from '@streampipes/platform-services';
+import { SpVisualizationConfigOuterComponent } from '../../../chart-config/visualization-config-outer/visualization-config-outer.component';
+import { SelectMultiplePropertiesConfigComponent } from '../../../chart-config/select-multiple-properties-config/select-multiple-properties-config.component';
+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';
+import { FieldOrderConfigComponent } from '../../../chart-config/field-order-config/field-order-config.component';
+import { MatCheckbox } from '@angular/material/checkbox';
+
+@Component({
+    selector: 'sp-data-explorer-value-card-widget-config',
+    templateUrl: './value-card-widget-config.component.html',
+    imports: [
+        SpVisualizationConfigOuterComponent,
+        SplitSectionComponent,
+        SelectMultiplePropertiesConfigComponent,
+        FormFieldComponent,
+        FieldOrderConfigComponent,
+        MatFormField,
+        MatInput,
+        MatCheckbox,
+        FlexDirective,
+        FormsModule,
+        TranslatePipe,
+    ],
+})
+export class ValueCardWidgetConfigComponent extends BaseWidgetConfig<
+    ValueCardWidgetModel,
+    ValueCardVisConfig
+> {
+    updateSelectedFields(fields: DataExplorerField[]): void {
+        this.currentlyConfiguredWidget.visualizationConfig.selectedFields =
+            this.mergeSelectedFieldOrder(fields);
+        this.triggerViewRefresh();
+    }
+
+    setSelectedFieldOrder(fields: DataExplorerField[]): void {
+        this.currentlyConfiguredWidget.visualizationConfig.selectedFields =
+            fields;
+        this.triggerViewRefresh();
+    }
+
+    protected applyWidgetConfig(config: ValueCardVisConfig): void {
+        config.selectedFields = this.getCompatibleSelectedFields(
+            config.selectedFields,
+        );
+        config.title ??= '';
+        config.description ??= '';
+        config.showTimestamp ??= false;
+    }
+
+    protected requiredFieldsForChartPresent(): boolean {
+        return this.fieldProvider.allFields.length > 0;
+    }
+
+    private getCompatibleSelectedFields(
+        selectedFields?: DataExplorerField[],
+    ): DataExplorerField[] {
+        const availableFields = this.fieldProvider.allFields;
+
+        if (!selectedFields?.length) {
+            return availableFields.slice(
+                0,
+                Math.min(4, availableFields.length),
+            );
+        }
+
+        return selectedFields.filter(selectedField =>
+            availableFields.find(
+                availableField =>
+                    availableField.fullDbName === selectedField.fullDbName &&
+                    availableField.sourceIndex === selectedField.sourceIndex,
+            ),
+        );
+    }
+
+    private mergeSelectedFieldOrder(
+        nextSelectedFields: DataExplorerField[],
+    ): DataExplorerField[] {
+        const currentSelectedFields =
+            this.currentlyConfiguredWidget.visualizationConfig.selectedFields ??
+            [];
+
+        const retainedFields = currentSelectedFields.filter(currentField =>
+            nextSelectedFields.some(nextField =>
+                this.isSameField(currentField, nextField),
+            ),
+        );
+
+        const newlyAddedFields = nextSelectedFields.filter(
+            nextField =>
+                !currentSelectedFields.some(currentField =>
+                    this.isSameField(currentField, nextField),
+                ),
+        );
+
+        return [...retainedFields, ...newlyAddedFields];
+    }
+
+    private isSameField(a: DataExplorerField, b: DataExplorerField): boolean {
+        return a.fullDbName === b.fullDbName && a.sourceIndex === b.sourceIndex;
+    }
+}
diff --git a/ui/src/app/chart-shared/components/charts/value-card/model/value-card-widget.model.ts b/ui/src/app/chart-shared/components/charts/value-card/model/value-card-widget.model.ts
new file mode 100644
index 0000000000..d75be279f3
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/value-card/model/value-card-widget.model.ts
@@ -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.
+ *
+ */
+
+import {
+    DataExplorerDataConfig,
+    DataExplorerField,
+    DataExplorerWidgetModel,
+} from '@streampipes/platform-services';
+import {
+    DataExplorerVisConfig,
+    WidgetNumberAppearanceConfig,
+} from '../../../../models/dataview-dashboard.model';
+
+export interface ValueCardVisConfig extends DataExplorerVisConfig {
+    selectedFields?: DataExplorerField[];
+    title?: string;
+    description?: string;
+    showTimestamp?: boolean;
+}
+
+export interface ValueCardAppearanceConfig extends WidgetNumberAppearanceConfig {
+    labelFontSize?: number;
+    valueFontSize?: number;
+}
+
+export interface ValueCardWidgetModel extends DataExplorerWidgetModel {
+    dataConfig: DataExplorerDataConfig;
+    visualizationConfig: ValueCardVisConfig;
+    baseAppearanceConfig: ValueCardAppearanceConfig;
+}
diff --git a/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.html b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.html
new file mode 100644
index 0000000000..943033f6e4
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.html
@@ -0,0 +1,91 @@
+<!--
+  ~ 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="value-card-widget"
+    fxLayout="column"
+    fxFlex="100"
+    [ngStyle]="widgetStyles"
+    data-cy="value-card-widget"
+>
+    @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="value-card-shell" fxLayout="column" fxFlex="100">
+            @if (titleText || descriptionText) {
+                <div class="value-card-copy" fxLayout="column">
+                    @if (titleText) {
+                        <div class="value-card-title">
+                            {{ titleText }}
+                        </div>
+                    }
+
+                    @if (descriptionText) {
+                        <div class="value-card-description">
+                            {{ descriptionText }}
+                        </div>
+                    }
+                </div>
+            }
+
+            @if (showTimestamp) {
+                <div class="value-card-timestamp">
+                    {{ latestTimestampLabel }}
+                </div>
+            }
+
+            <article class="value-card-panel" fxFlex>
+                <div
+                    class="value-card-list"
+                    [style.--value-card-list-columns]="valueListColumns"
+                >
+                    @for (card of valueCards; track trackCard($index, card)) {
+                        <section class="value-card-item">
+                            <div class="value-card-item-label">
+                                {{ card.label }}
+                            </div>
+                            <div class="value-card-item-value">
+                                {{ card.value }}
+                            </div>
+                        </section>
+                    }
+                </div>
+            </article>
+        </div>
+    }
+</div>
diff --git a/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.scss b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.scss
new file mode 100644
index 0000000000..a7c09985e7
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.scss
@@ -0,0 +1,117 @@
+/*
+ * 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%;
+}
+
+.value-card-widget {
+    box-sizing: border-box;
+    height: 100%;
+    min-height: 0;
+    overflow: hidden;
+    padding: var(--value-card-padding);
+}
+
+.value-card-shell {
+    gap: var(--value-card-gap);
+    min-height: 0;
+}
+
+.value-card-copy {
+    gap: calc(var(--value-card-gap) * 0.45);
+}
+
+.value-card-title {
+    font-size: var(--value-card-title-size);
+    font-weight: 700;
+    letter-spacing: -0.03em;
+    line-height: 1.08;
+}
+
+.value-card-description {
+    color: var(--value-card-muted-color);
+    font-size: var(--value-card-description-size);
+    line-height: 1.35;
+    max-width: 48ch;
+}
+
+.value-card-timestamp {
+    color: var(--value-card-muted-color);
+    font-size: 0.82rem;
+    font-weight: 600;
+    letter-spacing: 0.02em;
+    line-height: 1.2;
+}
+
+.value-card-panel {
+    background: var(--value-card-panel-background);
+    border: 1px solid var(--value-card-border-color);
+    border-radius: 5px;
+    min-height: 0;
+    overflow: hidden;
+    padding: 1.1rem 1.2rem;
+}
+
+.value-card-list {
+    display: grid;
+    gap: 0;
+    grid-template-columns: repeat(
+        var(--value-card-list-columns),
+        minmax(0, 1fr)
+    );
+    min-height: 100%;
+}
+
+.value-card-item {
+    align-items: start;
+    border-bottom: 1px solid var(--value-card-divider-color);
+    display: flex;
+    flex-direction: column;
+    gap: var(--value-card-row-gap);
+    min-height: 0;
+    padding: var(--value-card-row-padding) 0;
+}
+
+.value-card-item-label {
+    color: var(--value-card-muted-color);
+    font-size: var(--value-card-label-size);
+    font-weight: 600;
+    line-height: 1.3;
+    min-width: 0;
+    overflow-wrap: anywhere;
+}
+
+.value-card-item-value {
+    font-size: var(--value-card-value-size);
+    font-weight: 700;
+    letter-spacing: -0.04em;
+    line-height: 0.98;
+    min-width: 0;
+    overflow-wrap: anywhere;
+    text-align: left;
+}
+
+.value-card-item:last-child {
+    border-bottom: 0;
+}
diff --git a/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.ts b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.ts
new file mode 100644
index 0000000000..3816e58927
--- /dev/null
+++ b/ui/src/app/chart-shared/components/charts/value-card/value-card-widget.component.ts
@@ -0,0 +1,388 @@
+/*
+ * 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, LOCALE_ID, OnInit, inject } from '@angular/core';
+import {
+    DataExplorerField,
+    SpQueryResult,
+} from '@streampipes/platform-services';
+import { FlexDirective, LayoutDirective } from '@ngbracket/ngx-layout/flex';
+import { BaseDataExplorerWidgetDirective } from '../base/base-data-explorer-widget.directive';
+import { NoDataInDateRangeComponent } from '../base/no-data/no-data-in-date-range.component';
+import { TooMuchDataComponent } from '../base/too-much-data/too-much-data.component';
+import { SpInvalidConfigurationComponent } from '../base/invalid-configuration/invalid-configuration.component';
+import { ChartRegistry } from '../../../registry/chart-registry.service';
+import {
+    ValueCardAppearanceConfig,
+    ValueCardVisConfig,
+    ValueCardWidgetModel,
+} from './model/value-card-widget.model';
+import { ResultLabelService } from '../../../services/result-label.service';
+
+interface ValueCardView {
+    id: string;
+    label: string;
+    value: string;
+}
+
+@Component({
+    selector: 'sp-data-explorer-value-card-widget',
+    templateUrl: './value-card-widget.component.html',
+    styleUrls: ['./value-card-widget.component.scss'],
+    imports: [
+        LayoutDirective,
+        FlexDirective,
+        NgStyle,
+        NoDataInDateRangeComponent,
+        TooMuchDataComponent,
+        SpInvalidConfigurationComponent,
+    ],
+})
+export class ValueCardWidgetComponent
+    extends BaseDataExplorerWidgetDirective<ValueCardWidgetModel>
+    implements OnInit
+{
+    valueCards: ValueCardView[] = [];
+    widgetTypeLabel: string;
+    latestTimestampLabel?: string;
+
+    private hasReceivedData = false;
+    private latestData: SpQueryResult[] = [];
+    private readonly locale = inject(LOCALE_ID);
+    private readonly chartRegistry = inject(ChartRegistry);
+    private readonly resultLabelService = inject(ResultLabelService);
+
+    ngOnInit(): void {
+        super.ngOnInit();
+        this.widgetTypeLabel = this.chartRegistry.getChartTemplate(
+            this.dataExplorerWidget.widgetType,
+        ).label;
+    }
+
+    get appearanceConfig(): ValueCardAppearanceConfig {
+        this.dataExplorerWidget.baseAppearanceConfig ??= {
+            backgroundColor: 'var(--color-bg-0)',
+            textColor: 'var(--color-default-text)',
+            widgetTitle: '',
+            numberFormat: {},
+        };
+
+        return this.dataExplorerWidget
+            .baseAppearanceConfig as ValueCardAppearanceConfig;
+    }
+
+    get widgetStyles(): Record<string, string> {
+        const compactMode =
+            (this.currentWidth ?? 0) < 640 || this.hasManyValues;
+        const fontScale = this.getFontScale();
+
+        return {
+            'background': this.appearanceConfig.backgroundColor,
+            'color': this.appearanceConfig.textColor,
+            '--value-card-padding': compactMode ? '14px' : '20px',
+            '--value-card-gap': compactMode ? '12px' : '18px',
+            '--value-card-title-size': compactMode ? '18px' : '24px',
+            '--value-card-description-size': compactMode ? '12px' : '14px',
+            '--value-card-row-padding': `${this.scaleValue(12, 22, fontScale)}px`,
+            '--value-card-label-size': `${this.resolveFontSize(
+                this.appearanceConfig.labelFontSize,
+                11,
+                16,
+                fontScale,
+            )}px`,
+            '--value-card-value-size': `${this.resolveFontSize(
+                this.appearanceConfig.valueFontSize,
+                20,
+                42,
+                fontScale,
+            )}px`,
+            '--value-card-row-gap': `${this.scaleValue(4, 10, fontScale)}px`,
+            '--value-card-border-color':
+                'color-mix(in srgb, currentColor 10%, transparent)',
+            '--value-card-divider-color':
+                'color-mix(in srgb, currentColor 8%, transparent)',
+            '--value-card-muted-color':
+                'color-mix(in srgb, currentColor 68%, transparent)',
+            '--value-card-panel-background':
+                'color-mix(in srgb, currentColor 6%, transparent)',
+        };
+    }
+
+    get content(): ValueCardVisConfig {
+        return this.dataExplorerWidget.visualizationConfig;
+    }
+
+    get titleText(): string {
+        return this.content.title?.trim() ?? '';
+    }
+
+    get descriptionText(): string {
+        return this.content.description?.trim() ?? '';
+    }
+
+    get showTimestamp(): boolean {
+        return !!this.content.showTimestamp;
+    }
+
+    get hasManyValues(): boolean {
+        return this.valueCards.length > 3;
+    }
+
+    beforeDataFetched(): void {
+        this.setShownComponents(false, false, true, false);
+    }
+
+    onDataReceived(spQueryResults: SpQueryResult[]): void {
+        this.hasReceivedData = true;
+        this.latestData = spQueryResults;
+        this.refreshView();
+    }
+
+    onResize(_width: number, _height: number): void {
+        this.refreshView();
+    }
+
+    refreshView(): void {
+        this.updateCards();
+    }
+
+    handleUpdatedFields(
+        addedFields: DataExplorerField[],
+        removedFields: DataExplorerField[],
+    ): void {
+        const currentSelection = this.content.selectedFields ?? [];
+        this.content.selectedFields = currentSelection
+            .filter(
+                field =>
+                    !removedFields.find(
+                        removedField =>
+                            removedField.fullDbName === field.fullDbName &&
+                            removedField.sourceIndex === field.sourceIndex,
+                    ),
+            )
+            .concat(
+                addedFields.filter(
+                    field =>
+                        !currentSelection.find(
+                            currentField =>
+                                currentField.fullDbName === field.fullDbName &&
+                                currentField.sourceIndex === field.sourceIndex,
+                        ),
+                ),
+            );
+        this.refreshView();
+    }
+
+    trackCard(_index: number, card: ValueCardView): string {
+        return card.id;
+    }
+
+    private updateCards(): void {
+        if (this.content.configurationValid === false) {
+            this.showInvalidConfiguration = true;
+            this.valueCards = [];
+            this.setShownComponents(false, false, false, false);
+            return;
+        }
+
+        this.showInvalidConfiguration = false;
+        if (!this.hasReceivedData && this.latestData.length === 0) {
+            return;
+        }
+
+        this.latestTimestampLabel = this.getLatestTimestampLabel();
+        const selectedFields = this.content.selectedFields ?? [];
+        this.valueCards = selectedFields
+            .map(field => this.makeCard(field))
+            .filter((card): card is ValueCardView => !!card);
+
+        if (this.valueCards.length === 0) {
+            this.setShownComponents(true, false, false, false);
+            return;
+        }
+
+        this.setShownComponents(false, true, false, false);
+    }
+
+    private makeCard(field: DataExplorerField): ValueCardView | undefined {
+        const sourceResult = this.latestData[field.sourceIndex];
+
+        if (!sourceResult?.headers?.length) {
+            return undefined;
+        }
+
+        const fieldIndex = sourceResult.headers.findIndex(
+            header => header === field.fullDbName,
+        );
+
+        if (fieldIndex < 0) {
+            return undefined;
+        }
+
+        const firstRow = this.getFirstRow(sourceResult);
+        if (!firstRow) {
+            return undefined;
+        }
+
+        const queryConfig =
+            this.dataExplorerWidget.dataConfig.sourceConfigs[field.sourceIndex]
+                ?.queryConfig;
+
+        return {
+            id: `${field.sourceIndex}-${field.fullDbName}`,
+            label: queryConfig
+                ? this.resultLabelService.resolveLabel(queryConfig, field)
+                : (field.runtimeName ?? field.fullDbName),
+            value: this.formatValue(firstRow[fieldIndex]),
+        };
+    }
+
+    private getFirstRow(queryResult: SpQueryResult): unknown[] | undefined {
+        return queryResult.allDataSeries.find(series => series.rows.length > 0)
+            ?.rows[0];
+    }
+
+    private formatValue(value: unknown): string {
+        if (value === null || value === undefined || value === '') {
+            return '-';
+        }
+
+        if (typeof value === 'number' && Number.isFinite(value)) {
+            const decimals = this.normalizeDecimals(
+                this.appearanceConfig.numberFormat?.decimals,
+            );
+
+            return new Intl.NumberFormat(this.locale, {
+                maximumFractionDigits: decimals ?? 20,
+                minimumFractionDigits: decimals ?? 0,
+            }).format(value);
+        }
+
+        if (typeof value === 'boolean') {
+            return value ? 'True' : 'False';
+        }
+
+        return String(value);
+    }
+
+    private normalizeDecimals(decimals: unknown): number | undefined {
+        if (decimals === null || decimals === undefined || decimals === '') {
+            return undefined;
+        }
+
+        const parsedValue = Number(decimals);
+        if (!Number.isFinite(parsedValue)) {
+            return undefined;
+        }
+
+        return Math.min(10, Math.max(0, Math.round(parsedValue)));
+    }
+
+    private getLatestTimestampLabel(): string | undefined {
+        const firstQueryResult = this.latestData.find(
+            queryResult => !!this.getFirstRow(queryResult),
+        );
+
+        if (!firstQueryResult) {
+            return undefined;
+        }
+
+        const firstTimestamp = this.getFirstTimestamp(firstQueryResult);
+        if (firstTimestamp === undefined) {
+            return undefined;
+        }
+
+        return new Intl.DateTimeFormat(this.locale, {
+            dateStyle: 'medium',
+            timeStyle: 'medium',
+        }).format(new Date(firstTimestamp));
+    }
+
+    private getFirstTimestamp(queryResult: SpQueryResult): number | undefined {
+        const timeIndex = queryResult.headers.findIndex(
+            header => header === 'time',
+        );
+
+        if (timeIndex < 0) {
+            return undefined;
+        }
+
+        const firstRow = this.getFirstRow(queryResult);
+        if (!firstRow) {
+            return undefined;
+        }
+
+        const firstTimestamp = new Date(
+            firstRow[timeIndex] as string,
+        ).getTime();
+        return Number.isFinite(firstTimestamp) ? firstTimestamp : undefined;
+    }
+
+    private getFontScale(): number {
+        const width = this.currentWidth ?? 0;
+        const height = this.currentHeight ?? 0;
+        const rowCount = Math.max(this.valueCards.length, 1);
+
+        if (width <= 0 || height <= 0) {
+            return 0.8;
+        }
+
+        const widthScale = this.clamp(width / 640, 0.7, 1.45);
+        const availableHeight = Math.max(height - 110, 120);
+        const heightPerRowScale = this.clamp(
+            availableHeight / (rowCount * 84),
+            0.7,
+            1.45,
+        );
+
+        return this.clamp(Math.min(widthScale, heightPerRowScale), 0.7, 1.45);
+    }
+
+    private scaleValue(min: number, max: number, scale: number): number {
+        const normalizedScale = (scale - 0.7) / 0.75;
+        const scaledValue = min + (max - min) * normalizedScale;
+        return Math.round(this.clamp(scaledValue, min, max));
+    }
+
+    private resolveFontSize(
+        manualSize: number | undefined,
+        min: number,
+        max: number,
+        scale: number,
+    ): number {
+        if (
+            manualSize === undefined ||
+            manualSize === null ||
+            Number.isNaN(Number(manualSize)) ||
+            manualSize <= 0
+        ) {
+            return this.scaleValue(min, max, scale);
+        }
+
+        return Math.round(Math.max(1, manualSize) * scale * 10) / 10;
+    }
+
+    private clamp(value: number, min: number, max: number): number {
+        return Math.min(Math.max(value, min), max);
+    }
+
+    get valueListColumns(): number {
+        return 1;
+    }
+}
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 87165d5c2f..b2438b7926 100644
--- a/ui/src/app/chart-shared/registry/chart-registry.service.ts
+++ b/ui/src/app/chart-shared/registry/chart-registry.service.ts
@@ -61,6 +61,9 @@ import { StatusWidgetConfigComponent } from '../components/charts/status/config/
 import { StatusWidgetComponent } from '../components/charts/status/status-widget.component';
 import { IndicatorWidgetComponent } from '../components/charts/indicator/indicator-widget.component';
 import { TranslateService } from '@ngx-translate/core';
+import { ValueCardWidgetComponent } from '../components/charts/value-card/value-card-widget.component';
+import { ValueCardWidgetConfigComponent } from '../components/charts/value-card/config/value-card-widget-config.component';
+import { ValueCardWidgetAppearanceConfigComponent } from '../components/charts/value-card/appearance-config/value-card-appearance-config.component';
 
 @Injectable({ providedIn: 'root' })
 export class ChartRegistry {
@@ -185,6 +188,18 @@ export class ChartRegistry {
                 icon: 'image',
                 description: this.translateService.instant('Display an image'),
             },
+            {
+                id: 'value-card',
+                label: this.translateService.instant('Value Card'),
+                widgetAppearanceConfigurationComponent:
+                    ValueCardWidgetAppearanceConfigComponent,
+                widgetConfigurationComponent: ValueCardWidgetConfigComponent,
+                widgetComponent: ValueCardWidgetComponent,
+                icon: 'view_agenda',
+                description: this.translateService.instant(
+                    'Display the latest values of multiple selected fields in a single card',
+                ),
+            },
             {
                 id: 'indicator-chart',
                 label: this.translateService.instant('Indicator'),