This is an automated email from the ASF dual-hosted git repository.

dominikriemer pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to refs/heads/dev by this push:
     new 3d0406aff8 feat: Add coloring modes to time series chart in grouping 
mode (#4549)
3d0406aff8 is described below

commit 3d0406aff858f17dd82f67bb827d4187466f7a83
Author: Dominik Riemer <[email protected]>
AuthorDate: Fri Jun 12 12:24:39 2026 +0200

    feat: Add coloring modes to time series chart in grouping mode (#4549)
---
 ui/deployment/i18n/de.json                         |   4 +
 ui/deployment/i18n/en.json                         |   4 +
 ui/deployment/i18n/pl.json                         |   4 +
 .../color-mapping-options-config.component.html    |  30 +++---
 .../color-mapping-options-config.component.ts      |   6 +-
 .../time-series-item-config.component.html         |  66 +++++++++----
 .../time-series-item-config.component.ts           |  98 ++++++++++++++++---
 .../chart-container/chart-container.component.html |   7 ++
 .../chart-container/chart-container.component.ts   |  12 ++-
 .../time-series-chart-widget-config.component.ts   |  30 +++++-
 .../model/time-series-chart-widget.model.ts        |  10 ++
 .../sp-timeseries-renderer.service.ts              |  24 +++--
 .../traffic-light-widget.component.html            |  12 ++-
 .../traffic-light-widget.component.scss            |  26 +++--
 .../traffic-light-widget.component.ts              |  92 +++++++++++++++++-
 .../models/dataview-dashboard.model.ts             |   1 +
 .../services/chart-colorization.service.ts         | 106 ++++++++++++++-------
 .../panel/dashboard-panel.component.html           |  30 ++++++
 .../components/panel/dashboard-panel.component.ts  |  11 +++
 .../edit-dashboard-dialog.component.html           |  17 ++++
 .../edit-dashboard-dialog.component.ts             |   6 ++
 21 files changed, 485 insertions(+), 111 deletions(-)

diff --git a/ui/deployment/i18n/de.json b/ui/deployment/i18n/de.json
index 922fcc8568..b861858a15 100644
--- a/ui/deployment/i18n/de.json
+++ b/ui/deployment/i18n/de.json
@@ -262,6 +262,7 @@
   "Current year": "Aktuelles Jahr",
   "Currently configured query": "Aktuell konfigurierte Anfrage",
   "Custom": "Benutzerdefiniert",
+  "Custom color mapping": "Benutzerdefiniertes Farb-Mapping",
   "Custom fields": "Benutzerdefinierte Felder",
   "Dark mode": "Dunkel",
   "Dashboard": "Dashboard",
@@ -492,6 +493,7 @@
   "Group name": "Name der Benutzergruppe",
   "Group selection": "Auswahl der Gruppe",
   "Grouped": "Gruppiert",
+  "Grouped colors": "Farben für Gruppierung",
   "Groups": "Gruppen",
   "Header": "Header",
   "Header column name": "Spaltenname",
@@ -986,6 +988,7 @@
   "Source": "Quelle",
   "Split Number": "Anzahl der Unterteilungen",
   "Spread": "Spanne",
+  "Stable palette": "Stabile Palette",
   "Standard deviation": "Standardabweichung",
   "Start": "Start",
   "Start Adapter": "Start Adapter",
@@ -1202,6 +1205,7 @@
   "We've sent out a confirmation mail to this address.": "Wir haben eine 
Bestätigungsmail an diese Adresse verschickt.",
   "Week": "Woche",
   "Wheel": "Rad",
+  "Widget border thickness": "Rahmendicke des Widgets",
   "Will be shown on the map if required by the tile server": "Wird auf der 
Karte angezeigt, wenn der Tile-Server dies verlangt",
   "With Threshold being": "Schwellenwert entspricht",
   "X": "X",
diff --git a/ui/deployment/i18n/en.json b/ui/deployment/i18n/en.json
index af22414388..175bf2e9d3 100644
--- a/ui/deployment/i18n/en.json
+++ b/ui/deployment/i18n/en.json
@@ -262,6 +262,7 @@
   "Current year": null,
   "Currently configured query": null,
   "Custom": null,
+  "Custom color mapping": null,
   "Custom fields": null,
   "Dark mode": null,
   "Dashboard": null,
@@ -492,6 +493,7 @@
   "Group name": null,
   "Group selection": null,
   "Grouped": null,
+  "Grouped colors": null,
   "Groups": null,
   "Header": null,
   "Header column name": null,
@@ -986,6 +988,7 @@
   "Source": null,
   "Split Number": null,
   "Spread": null,
+  "Stable palette": null,
   "Standard deviation": null,
   "Start": null,
   "Start Adapter": null,
@@ -1202,6 +1205,7 @@
   "We've sent out a confirmation mail to this address.": null,
   "Week": null,
   "Wheel": null,
+  "Widget border thickness": null,
   "Will be shown on the map if required by the tile server": null,
   "With Threshold being": null,
   "X": null,
diff --git a/ui/deployment/i18n/pl.json b/ui/deployment/i18n/pl.json
index 1f9fcbcd68..7bbc0436e1 100644
--- a/ui/deployment/i18n/pl.json
+++ b/ui/deployment/i18n/pl.json
@@ -262,6 +262,7 @@
   "Current year": "Bieżący rok",
   "Currently configured query": "Aktualnie skonfigurowane zapytanie",
   "Custom": "Niestandardowe",
+  "Custom color mapping": "Własne mapowanie kolorów",
   "Custom fields": "Pola niestandardowe",
   "Dark mode": "Ciemny",
   "Dashboard": "Pulpit",
@@ -492,6 +493,7 @@
   "Group name": "Nazwa grupy użytkowników",
   "Group selection": "Wybór grupy",
   "Grouped": "Zgrupowane",
+  "Grouped colors": "Kolory grupowania",
   "Groups": "Grupy",
   "Header": "Nagłówek",
   "Header column name": "Nazwa kolumny nagłówka",
@@ -986,6 +988,7 @@
   "Source": "Źródło",
   "Split Number": "Liczba podziałów",
   "Spread": "Rozstęp",
+  "Stable palette": "Stabilna paleta",
   "Standard deviation": "Odchylenie standardowe",
   "Start": "Uruchom",
   "Start Adapter": "Uruchom adapter",
@@ -1202,6 +1205,7 @@
   "We've sent out a confirmation mail to this address.": "Wysłaliśmy wiadomość 
potwierdzającą na ten adres.",
   "Week": "Tydzień",
   "Wheel": "Kółko",
+  "Widget border thickness": "Grubość obramowania widżetu",
   "Will be shown on the map if required by the tile server": "Zostanie 
pokazana na mapie, jeśli wymaga tego serwera kafelków(serwer map cyfrowych)",
   "With Threshold being": "Próg wynosi",
   "X": "X",
diff --git 
a/ui/src/app/chart-shared/components/chart-config/color-mapping-options-config/color-mapping-options-config.component.html
 
b/ui/src/app/chart-shared/components/chart-config/color-mapping-options-config/color-mapping-options-config.component.html
index df661e5716..b4767037d6 100644
--- 
a/ui/src/app/chart-shared/components/chart-config/color-mapping-options-config/color-mapping-options-config.component.html
+++ 
b/ui/src/app/chart-shared/components/chart-config/color-mapping-options-config/color-mapping-options-config.component.html
@@ -16,21 +16,23 @@
 ~
 -->
 <div fxLayout="column" fxLayoutGap="10px">
-    <div
-        fxLayout="row"
-        fxLayoutGap="10px"
-        fxLayoutAlign="start center"
-        fxFlex="1"
-        class="checkbox-container"
-    >
-        <mat-checkbox
-            color="accent"
-            [(ngModel)]="showCustomColorMapping"
-            (ngModelChange)="setCustomColorMapping($event)"
+    @if (showToggle) {
+        <div
+            fxLayout="row"
+            fxLayoutGap="10px"
+            fxLayoutAlign="start center"
+            fxFlex="1"
+            class="checkbox-container"
         >
-        </mat-checkbox>
-        <small>{{ 'Add custom color mapping' | translate }}</small>
-    </div>
+            <mat-checkbox
+                color="accent"
+                [(ngModel)]="showCustomColorMapping"
+                (ngModelChange)="setCustomColorMapping($event)"
+            >
+            </mat-checkbox>
+            <small>{{ 'Add custom color mapping' | translate }}</small>
+        </div>
+    }
 
     @if (!isSelectedPropertyBoolean && showCustomColorMapping) {
         <div>
diff --git 
a/ui/src/app/chart-shared/components/chart-config/color-mapping-options-config/color-mapping-options-config.component.ts
 
b/ui/src/app/chart-shared/components/chart-config/color-mapping-options-config/color-mapping-options-config.component.ts
index 03380eddb0..2cbdaf51c0 100644
--- 
a/ui/src/app/chart-shared/components/chart-config/color-mapping-options-config/color-mapping-options-config.component.ts
+++ 
b/ui/src/app/chart-shared/components/chart-config/color-mapping-options-config/color-mapping-options-config.component.ts
@@ -72,10 +72,12 @@ export class ColorMappingOptionsConfigComponent implements 
OnInit, OnChanges {
 
     @Input() colorMapping: { value: string; label: string; color: string }[];
 
-    @Input() selectedProperty: DataExplorerField;
+    @Input() selectedProperty?: DataExplorerField;
 
     @Input() showCustomColorMapping: boolean;
 
+    @Input() showToggle = true;
+
     @Output()
     viewRefreshEmitter: EventEmitter<void> = new EventEmitter<void>();
 
@@ -170,7 +172,7 @@ export class ColorMappingOptionsConfigComponent implements 
OnInit, OnChanges {
     }
 
     isBooleanPropertySelected(): boolean {
-        return this.selectedProperty.fieldCharacteristics.binary;
+        return this.selectedProperty?.fieldCharacteristics.binary ?? false;
     }
 
     setCustomColorMapping(showCustomColorMapping: boolean) {
diff --git 
a/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.html
 
b/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.html
index ac1ba8e095..1a64d5092d 100644
--- 
a/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.html
+++ 
b/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.html
@@ -38,7 +38,7 @@
             [ngStyle]="{
                 background:
                     currentlyConfiguredWidget.visualizationConfig.chosenColor[
-                        field.fullDbName + field.sourceIndex.toString()
+                        getFieldKey(field)
                     ],
             }"
         ></span>
@@ -66,15 +66,11 @@
                         class="color-field"
                         [(colorPicker)]="
                             currentlyConfiguredWidget.visualizationConfig
-                                .chosenColor[
-                                field.fullDbName + field.sourceIndex.toString()
-                            ]
+                                .chosenColor[getFieldKey(field)]
                         "
                         [style.background]="
                             currentlyConfiguredWidget.visualizationConfig
-                                .chosenColor[
-                                field.fullDbName + field.sourceIndex.toString()
-                            ]
+                                .chosenColor[getFieldKey(field)]
                         "
                         required
                         [cpPosition]="'bottom'"
@@ -97,10 +93,7 @@
                         <input
                             [(ngModel)]="
                                 currentlyConfiguredWidget.visualizationConfig
-                                    .displayName[
-                                    field.fullDbName +
-                                        field.sourceIndex.toString()
-                                ]
+                                    .displayName[getFieldKey(field)]
                             "
                             matInput
                             (input)="
@@ -124,10 +117,7 @@
                         <mat-select
                             [(value)]="
                                 currentlyConfiguredWidget.visualizationConfig
-                                    .displayType[
-                                    field.fullDbName +
-                                        field.sourceIndex.toString()
-                                ]
+                                    .displayType[getFieldKey(field)]
                             "
                             (selectionChange)="viewRefreshEmitter.emit()"
                         >
@@ -163,10 +153,7 @@
                         <mat-select
                             [(value)]="
                                 currentlyConfiguredWidget.visualizationConfig
-                                    .chosenAxis[
-                                    field.fullDbName +
-                                        field.sourceIndex.toString()
-                                ]
+                                    .chosenAxis[getFieldKey(field)]
                             "
                             (selectionChange)="viewRefreshEmitter.emit()"
                         >
@@ -180,6 +167,47 @@
                     </mat-form-field>
                 </div>
             </div>
+            @if (hasGrouping(field)) {
+                <div fxLayout="row" fxFlex="100">
+                    <div fxFlex="30" fxLayoutAlign="start center">
+                        <small>{{ 'Grouped colors' | translate }}</small>
+                    </div>
+                    <div fxFlex="70" fxLayoutAlign="start center">
+                        <mat-form-field
+                            appearance="outline"
+                            color="accent"
+                            class="w-100"
+                        >
+                            <mat-select
+                                [value]="getGroupedColorMode(field)"
+                                (selectionChange)="
+                                    setGroupedColorMode(field, $event.value)
+                                "
+                            >
+                                <mat-option [value]="'stable_palette'">{{
+                                    'Stable palette' | translate
+                                }}</mat-option>
+                                <mat-option [value]="'custom_mapping'">{{
+                                    'Custom color mapping' | translate
+                                }}</mat-option>
+                            </mat-select>
+                        </mat-form-field>
+                    </div>
+                </div>
+                @if (getGroupedColorMode(field) === 'custom_mapping') {
+                    <sp-color-mapping-options-config
+                        class="mt-sm"
+                        [colorMapping]="getGroupedColorMappings(field)"
+                        [showCustomColorMapping]="true"
+                        [showToggle]="false"
+                        (colorMappingChange)="
+                            setGroupedColorMappings(field, $event)
+                        "
+                        (viewRefreshEmitter)="viewRefreshEmitter.emit()"
+                    >
+                    </sp-color-mapping-options-config>
+                }
+            }
         </div>
     }
 </div>
diff --git 
a/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.ts
 
b/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.ts
index f15ce41305..fc23ef2c40 100644
--- 
a/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.ts
+++ 
b/ui/src/app/chart-shared/components/chart-config/select-color-properties-config/time-series-item-config/time-series-item-config.component.ts
@@ -18,16 +18,19 @@
 
 import { Component, EventEmitter, Input, Output } from '@angular/core';
 import { DataExplorerField } from '@streampipes/platform-services';
-import { TimeSeriesChartWidgetModel } from 
'../../../charts/time-series-chart/model/time-series-chart-widget.model';
 import {
-    DefaultFlexDirective,
-    DefaultLayoutAlignDirective,
-    DefaultLayoutDirective,
-    DefaultLayoutGapDirective,
+    TimeSeriesChartWidgetModel,
+    TimeSeriesGroupColorMapping,
+    TimeSeriesGroupedColorMode,
+} from 
'../../../charts/time-series-chart/model/time-series-chart-widget.model';
+import {
+    FlexDirective,
+    LayoutAlignDirective,
+    LayoutGapDirective,
 } from '@ngbracket/ngx-layout/flex';
 import { MatCheckbox } from '@angular/material/checkbox';
 import { NgStyle } from '@angular/common';
-import { DefaultStyleDirective } from '@ngbracket/ngx-layout/extended';
+import { StyleDirective } from '@ngbracket/ngx-layout/extended';
 import { MatIconButton } from '@angular/material/button';
 import { MatTooltip } from '@angular/material/tooltip';
 import { ColorPickerDirective } from 'ngx-color-picker';
@@ -36,21 +39,23 @@ import { MatInput } from '@angular/material/input';
 import { FormsModule } from '@angular/forms';
 import { MatOption, MatSelect } from '@angular/material/select';
 import { TranslatePipe } from '@ngx-translate/core';
+import { ColorMappingOptionsConfigComponent } from 
'../../color-mapping-options-config/color-mapping-options-config.component';
+import { LayoutDirective } from '@ngbracket/ngx-layout';
 
 @Component({
     selector: 'sp-time-series-item-config',
     templateUrl: './time-series-item-config.component.html',
     styleUrls: ['./time-series-item-config.component.scss'],
     imports: [
-        DefaultLayoutDirective,
-        DefaultLayoutAlignDirective,
+        LayoutGapDirective,
+        LayoutAlignDirective,
         MatCheckbox,
-        DefaultFlexDirective,
+        FlexDirective,
         NgStyle,
-        DefaultStyleDirective,
+        StyleDirective,
         MatIconButton,
         MatTooltip,
-        DefaultLayoutGapDirective,
+        LayoutGapDirective,
         ColorPickerDirective,
         MatFormField,
         MatInput,
@@ -58,6 +63,8 @@ import { TranslatePipe } from '@ngx-translate/core';
         MatSelect,
         MatOption,
         TranslatePipe,
+        ColorMappingOptionsConfigComponent,
+        LayoutDirective,
     ],
 })
 export class SpTimeseriesItemConfigComponent {
@@ -122,4 +129,73 @@ export class SpTimeseriesItemConfigComponent {
         ] = searchValue;
         this.viewRefreshEmitter.emit();
     }
+
+    getFieldKey(field: DataExplorerField): string {
+        return field.fullDbName + field.sourceIndex.toString();
+    }
+
+    hasGrouping(field: DataExplorerField): boolean {
+        return (
+            this.currentlyConfiguredWidget.dataConfig.sourceConfigs[
+                field.sourceIndex
+            ]?.queryConfig.groupBy?.some(
+                groupByField => groupByField.selected,
+            ) ?? false
+        );
+    }
+
+    getGroupedColorMode(field: DataExplorerField): TimeSeriesGroupedColorMode {
+        this.currentlyConfiguredWidget.visualizationConfig.groupedColorMode ??=
+            {};
+        return (
+            
this.currentlyConfiguredWidget.visualizationConfig.groupedColorMode[
+                this.getFieldKey(field)
+            ] ?? 'stable_palette'
+        );
+    }
+
+    setGroupedColorMode(
+        field: DataExplorerField,
+        mode: TimeSeriesGroupedColorMode,
+    ): void {
+        this.currentlyConfiguredWidget.visualizationConfig.groupedColorMode ??=
+            {};
+        this.currentlyConfiguredWidget.visualizationConfig.groupedColorMode[
+            this.getFieldKey(field)
+        ] = mode;
+        this.viewRefreshEmitter.emit();
+    }
+
+    getGroupedColorMappings(
+        field: DataExplorerField,
+    ): TimeSeriesGroupColorMapping[] {
+        const fieldKey = this.getFieldKey(field);
+        
this.currentlyConfiguredWidget.visualizationConfig.groupedColorMappings ??=
+            {};
+        const mappings =
+            this.currentlyConfiguredWidget.visualizationConfig
+                .groupedColorMappings[fieldKey];
+
+        if (!mappings) {
+            
this.currentlyConfiguredWidget.visualizationConfig.groupedColorMappings[
+                fieldKey
+            ] = [];
+            return this.currentlyConfiguredWidget.visualizationConfig
+                .groupedColorMappings[fieldKey];
+        }
+
+        return mappings;
+    }
+
+    setGroupedColorMappings(
+        field: DataExplorerField,
+        mappings: TimeSeriesGroupColorMapping[],
+    ): void {
+        
this.currentlyConfiguredWidget.visualizationConfig.groupedColorMappings ??=
+            {};
+        
this.currentlyConfiguredWidget.visualizationConfig.groupedColorMappings[
+            this.getFieldKey(field)
+        ] = mappings;
+        this.viewRefreshEmitter.emit();
+    }
 }
diff --git 
a/ui/src/app/chart-shared/components/chart-container/chart-container.component.html
 
b/ui/src/app/chart-shared/components/chart-container/chart-container.component.html
index d0ac4576b7..7f1ca237ef 100644
--- 
a/ui/src/app/chart-shared/components/chart-container/chart-container.component.html
+++ 
b/ui/src/app/chart-shared/components/chart-container/chart-container.component.html
@@ -22,6 +22,11 @@
         background: configuredWidget.baseAppearanceConfig.backgroundColor,
         color: configuredWidget.baseAppearanceConfig.textColor,
         height: '100%',
+        border: !dataViewMode
+            ? (dashboardChartOverrides?.borderThickness || 0) +
+              'px solid var(--color-bg-2)'
+            : 'none',
+        boxSizing: 'border-box',
     }"
     [attr.data-cy]="
         'widget-' + configuredWidget.baseAppearanceConfig.widgetTitle
@@ -59,6 +64,8 @@
                                 type="text"
                                 [(ngModel)]="tempName"
                                 class="name-input"
+                                (keydown.enter)="saveName()"
+                                (keydown.escape)="cancelEditingName()"
                             />
 
                             <button mat-icon-button (click)="saveName()">
diff --git 
a/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts
 
b/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts
index adfe34871d..5206d4f22a 100644
--- 
a/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts
+++ 
b/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts
@@ -31,6 +31,7 @@ import {
     SimpleChanges,
     ViewChild,
     inject,
+    NgZone,
 } from '@angular/core';
 import {
     ClientDashboardItem,
@@ -122,6 +123,7 @@ export class ChartContainerComponent
     private nameChangeService = inject(NameChangeService);
     private el = inject<ElementRef<HTMLDivElement>>(ElementRef);
     private resizeService = inject(ResizeService);
+    private ngZone = inject(NgZone);
 
     @ViewChild('menuTrigger') menu: MatMenuTrigger;
     @ViewChild('timeSelectorMenu')
@@ -221,10 +223,12 @@ export class ChartContainerComponent
                 const { width, height } =
                     entries[entries.length - 1].contentRect;
 
-                this.resizeService.notify({
-                    width,
-                    height,
-                    widgetId: this.dashboardItem?.id || undefined,
+                this.ngZone.run(() => {
+                    this.resizeService.notify({
+                        width,
+                        height,
+                        widgetId: this.dashboardItem?.id || undefined,
+                    });
                 });
             }, 100);
         });
