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'),
