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';

Reply via email to