Add color mapping for pie chart (#3305)

diff --git a/ui/src/app/data-explorer/components/widgets/pie/config/pie-chart-widget-config.component.html b/ui/src/app/data-explorer/components/widgets/pie/config/pie-chart-widget-config.component.html
index b6b4b44..71afe09 100644
--- a/ui/src/app/data-explorer/components/widgets/pie/config/pie-chart-widget-config.component.html
+++ b/ui/src/app/data-explorer/components/widgets/pie/config/pie-chart-widget-config.component.html
@@ -31,19 +31,18 @@
         >
         </sp-select-property>
     </sp-configuration-box>
-    <sp-configuration-box
-        title="Settings"
-        *ngIf="
-            currentlyConfiguredWidget.visualizationConfig.selectedProperty
-                ?.fieldCharacteristics.numeric
-        "
-    >
+
+    <sp-configuration-box title="Settings">
         <div fxLayout="column" fxLayoutGap="10px">
             <div
                 fxFlex="100"
                 fxLayout="row"
                 fxLayoutAlign="start center"
                 fxLayoutGap="10px"
+                *ngIf="
+                    currentlyConfiguredWidget.visualizationConfig
+                        .selectedProperty?.fieldCharacteristics.numeric
+                "
             >
                 <small>Rounding</small>
                 <mat-form-field
@@ -67,6 +66,7 @@
                     </mat-select>
                 </mat-form-field>
             </div>
+
             <div
                 fxFlex="100"
                 fxLayout="row"
@@ -87,6 +87,98 @@
                 </mat-slider>
                 <small>{{ slider.value }}% </small>
             </div>
+            <div
+                fxLayout="row"
+                fxLayoutGap="10px"
+                fxLayoutAlign="start center"
+                fxFlex="100"
+                class="checkbox-container"
+            >
+                <mat-checkbox
+                    color="accent"
+                    [(ngModel)]="
+                        currentlyConfiguredWidget.visualizationConfig
+                            .showCustomColorMapping
+                    "
+                    (ngModelChange)="showCustomColorMapping($event)"
+                >
+                </mat-checkbox>
+                <small>Add custom color mapping</small>
+            </div>
+
+            <div
+                *ngIf="
+                    currentlyConfiguredWidget.visualizationConfig
+                        .showCustomColorMapping
+                "
+            >
+                <button mat-raised-button color="accent" (click)="addMapping()">
+                    <i class="material-icons">add</i
+                    ><span>&nbsp;Add Mapping</span>
+                </button>
+            </div>
+
+            <div
+                *ngIf="
+                    currentlyConfiguredWidget.visualizationConfig
+                        .showCustomColorMapping
+                "
+            >
+                <div fxLayout="column" fxLayoutGap="10px">
+                    <div
+                        *ngFor="
+                            let mapping of currentlyConfiguredWidget
+                                .visualizationConfig.colorMappings;
+                            let i = index
+                        "
+                        fxLayout="row"
+                        fxLayoutGap="10px"
+                        fxLayoutAlign="start center"
+                        fxFlex="100"
+                        style="margin-top: 10px; align-items: center"
+                    >
+                        <div fxFlex>
+                            <mat-form-field
+                                class="w-100"
+                                color="accent"
+                                appearance="outline"
+                            >
+                                <mat-label>Value</mat-label>
+                                <input
+                                    matInput
+                                    [(ngModel)]="mapping.value"
+                                    (ngModelChange)="updateMapping()"
+                                />
+                            </mat-form-field>
+                        </div>
+                        <div fxFlex="70px">
+                            <input
+                                [(colorPicker)]="mapping.color"
+                                [style.background]="mapping.color"
+                                style="
+                                    height: 50%;
+                                    width: 100%;
+                                    border: none;
+                                    border-radius: 10%;
+                                    cursor: pointer;
+                                "
+                                (colorPickerChange)="updateColor(i, $event)"
+                                readonly
+                            />
+                        </div>
+                        <div fxLayoutAlign="end center">
+                            <button
+                                mat-icon-button
+                                matTooltip="Remove Mapping"
+                                color="accent"
+                                (click)="removeMapping(i)"
+                            >
+                                <i class="material-icons">delete</i>
+                            </button>
+                        </div>
+                    </div>
+                </div>
+            </div>
         </div>
     </sp-configuration-box>
 </sp-visualization-config-outer>
diff --git a/ui/src/app/data-explorer/components/widgets/pie/config/pie-chart-widget-config.component.ts b/ui/src/app/data-explorer/components/widgets/pie/config/pie-chart-widget-config.component.ts
index e93dd85..f801556 100644
--- a/ui/src/app/data-explorer/components/widgets/pie/config/pie-chart-widget-config.component.ts
+++ b/ui/src/app/data-explorer/components/widgets/pie/config/pie-chart-widget-config.component.ts
@@ -23,6 +23,9 @@
     PieChartWidgetModel,
 } from '../model/pie-chart-widget.model';
 import { DataExplorerField } from '@streampipes/platform-services';