diff --git 
a/ui/src/app/chart-shared/components/charts/time-series-chart/config/time-series-chart-widget-config.component.ts
 
b/ui/src/app/chart-shared/components/charts/time-series-chart/config/time-series-chart-widget-config.component.ts
index 1b33620f18..2c8bb69567 100644
--- 
a/ui/src/app/chart-shared/components/charts/time-series-chart/config/time-series-chart-widget-config.component.ts
+++ 
b/ui/src/app/chart-shared/components/charts/time-series-chart/config/time-series-chart-widget-config.component.ts
@@ -84,6 +84,12 @@ export class TimeSeriesChartWidgetConfigComponent extends 
BaseWidgetConfig<
             this.currentlyConfiguredWidget.visualizationConfig.displayType;
         const currentAxis =
             this.currentlyConfiguredWidget.visualizationConfig.chosenAxis;
+        const currentGroupedColorMode =
+            this.currentlyConfiguredWidget.visualizationConfig
+                .groupedColorMode ?? {};
+        const currentGroupedColorMappings =
+            this.currentlyConfiguredWidget.visualizationConfig
+                .groupedColorMappings ?? {};
 
         const lenBefore = Object.keys(currentAxis).length;
 
@@ -94,6 +100,8 @@ export class TimeSeriesChartWidgetConfigComponent extends 
BaseWidgetConfig<
                 currentNames[name] = field.fullDbName;
                 currentTypes[name] = 'lines';
                 currentAxis[name] = 'left';
