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 <[email protected]>
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;
+}

Reply via email to