+import { ColorMappingService } from '../../../../services/color-mapping.service';
+import { WidgetConfigurationService } from 'src/app/data-explorer/services/widget-configuration.service';
+import { DataExplorerFieldProviderService } from 'src/app/data-explorer/services/data-explorer-field-provider-service';
 
 @Component({
     selector: 'sp-pie-chart-widget-config',
@@ -32,6 +35,14 @@
     PieChartWidgetModel,
     PieChartVisConfig
 > {
+    constructor(
+        private colorMappingService: ColorMappingService,
+        widgetConfigurationService: WidgetConfigurationService,
+        fieldService: DataExplorerFieldProviderService,
+    ) {
+        super(widgetConfigurationService, fieldService);
+    }
+
     setSelectedProperty(field: DataExplorerField) {
         this.currentlyConfiguredWidget.visualizationConfig.selectedProperty =
             field;
@@ -46,6 +57,8 @@
         );
         config.roundingValue ??= 0.1;
         config.selectedRadius ??= 0;
+        config.showCustomColorMapping ??= false;
+        config.colorMappings ??= [];
     }
 
     updateRoundingValue(selectedType: number) {
@@ -60,6 +73,52 @@
         this.triggerViewRefresh();
     }
 
+    showCustomColorMapping(showCustomColorMapping: boolean) {
+        this.currentlyConfiguredWidget.visualizationConfig.showCustomColorMapping =
+            showCustomColorMapping;
+
+        if (!showCustomColorMapping) {
+            this.resetColorMappings();
+        }
+
+        this.triggerViewRefresh();
+    }
+
+    resetColorMappings(): void {
+        this.currentlyConfiguredWidget.visualizationConfig.colorMappings = [];
+        this.triggerViewRefresh();
+    }
+
+    addMapping() {
+        this.colorMappingService.addMapping(
+            this.currentlyConfiguredWidget.visualizationConfig.colorMappings,
+        );
+        this.triggerViewRefresh();
+    }
+
+    removeMapping(index: number) {
+        this.currentlyConfiguredWidget.visualizationConfig.colorMappings =
+            this.colorMappingService.removeMapping(
+                this.currentlyConfiguredWidget.visualizationConfig
+                    .colorMappings,
+                index,
+            );
+        this.triggerViewRefresh();
+    }
+
+    updateColor(index: number, newColor: string) {
+        this.colorMappingService.updateColor(
+            this.currentlyConfiguredWidget.visualizationConfig.colorMappings,
+            index,
+            newColor,
+        );
+        this.triggerViewRefresh();
+    }
+
+    updateMapping() {
+        this.triggerViewRefresh();
+    }
+
     protected requiredFieldsForChartPresent(): boolean {
         return this.fieldProvider.allFields.length > 0;
     }
diff --git a/ui/src/app/data-explorer/components/widgets/pie/model/pie-chart-widget.model.ts b/ui/src/app/data-explorer/components/widgets/pie/model/pie-chart-widget.model.ts
index a846ad0..ad73397 100644
--- a/ui/src/app/data-explorer/components/widgets/pie/model/pie-chart-widget.model.ts
+++ b/ui/src/app/data-explorer/components/widgets/pie/model/pie-chart-widget.model.ts
@@ -27,6 +27,8 @@
     selectedProperty: DataExplorerField;
     roundingValue: number;
     selectedRadius: number;
+    showCustomColorMapping: boolean;
+    colorMappings: { value: string; color: string }[];
 }
 
 export interface PieChartWidgetModel extends DataExplorerWidgetModel {
diff --git a/ui/src/app/data-explorer/components/widgets/pie/pie-renderer.service.ts b/ui/src/app/data-explorer/components/widgets/pie/pie-renderer.service.ts
index be48f98..20a74de 100644
--- a/ui/src/app/data-explorer/components/widgets/pie/pie-renderer.service.ts
+++ b/ui/src/app/data-explorer/components/widgets/pie/pie-renderer.service.ts
@@ -22,12 +22,18 @@
 import { Injectable } from '@angular/core';
 import { 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';
 
 @Injectable({ providedIn: 'root' })
 export class SpPieRendererService extends SpBaseSingleFieldEchartsRenderer<
     PieChartWidgetModel,
     PieSeriesOption
 > {
+    constructor(private colorMappingService: ColorMappingService) {
+        super();
+    }
+
     addDatasetTransform(
         widgetConfig: PieChartWidgetModel,
     ): DataTransformOption {
@@ -65,6 +71,7 @@
         _widgetConfig: PieChartWidgetModel,
     ): PieSeriesOption {
         const innerRadius = _widgetConfig.visualizationConfig.selectedRadius;
+        const colorMapping = _widgetConfig.visualizationConfig.colorMappings;
         return {
             name,
             type: 'pie',
@@ -82,6 +89,18 @@
             },
             encode: { itemName: 'name', value: 'value' },
             radius: [innerRadius + '%', '90%'],
+            itemStyle: {
+                color: params => {
+                    const category = params.data[0];
+                    const color =
+                        colorMapping.find(c => c.value === category.toString())
+                            ?.color ||
+                        this.colorMappingService.getDefaultColor(
+                            params.dataIndex,
+                        );
+                    return color as ZRColor;
+                },
+            },
         };
     }
 
diff --git a/ui/src/app/data-explorer/services/color-mapping.service.ts b/ui/src/app/data-explorer/services/color-mapping.service.ts
new file mode 100644
index 0000000..9b878a3
--- /dev/null
+++ b/ui/src/app/data-explorer/services/color-mapping.service.ts
@@ -0,0 +1,63 @@
+/*
+ * 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 { Injectable } from '@angular/core';
+
+@Injectable({
+    providedIn: 'root',
+})
+export class ColorMappingService {
+    private colorPalette = [
+        '#5470c6',
+        '#91cc75',
+        '#fac858',
+        '#ee6666',
+        '#73c0de',
+        '#3ba272',
+        '#fc8452',
+        '#9a60b4',
+        '#ea7ccc',
+    ];
+    constructor() {}
+
+    addMapping(colorMappings: { value: string; color: string }[]): void {
+        colorMappings.push({
+            value: '',
+            color: this.getDefaultColor(colorMappings.length),
+        });
+    }
+
+    removeMapping(
+        colorMappings: { value: string; color: string }[],
+        index: number,
+    ): { value: string; color: string }[] {
+        return colorMappings.filter((_, i) => i !== index);
+    }
+
+    updateColor(
+        currentMappings: { value: string; color: string }[],
+        index: number,
+        newColor: string,
+    ): void {
+        currentMappings[index].color = newColor;
+    }
+
+    getDefaultColor(index: number): string {
+        return this.colorPalette[index % this.colorPalette.length];
+    }
+}