+                currentGroupedColorMode[name] = 'stable_palette';
+                currentGroupedColorMappings[name] = [];
             }
         });
 
@@ -105,6 +113,10 @@ export class TimeSeriesChartWidgetConfigComponent extends 
BaseWidgetConfig<
             currentTypes;
         this.currentlyConfiguredWidget.visualizationConfig.chosenAxis =
             currentAxis;
+        this.currentlyConfiguredWidget.visualizationConfig.groupedColorMode =
+            currentGroupedColorMode;
+        
this.currentlyConfiguredWidget.visualizationConfig.groupedColorMappings =
+            currentGroupedColorMappings;
 
         this.triggerViewRefresh();
     }
@@ -140,6 +152,16 @@ export class TimeSeriesChartWidgetConfigComponent extends 
BaseWidgetConfig<
             this.numericPlusBooleanFields,
             () => 'left',
         );
+        config.groupedColorMode = this.getConfigOrDefault(
+            config.groupedColorMode,
+            this.numericPlusBooleanFields,
+            () => 'stable_palette',
+        );
+        config.groupedColorMappings = this.getConfigOrDefault(
+            config.groupedColorMappings,
+            this.numericPlusBooleanFields,
+            () => [],
+        );
 
         config.yKeys = [];
         config.selectedTimeSeriesChartProperties =
