This is an automated email from the ASF dual-hosted git repository.
riemer pushed a commit to branch improve-chart-types
in repository https://gitbox.apache.org/repos/asf/streampipes.git
The following commit(s) were added to refs/heads/improve-chart-types by this
push:
new c3d9cca765 Extend configuration options of gauge and pie
c3d9cca765 is described below
commit c3d9cca765085802376dbdc255b57ad451610148
Author: Dominik Riemer <[email protected]>
AuthorDate: Tue Mar 24 20:56:54 2026 +0100
Extend configuration options of gauge and pie
---
.../config/gauge-widget-config.component.html | 219 ++++++++++++++++++-
.../gauge/config/gauge-widget-config.component.ts | 24 +++
.../charts/gauge/gauge-renderer.service.ts | 131 +++++++++++-
.../charts/gauge/model/gauge-widget.model.ts | 10 +
.../config/pie-chart-widget-config.component.html | 234 ++++++++++++++++++++-
.../config/pie-chart-widget-config.component.ts | 17 ++
.../charts/pie/model/pie-chart-widget.model.ts | 17 ++
.../components/charts/pie/pie-renderer.service.ts | 164 ++++++++++++---
.../echarts-transform/pie-aggregate.transform.ts | 116 ++++++++++
ui/src/main.ts | 2 +
10 files changed, 887 insertions(+), 47 deletions(-)
diff --git
a/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.html
b/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.html
index 1bb9127f40..389465b05c 100644
---
a/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.html
+++
b/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.html
@@ -36,8 +36,14 @@
?.fieldCharacteristics.numeric
) {
<sp-split-section [level]="3" [title]="'Settings' | translate">
- <sp-form-field [level]="3" [label]="'Min' | translate">
- <mat-form-field appearance="outline" color="accent"
fxFlex="30">
+ <sp-form-field
+ [level]="3"
+ [label]="'Min' | translate"
+ [description]="
+ 'Lower bound of the gauge value range.' | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
<input
matInput
type="number"
@@ -48,8 +54,14 @@
/>
</mat-form-field>
</sp-form-field>
- <sp-form-field [level]="3" [label]="'Max' | translate">
- <mat-form-field appearance="outline" color="accent"
fxFlex="30">
+ <sp-form-field
+ [level]="3"
+ [label]="'Max' | translate"
+ [description]="
+ 'Upper bound of the gauge value range.' | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
<input
matInput
type="number"
@@ -60,8 +72,14 @@
/>
</mat-form-field>
</sp-form-field>
- <sp-form-field [level]="3" [label]="'Display Name' | translate">
- <mat-form-field appearance="outline" color="accent"
fxFlex="60">
+ <sp-form-field
+ [level]="3"
+ [label]="'Display Name' | translate"
+ [description]="
+ 'Label shown below the current gauge value.' | translate
+ "
+ >
+ <mat-form-field fxFlex="60">
<input
matInput
type="text"
@@ -73,6 +91,195 @@
/>
</mat-form-field>
</sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'Start Angle' | translate"
+ [description]="
+ 'Angle where the gauge arc begins (in degrees).' |
translate
+ "
+ >
+ <mat-form-field fxFlex="30">
+ <input
+ matInput
+ type="number"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .startAngle
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'End Angle' | translate"
+ [description]="
+ 'Angle where the gauge arc ends (in degrees).' | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
+ <input
+ matInput
+ type="number"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .endAngle
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'Split Number' | translate"
+ [description]="
+ 'Number of major segments/ticks on the gauge arc.'
+ | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
+ <input
+ matInput
+ type="number"
+ min="1"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .splitNumber
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <mat-checkbox
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig.showPointer
+ "
+ (change)="triggerViewRefresh()"
+ >{{ 'Show Pointer' | translate }}
+ </mat-checkbox>
+ </sp-split-section>
+
+ <sp-split-section [level]="3" [title]="'Threshold Colors' | translate">
+ <mat-checkbox
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .enableThresholdColors
+ "
+ (change)="triggerViewRefresh()"
+ >{{ 'Enable threshold colors' | translate }}
+ </mat-checkbox>
+
+ @if (
+ currentlyConfiguredWidget.visualizationConfig
+ .enableThresholdColors
+ ) {
+ <sp-form-field
+ [level]="3"
+ [label]="'Low Threshold' | translate"
+ [description]="
+ 'Value where the low color range ends.' | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
+ <input
+ matInput
+ type="number"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .thresholdLow
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'High Threshold' | translate"
+ [description]="
+ 'Value where the medium color range ends and high
begins.'
+ | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
+ <input
+ matInput
+ type="number"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .thresholdHigh
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'Low Color' | translate"
+ [description]="
+ 'Color used from min up to the low threshold.'
+ | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
+ <input
+ matInput
+ type="color"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .thresholdColorLow
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'Medium Color' | translate"
+ [description]="
+ 'Color used between low and high thresholds.'
+ | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
+ <input
+ matInput
+ type="color"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .thresholdColorMedium
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'High Color' | translate"
+ [description]="
+ 'Color used above the high threshold.' | translate
+ "
+ >
+ <mat-form-field fxFlex="30">
+ <input
+ matInput
+ type="color"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .thresholdColorHigh
+ "
+ (ngModelChange)="triggerViewRefresh()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+ }
</sp-split-section>
}
</sp-visualization-config-outer>
diff --git
a/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.ts
b/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.ts
index 1643f1aebf..5caf7bc0d1 100644
---
a/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.ts
+++
b/ui/src/app/chart-shared/components/charts/gauge/config/gauge-widget-config.component.ts
@@ -31,6 +31,7 @@ import { FlexDirective } from '@ngbracket/ngx-layout/flex';
import { MatInput } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '@ngx-translate/core';
+import { MatCheckbox } from '@angular/material/checkbox';
@Component({
selector: 'sp-data-explorer-gauge-widget-config',
@@ -43,6 +44,7 @@ import { TranslatePipe } from '@ngx-translate/core';
MatFormField,
FlexDirective,
MatInput,
+ MatCheckbox,
FormsModule,
TranslatePipe,
],
@@ -63,8 +65,30 @@ export class GaugeWidgetConfigComponent extends
BaseWidgetConfig<
this.fieldProvider.numericFields,
() => this.fieldProvider.numericFields[0],
);
+ const defaultDisplayName =
+ config.selectedProperty?.runtimeName ||
+ config.selectedProperty?.fullDbName ||
+ '';
+ if (typeof config.displayName !== 'string') {
+ config.displayName = defaultDisplayName;
+ }
+ if (!config.displayName?.trim()) {
+ config.displayName = defaultDisplayName;
+ }
config.min ??= 0;
config.max ??= 100;
+ config.startAngle ??= 225;
+ config.endAngle ??= -45;
+ config.splitNumber ??= 10;
+ config.showPointer ??= true;
+ config.enableThresholdColors ??= false;
+ config.thresholdColorLow ??= '#91cc75';
+ config.thresholdColorMedium ??= '#fac858';
+ config.thresholdColorHigh ??= '#ee6666';
+
+ const range = Math.max(1, config.max - config.min);
+ config.thresholdLow ??= config.min + range * 0.6;
+ config.thresholdHigh ??= config.min + range * 0.8;
}
protected requiredFieldsForChartPresent(): boolean {
diff --git
a/ui/src/app/chart-shared/components/charts/gauge/gauge-renderer.service.ts
b/ui/src/app/chart-shared/components/charts/gauge/gauge-renderer.service.ts
index 263c62c961..4124342e62 100644
--- a/ui/src/app/chart-shared/components/charts/gauge/gauge-renderer.service.ts
+++ b/ui/src/app/chart-shared/components/charts/gauge/gauge-renderer.service.ts
@@ -17,7 +17,7 @@
*/
import { inject, Injectable } from '@angular/core';
-import { GaugeWidgetModel } from './model/gauge-widget.model';
+import { GaugeVisConfig, GaugeWidgetModel } from './model/gauge-widget.model';
import { EChartsOption, GaugeSeriesOption } from 'echarts';
import { FieldUpdateInfo } from '../../../models/field-update.model';
import {
@@ -51,11 +51,23 @@ export class SpGaugeRendererService implements
SpEchartsRenderer<GaugeWidgetMode
const visConfig = widgetConfig.visualizationConfig;
const minDimension = Math.min(widgetSize.width, widgetSize.height);
const clamp = Math.min(Math.max(minDimension / 320, 0.7), 1.4);
- return {
+ const useThresholdColors = !!visConfig.enableThresholdColors;
+ const displayName = this.makeDisplayName(
+ visConfig.displayName,
+ fieldName,
+ );
+
+ const series: GaugeSeriesOption = {
name: seriesName,
type: 'gauge',
center: ['50%', gaugeLayout.centerY],
radius: gaugeLayout.radius,
+ startAngle: this.toFiniteNumber(visConfig.startAngle, 225),
+ endAngle: this.toFiniteNumber(visConfig.endAngle, -45),
+ splitNumber: this.normalizeSplitNumber(visConfig.splitNumber),
+ pointer: {
+ show: visConfig.showPointer,
+ },
progress: {
show: true,
},
@@ -75,10 +87,28 @@ export class SpGaugeRendererService implements
SpEchartsRenderer<GaugeWidgetMode
data: [
{
value: value,
- name: visConfig.displayName ?? fieldName,
+ name: displayName,
},
],
};
+
+ if (useThresholdColors) {
+ const thresholdSegments = this.makeThresholdSegments(visConfig);
+ const progressColor = this.getProgressColor(value, visConfig);
+ series.progress = {
+ ...series.progress,
+ itemStyle: {
+ color: progressColor,
+ },
+ };
+ series.axisLine = {
+ lineStyle: {
+ color: thresholdSegments,
+ },
+ };
+ }
+
+ return series;
}
getSelectedField(widgetConfig: GaugeWidgetModel): DataExplorerField {
@@ -186,6 +216,101 @@ export class SpGaugeRendererService implements
SpEchartsRenderer<GaugeWidgetMode
detailOffsetY,
};
}
+
+ private makeThresholdSegments(
+ visConfig: GaugeVisConfig,
+ ): Array<[number, string]> {
+ const normalizedThresholds = this.normalizeThresholds(visConfig);
+ const lowRatio =
+ (normalizedThresholds.low - normalizedThresholds.min) /
+ normalizedThresholds.range;
+ const highRatio =
+ (normalizedThresholds.high - normalizedThresholds.min) /
+ normalizedThresholds.range;
+
+ return [
+ [this.clamp(lowRatio, 0, 1), this.getLowColor(visConfig)],
+ [this.clamp(highRatio, 0, 1), this.getMediumColor(visConfig)],
+ [1, this.getHighColor(visConfig)],
+ ];
+ }
+
+ private getProgressColor(value: number, visConfig: GaugeVisConfig): string
{
+ const normalizedThresholds = this.normalizeThresholds(visConfig);
+ if (value <= normalizedThresholds.low) {
+ return this.getLowColor(visConfig);
+ }
+
+ if (value <= normalizedThresholds.high) {
+ return this.getMediumColor(visConfig);
+ }
+
+ return this.getHighColor(visConfig);
+ }
+
+ private normalizeThresholds(visConfig: GaugeVisConfig): {
+ min: number;
+ max: number;
+ range: number;
+ low: number;
+ high: number;
+ } {
+ const min = this.toFiniteNumber(visConfig.min, 0);
+ const configMax = this.toFiniteNumber(visConfig.max, min + 1);
+ const max = configMax > min ? configMax : min + 1;
+ const range = max - min;
+
+ const lowDefault = min + range * 0.6;
+ const highDefault = min + range * 0.8;
+
+ const low = this.clamp(
+ this.toFiniteNumber(visConfig.thresholdLow, lowDefault),
+ min,
+ max,
+ );
+ const high = this.clamp(
+ this.toFiniteNumber(visConfig.thresholdHigh, highDefault),
+ min,
+ max,
+ );
+
+ return low <= high
+ ? { min, max, range, low, high }
+ : { min, max, range, low: high, high: low };
+ }
+
+ private getLowColor(visConfig: GaugeVisConfig): string {
+ return visConfig.thresholdColorLow || '#91cc75';
+ }
+
+ private getMediumColor(visConfig: GaugeVisConfig): string {
+ return visConfig.thresholdColorMedium || '#fac858';
+ }
+
+ private getHighColor(visConfig: GaugeVisConfig): string {
+ return visConfig.thresholdColorHigh || '#ee6666';
+ }
+
+ private normalizeSplitNumber(splitNumber: number): number {
+ return Math.max(1, Math.round(this.toFiniteNumber(splitNumber, 10)));
+ }
+
+ private clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+ }
+
+ private toFiniteNumber(value: unknown, fallback: number): number {
+ const parsedValue = Number(value);
+ return Number.isFinite(parsedValue) ? parsedValue : fallback;
+ }
+
+ private makeDisplayName(displayName: unknown, fallback: string): string {
+ if (typeof displayName === 'string' && displayName.trim().length > 0) {
+ return displayName;
+ }
+
+ return fallback;
+ }
}
interface GaugeLayout {
diff --git
a/ui/src/app/chart-shared/components/charts/gauge/model/gauge-widget.model.ts
b/ui/src/app/chart-shared/components/charts/gauge/model/gauge-widget.model.ts
index 06135cc259..77bde3b066 100644
---
a/ui/src/app/chart-shared/components/charts/gauge/model/gauge-widget.model.ts
+++
b/ui/src/app/chart-shared/components/charts/gauge/model/gauge-widget.model.ts
@@ -28,6 +28,16 @@ export interface GaugeVisConfig extends
DataExplorerVisConfig {
min: number;
max: number;
displayName: string;
+ startAngle: number;
+ endAngle: number;
+ splitNumber: number;
+ showPointer: boolean;
+ enableThresholdColors: boolean;
+ thresholdLow?: number;
+ thresholdHigh?: number;
+ thresholdColorLow?: string;
+ thresholdColorMedium?: string;
+ thresholdColorHigh?: string;
}
export interface GaugeWidgetModel extends DataExplorerWidgetModel {
diff --git
a/ui/src/app/chart-shared/components/charts/pie/config/pie-chart-widget-config.component.html
b/ui/src/app/chart-shared/components/charts/pie/config/pie-chart-widget-config.component.html
index 7cd42c8373..3f91044bad 100644
---
a/ui/src/app/chart-shared/components/charts/pie/config/pie-chart-widget-config.component.html
+++
b/ui/src/app/chart-shared/components/charts/pie/config/pie-chart-widget-config.component.html
@@ -37,12 +37,14 @@
currentlyConfiguredWidget.visualizationConfig.selectedProperty
?.fieldCharacteristics.numeric
) {
- <sp-form-field [level]="3" [label]="'Rounding' | translate">
- <mat-form-field
- appearance="outline"
- class="marginColorField"
- color="accent"
- >
+ <sp-form-field
+ [level]="3"
+ [label]="'Rounding' | translate"
+ [description]="
+ 'Rounds values before grouping chart segments.' | translate
+ "
+ >
+ <mat-form-field class="marginColorField">
<mat-select
[(value)]="
currentlyConfiguredWidget.visualizationConfig
@@ -60,7 +62,14 @@
</sp-form-field>
}
- <sp-form-field [level]="3" [label]="'Inner Radius' | translate">
+ <sp-form-field
+ [level]="3"
+ [label]="'Inner Radius' | translate"
+ [description]="
+ 'Controls donut thickness by setting the inner empty space.'
+ | translate
+ "
+ >
<mat-slider min="0" max="80" step="1">
<input
matSliderThumb
@@ -75,6 +84,217 @@
<small>{{ slider.value }}% </small>
</sp-form-field>
+ <sp-form-field
+ [level]="3"
+ [label]="'Start Angle' | translate"
+ [description]="
+ 'Angle where the first slice starts (in degrees).' | translate
+ "
+ >
+ <mat-form-field class="marginColorField">
+ <input
+ matInput
+ type="number"
+ [(ngModel)]="
+
currentlyConfiguredWidget.visualizationConfig.startAngle
+ "
+ (ngModelChange)="triggerViewUpdate()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <mat-checkbox
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig.clockwise
+ "
+ (change)="triggerViewUpdate()"
+ >{{ 'Clockwise' | translate }}
+ </mat-checkbox>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'Min Slice Angle' | translate"
+ [description]="
+ 'Minimum angle reserved for each slice (in degrees).'
+ | translate
+ "
+ >
+ <mat-form-field class="marginColorField">
+ <input
+ matInput
+ type="number"
+ min="0"
+ max="360"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig.minAngle
+ "
+ (ngModelChange)="triggerViewUpdate()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'Label Content' | translate"
+ [description]="
+ 'Selects which values are shown in each label.' | translate
+ "
+ >
+ <mat-form-field class="marginColorField">
+ <mat-select
+ [(value)]="
+ currentlyConfiguredWidget.visualizationConfig.labelMode
+ "
+ (selectionChange)="triggerViewUpdate()"
+ >
+ <mat-option [value]="'name'">{{
+ 'Name' | translate
+ }}</mat-option>
+ <mat-option [value]="'value'">{{
+ 'Value' | translate
+ }}</mat-option>
+ <mat-option [value]="'percent'">{{
+ 'Percent' | translate
+ }}</mat-option>
+ <mat-option [value]="'name_percent'">{{
+ 'Name + Percent' | translate
+ }}</mat-option>
+ <mat-option [value]="'name_value'">{{
+ 'Name + Value' | translate
+ }}</mat-option>
+ <mat-option [value]="'name_value_percent'">{{
+ 'Name + Value + Percent' | translate
+ }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'Label Position' | translate"
+ [description]="
+ 'Places labels inside slices or outside the chart.' | translate
+ "
+ >
+ <mat-form-field class="marginColorField">
+ <mat-select
+ [(value)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .labelPosition
+ "
+ (selectionChange)="triggerViewUpdate()"
+ >
+ <mat-option [value]="'outside'">{{
+ 'Outside' | translate
+ }}</mat-option>
+ <mat-option [value]="'inside'">{{
+ 'Inside' | translate
+ }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ </sp-form-field>
+
+ @if (
+ currentlyConfiguredWidget.visualizationConfig.labelPosition ===
+ 'outside'
+ ) {
+ <sp-form-field
+ [level]="3"
+ [label]="'Label Align' | translate"
+ [description]="
+ 'Defines how outside labels align near the chart edge.'
+ | translate
+ "
+ >
+ <mat-form-field class="marginColorField">
+ <mat-select
+ [(value)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .labelAlignTo
+ "
+ (selectionChange)="triggerViewUpdate()"
+ >
+ <mat-option [value]="'edge'">{{
+ 'Edge' | translate
+ }}</mat-option>
+ <mat-option [value]="'labelLine'">{{
+ 'Label Line' | translate
+ }}</mat-option>
+ <mat-option [value]="'none'">{{
+ 'None' | translate
+ }}</mat-option>
+ </mat-select>
+ </mat-form-field>
+ </sp-form-field>
+ }
+
+ <mat-checkbox
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig.avoidLabelOverlap
+ "
+ (change)="triggerViewUpdate()"
+ >{{ 'Avoid Label Overlap' | translate }}
+ </mat-checkbox>
+
+ <mat-checkbox
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig.showLabelLine
+ "
+ (change)="triggerViewUpdate()"
+ >{{ 'Show Label Line' | translate }}
+ </mat-checkbox>
+
+ <mat-checkbox
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig.topNEnabled
+ "
+ (change)="triggerViewUpdate()"
+ >{{ 'Enable Top-N' | translate }}
+ </mat-checkbox>
+
+ @if (currentlyConfiguredWidget.visualizationConfig.topNEnabled) {
+ <sp-form-field
+ [level]="3"
+ [label]="'Top N' | translate"
+ [description]="
+ 'Keeps the largest N slices and groups the rest.'
+ | translate
+ "
+ >
+ <mat-form-field class="marginColorField">
+ <input
+ matInput
+ type="number"
+ min="1"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig.topN
+ "
+ (ngModelChange)="triggerViewUpdate()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+
+ <sp-form-field
+ [level]="3"
+ [label]="'Others Label' | translate"
+ [description]="
+ 'Name used for the grouped remaining slices.' | translate
+ "
+ >
+ <mat-form-field class="marginColorField">
+ <input
+ matInput
+ type="text"
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .othersLabel
+ "
+ (ngModelChange)="triggerViewUpdate()"
+ />
+ </mat-form-field>
+ </sp-form-field>
+ }
+
<sp-color-mapping-options-config
[(colorMapping)]="
currentlyConfiguredWidget.visualizationConfig
diff --git
a/ui/src/app/chart-shared/components/charts/pie/config/pie-chart-widget-config.component.ts
b/ui/src/app/chart-shared/components/charts/pie/config/pie-chart-widget-config.component.ts
index 719925124b..3a71940419 100644
---
a/ui/src/app/chart-shared/components/charts/pie/config/pie-chart-widget-config.component.ts
+++
b/ui/src/app/chart-shared/components/charts/pie/config/pie-chart-widget-config.component.ts
@@ -30,11 +30,13 @@ import {
} 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 { MatOption, MatSelect } from '@angular/material/select';
import { MatSlider, MatSliderThumb } from '@angular/material/slider';
import { FormsModule } from '@angular/forms';
import { ColorMappingOptionsConfigComponent } from
'../../../chart-config/color-mapping-options-config/color-mapping-options-config.component';
import { TranslatePipe } from '@ngx-translate/core';
+import { MatCheckbox } from '@angular/material/checkbox';
@Component({
selector: 'sp-pie-chart-widget-config',
@@ -45,10 +47,12 @@ import { TranslatePipe } from '@ngx-translate/core';
SelectSinglePropertyConfigComponent,
FormFieldComponent,
MatFormField,
+ MatInput,
MatSelect,
MatOption,
MatSlider,
MatSliderThumb,
+ MatCheckbox,
FormsModule,
ColorMappingOptionsConfigComponent,
TranslatePipe,
@@ -72,6 +76,19 @@ export class SpPieChartWidgetConfigComponent extends
BaseWidgetConfig<
);
config.roundingValue ??= 0.1;
config.selectedRadius ??= 0;
+ config.startAngle ??= 90;
+ config.clockwise ??= true;
+ config.minAngle ??= 0;
+ config.labelMode ??= 'name_percent';
+ config.labelPosition ??= 'outside';
+ config.labelAlignTo ??= 'edge';
+ config.avoidLabelOverlap ??= true;
+ config.showLabelLine ??= true;
+ config.topNEnabled ??= false;
+ config.topN ??= 10;
+ config.othersLabel ??= 'Others';
+ config.colorMappingsPieChart ??= [];
+ config.showCustomColorMappingPieChart ??= false;
}
updateRoundingValue(selectedType: number) {
diff --git
a/ui/src/app/chart-shared/components/charts/pie/model/pie-chart-widget.model.ts
b/ui/src/app/chart-shared/components/charts/pie/model/pie-chart-widget.model.ts
index 59a22a0245..56d5f114a8 100644
---
a/ui/src/app/chart-shared/components/charts/pie/model/pie-chart-widget.model.ts
+++
b/ui/src/app/chart-shared/components/charts/pie/model/pie-chart-widget.model.ts
@@ -27,6 +27,23 @@ export interface PieChartVisConfig extends
DataExplorerVisConfig {
selectedProperty: DataExplorerField;
roundingValue: number;
selectedRadius: number;
+ startAngle: number;
+ clockwise: boolean;
+ minAngle: number;
+ labelMode:
+ | 'name'
+ | 'value'
+ | 'percent'
+ | 'name_percent'
+ | 'name_value'
+ | 'name_value_percent';
+ labelPosition: 'inside' | 'outside';
+ labelAlignTo: 'none' | 'labelLine' | 'edge';
+ avoidLabelOverlap: boolean;
+ showLabelLine: boolean;
+ topNEnabled: boolean;
+ topN: number;
+ othersLabel: string;
showCustomColorMappingPieChart: boolean;
isSelectedPropertyBoolean: boolean;
colorMappingsPieChart: { value: string; label: string; color: string }[];
diff --git
a/ui/src/app/chart-shared/components/charts/pie/pie-renderer.service.ts
b/ui/src/app/chart-shared/components/charts/pie/pie-renderer.service.ts
index 5b4aaf3430..71b97fa794 100644
--- a/ui/src/app/chart-shared/components/charts/pie/pie-renderer.service.ts
+++ b/ui/src/app/chart-shared/components/charts/pie/pie-renderer.service.ts
@@ -20,7 +20,10 @@ import { EChartsOption, PieSeriesOption } from 'echarts';
import type { DataTransformOption } from
'echarts/types/src/data/helper/transform.d.ts';
import { SpBaseSingleFieldEchartsRenderer } from
'../../../echarts-renderer/base-single-field-echarts-renderer';
import { inject, Injectable } from '@angular/core';
-import { PieChartWidgetModel } from './model/pie-chart-widget.model';
+import {
+ PieChartVisConfig,
+ PieChartWidgetModel,
+} from './model/pie-chart-widget.model';
import { FieldUpdateInfo } from '../../../models/field-update.model';
import { ZRColor } from 'echarts/types/dist/shared';
import { ColorMappingService } from '../../../services/color-mapping.service';
@@ -35,16 +38,29 @@ export class SpPieRendererService extends
SpBaseSingleFieldEchartsRenderer<
addDatasetTransform(
widgetConfig: PieChartWidgetModel,
): DataTransformOption {
- const field =
- widgetConfig.visualizationConfig.selectedProperty.fullDbName;
+ const config = this.getSafeConfig(widgetConfig);
+ const field = config.selectedProperty.fullDbName;
+ const topNEnabled = config.topNEnabled;
+ if (!topNEnabled) {
+ return {
+ type: 'ecSimpleTransform:aggregate',
+ config: {
+ resultDimensions: [
+ { name: 'name', from: field },
+ { name: 'value', from: 'time', method: 'count' },
+ ],
+ groupBy: field,
+ },
+ };
+ }
+
return {
- type: 'ecSimpleTransform:aggregate',
+ type: 'sp:pie-aggregate',
config: {
- resultDimensions: [
- { name: 'name', from: field },
- { name: 'value', from: 'time', method: 'count' },
- ],
- groupBy: field,
+ field,
+ topNEnabled,
+ topN: config.topN,
+ othersLabel: config.othersLabel,
},
};
}
@@ -63,17 +79,15 @@ export class SpPieRendererService extends
SpBaseSingleFieldEchartsRenderer<
option: EChartsOption,
widgetConfig: PieChartWidgetModel,
): void {
- if (
- widgetConfig.visualizationConfig.selectedProperty
- .fieldCharacteristics.binary
- ) {
+ const config = this.getSafeConfig(widgetConfig);
+ if (config.selectedProperty.fieldCharacteristics.binary) {
option.legend = { show: false };
} else {
option.legend = {
type: 'scroll',
formatter: name => {
return (
-
widgetConfig.visualizationConfig.colorMappingsPieChart.find(
+ config.colorMappingsPieChart.find(
c => String(c.value) === name,
)?.label || name
);
@@ -88,48 +102,69 @@ export class SpPieRendererService extends
SpBaseSingleFieldEchartsRenderer<
datasetIndex: number,
widgetConfig: PieChartWidgetModel,
): PieSeriesOption {
- const innerRadius = widgetConfig.visualizationConfig.selectedRadius;
- const colorMapping =
- widgetConfig.visualizationConfig.colorMappingsPieChart;
+ const config = this.getSafeConfig(widgetConfig);
+ const innerRadius = config.selectedRadius;
+ const colorMapping = config.colorMappingsPieChart;
const decimals = this.getDecimals(widgetConfig);
+ const labelMode = config.labelMode;
+ const labelPosition = config.labelPosition;
+ const labelAlignTo = config.labelAlignTo;
+ const isOutsideLabel = labelPosition === 'outside';
+ const outerRadius = isOutsideLabel ? '78%' : '90%';
return {
name,
type: 'pie',
universalTransition: true,
datasetIndex: datasetIndex,
+ startAngle: config.startAngle,
+ clockwise: config.clockwise,
+ minAngle: config.minAngle,
+ avoidLabelOverlap: config.avoidLabelOverlap,
tooltip: {
formatter: params => {
const mappedLabel =
colorMapping.find(
c => c.value === params.value[0]?.toString(),
)?.label || params.value[0];
- const formattedValue = this.formatNumber(
+ return `${params.marker} ${this.formatPieText(
+ mappedLabel,
params.value[1],
+ params.percent,
+ labelMode,
decimals,
- );
- const formattedPercent =
- typeof params.percent === 'number'
- ? this.formatNumber(params.percent, decimals)
- : params.percent;
- return `${params.marker} ${mappedLabel}
<b>${formattedValue}</b> (${formattedPercent}%)`;
+ true,
+ )}`;
},
},
label: {
+ position: labelPosition,
+ alignTo: isOutsideLabel ? labelAlignTo : undefined,
+ edgeDistance:
+ isOutsideLabel && labelAlignTo === 'edge' ? 8 : undefined,
+ bleedMargin: isOutsideLabel ? 4 : undefined,
+ overflow: 'truncate',
formatter: params => {
const mappedLabel =
colorMapping.find(
c => c.value === params.value[0]?.toString(),
)?.label || params.value[0];
- const formattedPercent =
- typeof params.percent === 'number'
- ? this.formatNumber(params.percent, decimals)
- : params.percent;
- return `${mappedLabel} (${formattedPercent}%)`;
+ return this.formatPieText(
+ mappedLabel,
+ params.value[1],
+ params.percent,
+ labelMode,
+ decimals,
+ );
},
},
+ labelLine: {
+ show: config.showLabelLine && labelPosition === 'outside',
+ length: 8,
+ length2: 8,
+ },
encode: { itemName: 'name', value: 'value' },
- radius: [innerRadius + '%', '90%'],
+ radius: [innerRadius + '%', outerRadius],
itemStyle: {
color: params => {
const category = params.data[0];
@@ -144,6 +179,37 @@ export class SpPieRendererService extends
SpBaseSingleFieldEchartsRenderer<
};
}
+ private formatPieText(
+ mappedLabel: string,
+ rawValue: unknown,
+ rawPercent: unknown,
+ labelMode: PieChartWidgetModel['visualizationConfig']['labelMode'],
+ decimals: number,
+ boldValue = false,
+ ): string {
+ const value = this.formatNumber(rawValue, decimals);
+ const valueText = boldValue ? `<b>${value}</b>` : value;
+ const percent =
+ typeof rawPercent === 'number'
+ ? this.formatNumber(rawPercent, decimals)
+ : String(rawPercent ?? '');
+ const percentText = `${percent}%`;
+
+ if (labelMode === 'name') {
+ return mappedLabel;
+ } else if (labelMode === 'value') {
+ return valueText;
+ } else if (labelMode === 'percent') {
+ return percentText;
+ } else if (labelMode === 'name_value') {
+ return `${mappedLabel}: ${valueText}`;
+ } else if (labelMode === 'name_value_percent') {
+ return `${mappedLabel}: ${valueText} (${percentText})`;
+ } else {
+ return `${mappedLabel} (${percentText})`;
+ }
+ }
+
initialTransforms(
widgetConfig: PieChartWidgetModel,
sourceIndex: number,
@@ -179,7 +245,43 @@ export class SpPieRendererService extends
SpBaseSingleFieldEchartsRenderer<
}
getDefaultSeriesName(widgetConfig: PieChartWidgetModel): string {
- return widgetConfig.visualizationConfig.selectedProperty.fullDbName;
+ return this.getSafeConfig(widgetConfig).selectedProperty.fullDbName;
+ }
+
+ private getSafeConfig(
+ widgetConfig: PieChartWidgetModel,
+ ): PieChartVisConfig {
+ const config = widgetConfig.visualizationConfig;
+ return {
+ ...config,
+ startAngle: Number.isFinite(config.startAngle)
+ ? config.startAngle
+ : 90,
+ clockwise:
+ typeof config.clockwise === 'boolean' ? config.clockwise :
true,
+ minAngle: Number.isFinite(config.minAngle) ? config.minAngle : 0,
+ labelMode: config.labelMode || 'name_percent',
+ labelPosition: config.labelPosition || 'outside',
+ labelAlignTo: config.labelAlignTo || 'edge',
+ avoidLabelOverlap:
+ typeof config.avoidLabelOverlap === 'boolean'
+ ? config.avoidLabelOverlap
+ : true,
+ showLabelLine:
+ typeof config.showLabelLine === 'boolean'
+ ? config.showLabelLine
+ : true,
+ topNEnabled: !!config.topNEnabled,
+ topN:
+ Number.isFinite(config.topN) && config.topN > 0
+ ? Math.round(config.topN)
+ : 10,
+ othersLabel: config.othersLabel || 'Others',
+ colorMappingsPieChart: config.colorMappingsPieChart || [],
+ selectedRadius: Number.isFinite(config.selectedRadius)
+ ? config.selectedRadius
+ : 0,
+ };
}
private applySinglePieResponsiveLayout(option: EChartsOption): void {
diff --git a/ui/src/app/core-ui/echarts-transform/pie-aggregate.transform.ts
b/ui/src/app/core-ui/echarts-transform/pie-aggregate.transform.ts
new file mode 100644
index 0000000000..206720fcbc
--- /dev/null
+++ b/ui/src/app/core-ui/echarts-transform/pie-aggregate.transform.ts
@@ -0,0 +1,116 @@
+/*
+ * 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 type {
+ DataTransformOption,
+ ExternalDataTransform,
+ ExternalDataTransformResultItem,
+} from 'echarts/types/src/data/helper/transform.d.ts';
+import type { OptionSourceDataArrayRows } from
'echarts/types/src/util/types.d.ts';
+
+export interface PieAggregateConfig extends DataTransformOption {
+ field: string;
+ topNEnabled: boolean;
+ topN: number;
+ othersLabel: string;
+}
+
+type PieEntry = {
+ key: string;
+ name: string;
+ value: number;
+};
+
+export const PieAggregateTransform: ExternalDataTransform<PieAggregateConfig> =
+ {
+ type: 'sp:pie-aggregate',
+
+ transform: function (
+ params,
+ ): ExternalDataTransformResultItem | ExternalDataTransformResultItem[]
{
+ const upstream = params.upstream;
+ const field = params.config['field'] as string;
+ const topNEnabled = !!params.config['topNEnabled'];
+ const topN = Math.max(
+ 1,
+ Math.round(Number(params.config['topN']) || 1),
+ );
+ const othersLabel =
+ (params.config['othersLabel'] as string) || 'Others';
+ const dimension = upstream.getDimensionInfo(field);
+
+ if (!dimension) {
+ return {
+ data: [],
+ dimensions: ['name', 'value'],
+ };
+ }
+
+ const rows = upstream.cloneRawData() as OptionSourceDataArrayRows;
+ const dimsDef = upstream.cloneAllDimensionInfo();
+ const hasHeaderRow =
+ rows.length > 0 &&
+ Array.isArray(rows[0]) &&
+ dimsDef.every((dim, index) => rows[0][index] === dim.name);
+ const startIndex = hasHeaderRow ? 1 : 0;
+
+ const grouped = new Map<string, PieEntry>();
+ for (let i = startIndex; i < rows.length; i++) {
+ const rawValue = rows[i][dimension.index];
+ const key =
+ rawValue === null || rawValue === undefined
+ ? '__null__'
+ : String(rawValue);
+ const name =
+ rawValue === null || rawValue === undefined
+ ? 'null'
+ : String(rawValue);
+ const existing = grouped.get(key);
+ if (existing) {
+ existing.value += 1;
+ } else {
+ grouped.set(key, { key, name, value: 1 });
+ }
+ }
+
+ let result = [...grouped.values()];
+
+ if (topNEnabled) {
+ const sorted = [...result].sort((a, b) => b.value - a.value);
+ const kept = sorted.slice(0, topN);
+ const remainder = sorted.slice(topN);
+ const remainderSum = remainder.reduce(
+ (sum, item) => sum + item.value,
+ 0,
+ );
+ result = kept;
+ if (remainderSum > 0) {
+ result.push({
+ key: '__others__',
+ name: othersLabel,
+ value: remainderSum,
+ });
+ }
+ }
+
+ return {
+ data: result.map(item => [item.name, item.value]),
+ dimensions: ['name', 'value'],
+ };
+ },
+ };
diff --git a/ui/src/main.ts b/ui/src/main.ts
index 857a788c1f..82c29c776c 100644
--- a/ui/src/main.ts
+++ b/ui/src/main.ts
@@ -29,12 +29,14 @@ import { ValueDistributionTransform } from
'./app/core-ui/echarts-transform/valu
import { HistogramTransform } from
'./app/core-ui/echarts-transform/histogram.transform';
import { RoundValuesTransform } from
'./app/core-ui/echarts-transform/round-values.transform';
import { MapTransform } from './app/core-ui/echarts-transform/map.transform';
+import { PieAggregateTransform } from
'./app/core-ui/echarts-transform/pie-aggregate.transform';
echarts.registerTransform(transform.aggregate);
echarts.registerTransform(ValueDistributionTransform);
echarts.registerTransform(HistogramTransform);
echarts.registerTransform(RoundValuesTransform);
echarts.registerTransform(MapTransform);
+echarts.registerTransform(PieAggregateTransform);
// required
import * as $ from 'jquery';