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 <[email protected]>
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'),

Reply via email to