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