@@ -165,11 +187,11 @@ export class TimeSeriesChartWidgetConfigComponent extends 
BaseWidgetConfig<
         };
     }
 
-    private getConfigOrDefault(
-        config: Record<string, any>,
+    private getConfigOrDefault<T>(
+        config: Record<string, T>,
         availableFields: DataExplorerField[],
-        getDefaultValue: (field: DataExplorerField, index: number) => string,
-    ) {
+        getDefaultValue: (field: DataExplorerField, index: number) => T,
+    ): Record<string, T> {
         const fieldKeys = availableFields.map(
             f => f.fullDbName + f.sourceIndex,
         );
diff --git 
a/ui/src/app/chart-shared/components/charts/time-series-chart/model/time-series-chart-widget.model.ts
 
b/ui/src/app/chart-shared/components/charts/time-series-chart/model/time-series-chart-widget.model.ts
index 4cc95bc86f..24a3a89bb4 100644
--- 
a/ui/src/app/chart-shared/components/charts/time-series-chart/model/time-series-chart-widget.model.ts
+++ 
b/ui/src/app/chart-shared/components/charts/time-series-chart/model/time-series-chart-widget.model.ts
@@ -27,6 +27,14 @@ import {
     DataExplorerVisConfig,
 } from '../../../../models/dataview-dashboard.model';
 
+export type TimeSeriesGroupedColorMode = 'stable_palette' | 'custom_mapping';
+
+export interface TimeSeriesGroupColorMapping {
+    value: string;
+    label: string;
+    color: string;
+}
+
 export interface TimeSeriesChartVisConfig extends DataExplorerVisConfig {
     selectedTimeSeriesChartProperties: DataExplorerField[];
     selectedBackgroundColorProperty?: EventPropertyUnion;
@@ -39,6 +47,8 @@ export interface TimeSeriesChartVisConfig extends 
DataExplorerVisConfig {
     displayName: { [id: string]: string };
     displayType: { [id: string]: string };
     chosenAxis: { [id: string]: string };
+    groupedColorMode: { [id: string]: TimeSeriesGroupedColorMode };
+    groupedColorMappings: { [id: string]: TimeSeriesGroupColorMapping[] };
     leftAxis: AxisConfig;
     rightAxis: AxisConfig;
     showSpike: boolean;
diff --git 
a/ui/src/app/chart-shared/components/charts/time-series-chart/sp-timeseries-renderer.service.ts
 
b/ui/src/app/chart-shared/components/charts/time-series-chart/sp-timeseries-renderer.service.ts
index cdc652878a..ebd417bfa5 100644
--- 
a/ui/src/app/chart-shared/components/charts/time-series-chart/sp-timeseries-renderer.service.ts
+++ 
b/ui/src/app/chart-shared/components/charts/time-series-chart/sp-timeseries-renderer.service.ts
@@ -27,7 +27,11 @@ import { Injectable } from '@angular/core';
 import { TimeSeriesChartWidgetModel } from 
'./model/time-series-chart-widget.model';
 import { DataExplorerField } from '@streampipes/platform-services';
 import { SpBaseEchartsRenderer } from 
'../../../echarts-renderer/base-echarts-renderer';
-import { GeneratedDataset, WidgetSize } from '../../../models/dataset.model';
+import {
+    GeneratedDataset,
+    TagValue,
+    WidgetSize,
+} from '../../../models/dataset.model';
 import {
     AxisConfig,
     WidgetBaseAppearanceConfig,
@@ -71,12 +75,18 @@ export class SpTimeseriesRendererService extends 
SpBaseEchartsRenderer<TimeSerie
                         widgetConfig.visualizationConfig.displayName[
                             field.fullDbName + field.sourceIndex
                         ];
+                    const mappedGroupLabel = 
this.colorizationService.findLabel(
+                        widgetConfig.visualizationConfig,
+                        field,
+                        tag,
+                    );
                     const seriesName =
                         dataset.groupedDatasets.length > 0
-                            ? this.echartsUtilsService.toTagString(
+                            ? (mappedGroupLabel ??
+                              this.echartsUtilsService.toTagString(
                                   tag,
                                   displayName,
-                              )
+                              ))
                             : displayName;
                     const fieldIndex = rawDatasetDimensions.indexOf(
                         field.fullDbName,
@@ -85,10 +95,10 @@ export class SpTimeseriesRendererService extends 
SpBaseEchartsRenderer<TimeSerie
                         this.makeSeries(
                             widgetConfig,
                             i,
-                            groupIndex,
                             field,
                             fieldIndex,
                             seriesName,
+                            tag,
                         ),
                     );
                 }
@@ -136,19 +146,19 @@ export class SpTimeseriesRendererService extends 
SpBaseEchartsRenderer<TimeSerie
     makeSeries(
         widgetConfig: TimeSeriesChartWidgetModel,
         datasetIndex: number,
-        groupIndex: number,
         field: DataExplorerField,
         fieldIndex: number,
         seriesName: string,
+        tag?: TagValue,
     ): SeriesOption {
         const seriesType = this.makeSeriesType(
             widgetConfig.visualizationConfig.displayType,
             field,
         );
         const color = this.colorizationService.makeColor(
-            widgetConfig.visualizationConfig.chosenColor,
+            widgetConfig.visualizationConfig,
             field,
-            groupIndex,
+            tag,
         );
 
         const series = {
diff --git 
a/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.html
 
b/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.html
index e1b64c93de..0e2bbc2aae 100644
--- 
a/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.html
+++ 
b/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.html
@@ -39,6 +39,7 @@
         <div
             class="tl-container"
             [ngClass]="selectedToShowValue ? 'no-border-container' : ''"
+            [style.width.px]="trafficLightShellWidth"
         >
             <div
                 class="light"
@@ -53,9 +54,16 @@
                 [ngClass]="{ 'light-green': activeClass === 'green' }"
             ></div>
         </div>
-        <div class="light-value-container">
+        <div
+            class="light-value-container"
+            [style.width.px]="trafficLightShellWidth"
+        >
             @if (selectedToShowValue) {
-                <div class="light-value text-md">
+                <div
+                    class="light-value"
+                    [style.fontSize.px]="trafficLightValueFontSize"
+                    [style.lineHeight.px]="trafficLightValueFontSize"
+                >
                     {{ displayed_value }}
                 </div>
             }
diff --git 
a/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.scss
 
b/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.scss
index 160ee935a7..38c24f9ddc 100644
--- 
a/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.scss
+++ 
b/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.scss
@@ -20,13 +20,15 @@
 }
 
 .traffic-light-wrapper {
-    display: inline-flex;
+    display: flex;
     flex-direction: column;
     align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
 }
 
 .tl-container {
-    height: 90%;
     aspect-ratio: 1 / 3;
 
     box-sizing: border-box;
@@ -41,7 +43,7 @@
 }
 
 .no-border-container {
-    border-radius: 10px 10px 0 0;
+    border-radius: 10px;
 }
 
 .light {
@@ -78,20 +80,16 @@
 }
 
 .light-value-container {
-    background-color: #222;
-    border-radius: 10px;
-    width: 100%;
+    box-sizing: border-box;
+    overflow: hidden;
+    margin-top: 10px;
 }
 
 .light-value {
-    color: #fff;
+    width: 100%;
     font-weight: bold;
     text-align: center;
-    background-color: #222;
-    padding: 10px;
-
-    border-bottom-left-radius: 10px;
-    border-bottom-right-radius: 10px;
-    border-top-left-radius: 0;
-    border-top-right-radius: 0;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: clip;
 }
diff --git 
a/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.ts
 
b/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.ts
index 99759449e1..b7a8751f0a 100644
--- 
a/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.ts
+++ 
b/ui/src/app/chart-shared/components/charts/traffic-light/traffic-light-widget.component.ts
@@ -63,10 +63,18 @@ export class TrafficLightWidgetComponent
 
     activeClass = 'red';
     displayed_value: string;
+    currentValue?: number;
+    widgetWidth = 0;
+    widgetHeight = 0;
+    trafficLightShellWidth = 88;
+    trafficLightValueFontSize = 28;
 
     ngOnInit(): void {
         super.ngOnInit();
+        this.widgetWidth = this.initialSize.width;
+        this.widgetHeight = this.initialSize.height;
         this.updateSettings();
+        this.updateTrafficLightMetrics();
     }
 
     updateSettings(): void {
@@ -84,7 +92,7 @@ export class TrafficLightWidgetComponent
 
     getTrafficLightColor(): void {
         const value = this.row[0][this.fieldIndex];
-        this.displayed_value = value.toFixed(2);
+        this.currentValue = value;
 
         if (this.isInOkRange(value)) {
             this.activeClass = 'green';
@@ -93,6 +101,8 @@ export class TrafficLightWidgetComponent
         } else {
             this.activeClass = 'red';
         }
+
+        this.updateTrafficLightMetrics();
     }
 
     exceedsThreshold(value) {
@@ -159,7 +169,11 @@ export class TrafficLightWidgetComponent
         }
     }
 
-    onResize(_width: number, _heigth: number) {}
+    onResize(width: number, height: number) {
+        this.widgetWidth = width;
+        this.widgetHeight = height;
+        this.updateTrafficLightMetrics();
+    }
 
     handleUpdatedFields(
         addedFields: DataExplorerField[],
@@ -182,4 +196,78 @@ export class TrafficLightWidgetComponent
         this.fieldToObserve();
         this.refreshView();
     }
+
+    private updateTrafficLightMetrics(): void {
+        const availableWidth = this.widgetWidth > 0 ? this.widgetWidth : 280;
+        const availableHeight = this.widgetHeight > 0 ? this.widgetHeight : 
320;
+        const reservedValueHeight = this.selectedToShowValue
+            ? Math.max(36, availableHeight * 0.12)
+            : 0;
+        const usableHeight = Math.max(
+            availableHeight - reservedValueHeight,
+            120,
+        );
+        const maxWidthByHeight = usableHeight / 3.25;
+        const preferredWidth = availableWidth * 0.4;
+        const maxShellWidth = Math.min(
+            Math.max(availableWidth * 0.55, 140),
+            220,
+        );
+        let shellWidth = this.clamp(
+            Math.min(preferredWidth, maxWidthByHeight),
+            72,
+            maxShellWidth,
+        );
+
+        const valueText = this.formatDisplayedValue(this.currentValue);
+        const desiredFontSize = this.clamp(
+            Math.round(shellWidth * 0.34),
+            18,
+            42,
+        );
+        const estimatedRequiredWidth =
+            valueText.length * desiredFontSize * 0.58 + 2 * 12;
+
+        shellWidth = Math.max(
+            shellWidth,
+            Math.min(estimatedRequiredWidth, maxShellWidth),
+        );
+        shellWidth = Math.min(
+            shellWidth,
+            availableWidth * 0.82,
+            maxWidthByHeight,
+            maxShellWidth,
+        );
+
+        this.trafficLightShellWidth = Math.round(shellWidth);
+        this.displayed_value = this.formatDisplayedValue(this.currentValue);
+        this.trafficLightValueFontSize = this.computeValueFontSize(
+            this.trafficLightShellWidth,
+            this.displayed_value,
+        );
+    }
+
+    private computeValueFontSize(width: number, value: string): number {
+        if (!value) {
+            return 28;
+        }
+
+        return this.clamp(
+            Math.floor((width - 24) / Math.max(value.length * 0.58, 1)),
+            12,
+            42,
+        );
+    }
+
+    private formatDisplayedValue(value: number | undefined): string {
+        if (value === undefined || value === null || Number.isNaN(value)) {
+            return '';
+        }
+
+        return value.toFixed(2);
+    }
+
+    private clamp(value: number, min: number, max: number): number {
+        return Math.max(min, Math.min(max, value));
+    }
 }
diff --git a/ui/src/app/chart-shared/models/dataview-dashboard.model.ts 
b/ui/src/app/chart-shared/models/dataview-dashboard.model.ts
index 5a3c7ab498..b749ad6d95 100644
--- a/ui/src/app/chart-shared/models/dataview-dashboard.model.ts
+++ b/ui/src/app/chart-shared/models/dataview-dashboard.model.ts
@@ -57,6 +57,7 @@ export interface BaseWidgetData<T extends 
DataExplorerWidgetModel> {
 
 export interface DashboardChartOverrides {
     hideToolbox?: boolean;
+    borderThickness?: number;
 }
 
 export interface ObservableGenerator {
diff --git a/ui/src/app/chart-shared/services/chart-colorization.service.ts 
b/ui/src/app/chart-shared/services/chart-colorization.service.ts
index 35817bc944..eb2a3057b1 100644
--- a/ui/src/app/chart-shared/services/chart-colorization.service.ts
+++ b/ui/src/app/chart-shared/services/chart-colorization.service.ts
@@ -18,53 +18,95 @@
 
 import { Injectable } from '@angular/core';
 import { DataExplorerField } from '@streampipes/platform-services';
+import {
+    TimeSeriesChartVisConfig,
+    TimeSeriesGroupColorMapping,
+} from 
'../components/charts/time-series-chart/model/time-series-chart-widget.model';
+import { TagValue } from '../models/dataset.model';
+import { ColorMappingService } from './color-mapping.service';
 
 @Injectable({ providedIn: 'root' })
 export class ChartColorizationService {
+    constructor(private colorMappingService: ColorMappingService) {}
+
     makeColor(
-        chosenColor: Record<string, string>,
+        visualizationConfig: TimeSeriesChartVisConfig,
         field: DataExplorerField,
-        groupIndex: number,
+        tag?: TagValue,
     ): string {
-        const baseColor = chosenColor[field.fullDbName + field.sourceIndex];
-        return this.adjustColorBrightness(baseColor, groupIndex);
+        const fieldKey = field.fullDbName + field.sourceIndex;
+        const baseColor = visualizationConfig.chosenColor[fieldKey];
+
+        if (!tag || tag.values.length === 0) {
+            return baseColor;
+        }
+
+        const groupKey = this.makeGroupKey(tag);
+        const fieldMappings =
+            visualizationConfig.groupedColorMappings?.[fieldKey] ?? [];
+
+        if (
+            visualizationConfig.groupedColorMode?.[fieldKey] ===
+            'custom_mapping'
+        ) {
+            return (
+                this.findMapping(fieldMappings, tag, groupKey)?.color ??
+                this.colorMappingService.getDefaultColor(groupKey)
+            );
+        }
+
+        return this.colorMappingService.getDefaultColor(
+            `${fieldKey}:${groupKey}`,
+        );
     }
 
-    private adjustColorBrightness(color: string, groupIndex: number): string {
-        const amount = groupIndex * 15;
-        let { r, g, b } = this.hexToRgb(color);
+    findLabel(
+        visualizationConfig: TimeSeriesChartVisConfig,
+        field: DataExplorerField,
+        tag?: TagValue,
+    ): string | undefined {
+        if (!tag || tag.values.length === 0) {
+            return undefined;
+        }
 
-        r = this.clampColorValue(r + amount);
-        g = this.clampColorValue(g + amount);
-        b = this.clampColorValue(b + amount);
+        const fieldKey = field.fullDbName + field.sourceIndex;
+        const fieldMappings =
+            visualizationConfig.groupedColorMappings?.[fieldKey] ?? [];
+        const mapping = this.findMapping(
+            fieldMappings,
+            tag,
+            this.makeGroupKey(tag),
+        );
 
-        return this.rgbToHex(r, g, b);
+        return mapping?.label?.trim() ? mapping.label : undefined;
     }
 
-    private hexToRgb(hex: string): { r: number; g: number; b: number } {
-        const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
-        return result
-            ? {
-                  r: parseInt(result[1], 16),
-                  g: parseInt(result[2], 16),
-                  b: parseInt(result[3], 16),
-              }
-            : null;
+    private findMapping(
+        mappings: TimeSeriesGroupColorMapping[],
+        tag: TagValue,
+        groupKey: string,
+    ): TimeSeriesGroupColorMapping | undefined {
+        const candidateKeys = this.makeCandidateKeys(tag, groupKey);
+        return mappings.find(mapping => candidateKeys.includes(mapping.value));
     }
 
-    private rgbToHex(r: number, g: number, b: number): string {
-        return (
-            '#' +
-            [r, g, b]
-                .map(x => {
-                    const hex = x.toString(16);
-                    return hex.length === 1 ? '0' + hex : hex;
-                })
-                .join('')
-        );
+    private makeGroupKey(tag: TagValue): string {
+        return tag.tagKeys
+            .map((key, index) => `${key}=${tag.values[index]}`)
+            .join(', ');
     }
 
-    private clampColorValue(value: number): number {
-        return Math.max(0, Math.min(255, value));
+    private makeCandidateKeys(tag: TagValue, groupKey: string): string[] {
+        const valueStrings = tag.values.map(value => String(value));
+        const keyValuePairs = tag.tagKeys.map(
+            (key, index) => `${key}=${valueStrings[index]}`,
+        );
+
+        return [
+            groupKey,
+            valueStrings.join(', '),
+            ...keyValuePairs,
+            ...valueStrings,
+        ];
     }
 }
diff --git 
a/ui/src/app/dashboard/components/panel/dashboard-panel.component.html 
b/ui/src/app/dashboard/components/panel/dashboard-panel.component.html
index bc24b024d0..86eda57b85 100644
--- a/ui/src/app/dashboard/components/panel/dashboard-panel.component.html
+++ b/ui/src/app/dashboard/components/panel/dashboard-panel.component.html
@@ -222,6 +222,36 @@
                                                         class="mt-10"
                                                         fxLayout="column"
                                                     >
+                                                        <sp-form-field
+                                                            [level]="3"
+                                                            [label]="
+                                                                'Widget border 
thickness'
+                                                                    | translate
+                                                            "
+                                                        >
+                                                            <mat-form-field
+                                                                
floatLabel="auto"
+                                                                color="accent"
+                                                            >
+                                                                <input
+                                                                    matInput
+                                                                    
type="number"
+                                                                    min="0"
+                                                                    max="12"
+                                                                    [ngModel]="
+                                                                        
dashboard
+                                                                            
.dashboardGeneralSettings
+                                                                            
.chartOverrides
+                                                                            
.borderThickness
+                                                                    "
+                                                                    
(ngModelChange)="
+                                                                        
onBorderThicknessChange(
+                                                                            
$event
+                                                                        )
+                                                                    "
+                                                                />
+                                                            </mat-form-field>
+                                                        </sp-form-field>
                                                         <mat-checkbox
                                                             [(ngModel)]="
                                                                 dashboard
diff --git a/ui/src/app/dashboard/components/panel/dashboard-panel.component.ts 
b/ui/src/app/dashboard/components/panel/dashboard-panel.component.ts
index 0f9143b900..4f1ce4a9f9 100644
--- a/ui/src/app/dashboard/components/panel/dashboard-panel.component.ts
+++ b/ui/src/app/dashboard/components/panel/dashboard-panel.component.ts
@@ -301,6 +301,16 @@ export class DashboardPanelComponent
         };
     }
 
+    onBorderThicknessChange(value: number | string): void {
+        const numericValue = Number(value);
+        this.dashboard.dashboardGeneralSettings.chartOverrides = {
+            ...this.dashboard.dashboardGeneralSettings.chartOverrides,
+            borderThickness: Number.isFinite(numericValue)
+                ? Math.max(0, Math.min(12, numericValue))
+                : 0,
+        };
+    }
+
     private getNextWidgetY(): number {
         if (!this.dashboard?.widgets?.length) {
             return 0;
@@ -675,6 +685,7 @@ export class DashboardPanelComponent
         this.dashboard.dashboardGeneralSettings.globalTimeEnabled ??= true;
         this.dashboard.dashboardGeneralSettings.chartOverrides ??= {};
         this.dashboard.dashboardGeneralSettings.chartOverrides.hideToolbox ??= 
false;
+        this.dashboard.dashboardGeneralSettings.chartOverrides.borderThickness 
??= 0;
         this.dashboard.dashboardGeneralSettings.gridRowHeightPx ??= 90;
     }
 
diff --git 
a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.html
 
b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.html
index 747ee94bd1..ff43ce6e24 100644
--- 
a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.html
+++ 
b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.html
@@ -111,6 +111,23 @@
                     </mat-form-field>
                 </sp-form-field>
                 <div class="mt-10" fxLayout="column">
+                    <sp-form-field
+                        [level]="3"
+                        [label]="'Widget border thickness' | translate"
+                    >
+                        <mat-form-field floatLabel="auto" color="accent">
+                            <input
+                                matInput
+                                type="number"
+                                min="0"
+                                max="12"
+                                [(ngModel)]="
+                                    dashboard.dashboardGeneralSettings
+                                        .chartOverrides.borderThickness
+                                "
+                            />
+                        </mat-form-field>
+                    </sp-form-field>
                     <mat-checkbox
                         [(ngModel)]="
                             
dashboard.dashboardGeneralSettings.globalTimeEnabled
diff --git 
a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
 
b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
index 8b27be9d00..eae9b82465 100644
--- 
a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
+++ 
b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
@@ -116,6 +116,12 @@ export class EditDashboardDialogComponent implements 
OnInit {
         ) {
             this.dashboard.dashboardGeneralSettings.chartOverrides.hideToolbox 
= false;
         }
+        if (
+            this.dashboard.dashboardGeneralSettings.chartOverrides
+                .borderThickness === undefined
+        ) {
+            
this.dashboard.dashboardGeneralSettings.chartOverrides.borderThickness = 0;
+        }
         if (
             this.dashboard.dashboardGeneralSettings.gridRowHeightPx ===
             undefined

Reply via email to