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

riemer pushed a commit to branch add-3d-time-series-chart
in repository https://gitbox.apache.org/repos/asf/streampipes.git

commit a7148715e54bd85fc91c3a9db9f81cab5e7b3523
Author: Dominik Riemer <[email protected]>
AuthorDate: Thu Jun 19 19:27:28 2025 +0200

    feat: Add 3D time-series chart
---
 ui/deployment/app.module.mst                       |   1 +
 ui/package-lock.json                               |  18 ++
 ui/package.json                                    |   1 +
 .../time-series-3d-widget-config.component.html    |  77 +++++++
 .../time-series-3d-widget-config.component.ts      |  64 ++++++
 .../model/time-series-3d-widget.model.ts           |  35 +++
 .../time-series-3d-widget.component.ts             | 239 +++++++++++++++++++++
 .../data-explorer-shared.module.ts                 |   4 +
 .../registry/data-explorer-chart-registry.ts       |  14 ++
 9 files changed, 453 insertions(+)

diff --git a/ui/deployment/app.module.mst b/ui/deployment/app.module.mst
index 29748c1a12..c27d5a8d0f 100644
--- a/ui/deployment/app.module.mst
+++ b/ui/deployment/app.module.mst
@@ -57,6 +57,7 @@ import { MapTransform } from 
'./core-ui/echarts-transform/map.transform';
 import { MarkdownModule } from 'ngx-markdown';
 
 import * as echarts from 'echarts';
+import 'echarts-gl';
 import * as transform from 'echarts-simple-transform';
 
 echarts.registerTransform(transform.aggregate);
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 569e56ebc1..c63db6e709 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -38,6 +38,7 @@
         "dagre": "0.8.5",
         "date-fns": "^3.6.0",
         "echarts": "^5.6.0",
+        "echarts-gl": "^2.0.9",
         "echarts-simple-transform": "^1.0.0",
         "file-saver": "2.0.5",
         "jquery": "^3.7.0",
@@ -8783,6 +8784,11 @@
         "node": ">=8"
       }
     },
+    "node_modules/claygl": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz";,
+      "integrity": 
"sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
+    },
     "node_modules/clean-stack": {
       "version": "2.2.0",
       "resolved": 
"https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz";,
@@ -10731,6 +10737,18 @@
         "zrender": "5.6.1"
       }
     },
+    "node_modules/echarts-gl": {
+      "version": "2.0.9",
+      "resolved": 
"https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz";,
+      "integrity": 
"sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==",
+      "dependencies": {
+        "claygl": "^1.2.1",
+        "zrender": "^5.1.1"
+      },
+      "peerDependencies": {
+        "echarts": "^5.1.2"
+      }
+    },
     "node_modules/echarts-simple-transform": {
       "version": "1.0.0",
       "resolved": 
"https://registry.npmjs.org/echarts-simple-transform/-/echarts-simple-transform-1.0.0.tgz";,
diff --git a/ui/package.json b/ui/package.json
index db299c42eb..cf9b3890be 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -59,6 +59,7 @@
     "dagre": "0.8.5",
     "date-fns": "^3.6.0",
     "echarts": "^5.6.0",
+    "echarts-gl": "^2.0.9",
     "echarts-simple-transform": "^1.0.0",
     "file-saver": "2.0.5",
     "jquery": "^3.7.0",
diff --git 
a/ui/src/app/data-explorer-shared/components/charts/time-series-3d/config/time-series-3d-widget-config.component.html
 
b/ui/src/app/data-explorer-shared/components/charts/time-series-3d/config/time-series-3d-widget-config.component.html
new file mode 100644
index 0000000000..34742a06a9
--- /dev/null
+++ 
b/ui/src/app/data-explorer-shared/components/charts/time-series-3d/config/time-series-3d-widget-config.component.html
@@ -0,0 +1,77 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You under the Apache License, Version 2.0
+  ~ (the "License"); you may not use this file except in compliance with
+  ~ the License.  You may obtain a copy of the License at
+  ~
+  ~    http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  -->
+
+<sp-visualization-config-outer
+    [configurationValid]="
+        currentlyConfiguredWidget.visualizationConfig.configurationValid
+    "
+>
+    <sp-configuration-box [title]="'Chart Type' | translate">
+        <mat-form-field appearance="outline" color="accent" class="w-100">
+            <mat-select
+                [(value)]="
+                    currentlyConfiguredWidget.visualizationConfig.chartType
+                "
+                (selectionChange)="triggerViewRefresh()"
+            >
+                <mat-option [value]="'bar3D'"
+                    >{{ 'Bar' | translate }}
+                </mat-option>
+                <mat-option [value]="'scatter3D'"
+                    >{{ 'Scatter' | translate }}
+                </mat-option>
+            </mat-select>
+        </mat-form-field>
+    </sp-configuration-box>
+    <sp-configuration-box [title]="'Field' | translate">
+        <sp-select-single-property-config
+            [availableProperties]="fieldProvider.numericFields"
+            [selectedProperty]="
+                currentlyConfiguredWidget.visualizationConfig.selectedProperty
+            "
+            (changeSelectedProperty)="setSelectedProperty($event)"
+        >
+        </sp-select-single-property-config>
+    </sp-configuration-box>
+    @if (
+        currentlyConfiguredWidget.visualizationConfig.chartType === 'scatter3D'
+    ) {
+        <sp-configuration-box [title]="'Settings' | translate">
+            <div fxFlex="30" fxLayoutAlign="start center">
+                <small>{{ 'Scatter width' | translate }}</small>
+            </div>
+            <div fxFlex="70" fxLayoutAlign="start center">
+                <mat-form-field
+                    appearance="outline"
+                    color="accent"
+                    class="w-100"
+                >
+                    <input
+                        [(ngModel)]="
+                            currentlyConfiguredWidget.visualizationConfig
+                                .symbolSize
+                        "
+                        matInput
+                        type="number"
+                        (ngModelChange)="triggerViewRefresh()"
+                    />
+                </mat-form-field>
+            </div>
+        </sp-configuration-box>
+    }
+</sp-visualization-config-outer>
diff --git 
a/ui/src/app/data-explorer-shared/components/charts/time-series-3d/config/time-series-3d-widget-config.component.ts
 
b/ui/src/app/data-explorer-shared/components/charts/time-series-3d/config/time-series-3d-widget-config.component.ts
new file mode 100644
index 0000000000..3a089ea2d8
--- /dev/null
+++ 
b/ui/src/app/data-explorer-shared/components/charts/time-series-3d/config/time-series-3d-widget-config.component.ts
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { Component } from '@angular/core';
+import { BaseWidgetConfig } from '../../base/base-widget-config';
+import { ChartConfigurationService } from 
'../../../../services/chart-configuration.service';
+import { DataExplorerFieldProviderService } from 
'../../../../services/data-explorer-field-provider-service';
+import {
+    TimeSeries3dVisConfig,
+    TimeSeries3dWidgetModel,
+} from '../model/time-series-3d-widget.model';
+import { DataExplorerField } from '@streampipes/platform-services';
+
+@Component({
+    selector: 'sp-data-explorer-time-series-3d-widget-config',
+    templateUrl: './time-series-3d-widget-config.component.html',
+    standalone: false,
+})
+export class TimeSeries3dWidgetConfigComponent extends BaseWidgetConfig<
+    TimeSeries3dWidgetModel,
+    TimeSeries3dVisConfig
+> {
+    constructor(
+        widgetConfigurationService: ChartConfigurationService,
+        fieldService: DataExplorerFieldProviderService,
+    ) {
+        super(widgetConfigurationService, fieldService);
+    }
+
+    setSelectedProperty(field: DataExplorerField) {
+        this.currentlyConfiguredWidget.visualizationConfig.selectedProperty =
+            field;
+        this.triggerViewRefresh();
+    }
+
+    protected applyWidgetConfig(config: TimeSeries3dVisConfig): void {
+        config.selectedProperty = this.fieldService.getSelectedField(
+            config.selectedProperty,
+            this.fieldProvider.numericFields,
+            () => this.fieldProvider.numericFields[0],
+        );
+        config.chartType ??= 'bar3D';
+        config.symbolSize ??= 0.5;
+    }
+
+    protected requiredFieldsForChartPresent(): boolean {
+        return this.fieldProvider.numericFields?.length > 0;
+    }
+}
diff --git 
a/ui/src/app/data-explorer-shared/components/charts/time-series-3d/model/time-series-3d-widget.model.ts
 
b/ui/src/app/data-explorer-shared/components/charts/time-series-3d/model/time-series-3d-widget.model.ts
new file mode 100644
index 0000000000..28751c2617
--- /dev/null
+++ 
b/ui/src/app/data-explorer-shared/components/charts/time-series-3d/model/time-series-3d-widget.model.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { DataExplorerVisConfig } from 
'../../../../models/dataview-dashboard.model';
+import {
+    DataExplorerDataConfig,
+    DataExplorerField,
+    DataExplorerWidgetModel,
+} from '@streampipes/platform-services';
+
+export interface TimeSeries3dVisConfig extends DataExplorerVisConfig {
+    selectedProperty: DataExplorerField;
+    chartType: 'scatter3D' | 'bar3D';
+    symbolSize: number;
+}
+
+export interface TimeSeries3dWidgetModel extends DataExplorerWidgetModel {
+    dataConfig: DataExplorerDataConfig;
+    visualizationConfig: TimeSeries3dVisConfig;
+}
diff --git 
a/ui/src/app/data-explorer-shared/components/charts/time-series-3d/time-series-3d-widget.component.ts
 
b/ui/src/app/data-explorer-shared/components/charts/time-series-3d/time-series-3d-widget.component.ts
new file mode 100644
index 0000000000..d6b8e4c3bc
--- /dev/null
+++ 
b/ui/src/app/data-explorer-shared/components/charts/time-series-3d/time-series-3d-widget.component.ts
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { Component, inject, OnDestroy, OnInit } from '@angular/core';
+import {
+    DataExplorerField,
+    SpQueryResult,
+} from '@streampipes/platform-services';
+import { BaseDataExplorerWidgetDirective } from 
'../base/base-data-explorer-widget.directive';
+import { ECharts } from 'echarts/core';
+import { EChartsOption } from 'echarts';
+import { Subject, Subscription } from 'rxjs';
+import { ResizeEchartsService } from 
'../../../services/resize-echarts.service';
+import { debounceTime } from 'rxjs/operators';
+import { TimeSeries3dWidgetModel } from './model/time-series-3d-widget.model';
+import { EchartsBasicOptionsGeneratorService } from 
'../../../echarts-renderer/echarts-basic-options-generator.service';
+import { WidgetEchartsAppearanceConfig } from 
'../../../models/dataview-dashboard.model';
+
+@Component({
+    selector: 'sp-data-explorer-time-series-3d-widget',
+    templateUrl: '../base/echarts-widget.component.html',
+    standalone: false,
+})
+export class TimeSeries3dWidgetComponent
+    extends BaseDataExplorerWidgetDirective<TimeSeries3dWidgetModel>
+    implements OnInit, OnDestroy
+{
+    eChartsInstance: ECharts;
+    currentWidth: number;
+    currentHeight: number;
+
+    option: EChartsOption;
+
+    configReady = false;
+    latestData: SpQueryResult[];
+
+    renderSubject = new Subject<void>();
+    renderSubject$: Subscription;
+    resizeEcharts$: Subscription;
+
+    private resizeEchartsService = inject(ResizeEchartsService);
+    private echartsBasicOptionsGeneratorService = inject(
+        EchartsBasicOptionsGeneratorService,
+    );
+
+    widgetTypeLabel: string;
+
+    ngOnInit(): void {
+        super.ngOnInit();
+        this.resizeEcharts$ =
+            this.resizeEchartsService.echartsResizeSubject.subscribe(width => {
+                this.currentWidth = width - this.widthOffset;
+                this.applySize(this.currentWidth, this.currentHeight);
+                this.refreshView();
+            });
+        this.renderSubject$ = this.renderSubject
+            .pipe(debounceTime(300))
+            .subscribe(() => {
+                this.renderChartOptions(this.latestData);
+            });
+        this.widgetTypeLabel = this.widgetRegistryService.getChartTemplate(
+            this.dataExplorerWidget.widgetType,
+        ).label;
+    }
+
+    beforeDataFetched() {}
+
+    onDataReceived(spQueryResult: SpQueryResult[]) {
+        this.renderChartOptions(spQueryResult);
+        this.latestData = spQueryResult;
+        this.setShownComponents(false, true, false, false);
+    }
+
+    onResize(width: number, height: number) {
+        this.currentWidth = width;
+        this.currentHeight = height;
+        this.configReady = true;
+        this.applySize(width, height);
+        if (this.latestData) {
+            this.renderSubject.next();
+        }
+    }
+
+    onChartInit(ec: ECharts) {
+        this.eChartsInstance = ec;
+        this.applySize(this.currentWidth, this.currentHeight);
+    }
+
+    applySize(width: number, height: number) {
+        if (this.eChartsInstance) {
+            this.eChartsInstance.resize({ width, height });
+        }
+    }
+
+    renderChartOptions(spQueryResult: SpQueryResult[]): void {
+        const chartData = this.convertChartData(spQueryResult);
+        if (
+            this.dataExplorerWidget.visualizationConfig.configurationValid ===
+                undefined ||
+            this.dataExplorerWidget.visualizationConfig.configurationValid ===
+                true
+        ) {
+            this.showInvalidConfiguration = false;
+            this.option = {
+                tooltip: {},
+                visualMap: {
+                    show: true,
+                    min: chartData.min,
+                    max: chartData.max,
+                    top: '0px',
+                    right: '50px',
+                    orient: 'horizontal',
+                    dimension: 2,
+                    inRange: {
+                        color: [
+                            '#313695',
+                            '#4575b4',
+                            '#74add1',
+                            '#abd9e9',
+                            '#e0f3f8',
+                            '#ffffbf',
+                            '#fee090',
+                            '#fdae61',
+                            '#f46d43',
+                            '#d73027',
+                            '#a50026',
+                        ],
+                    },
+                },
+                xAxis3D: {
+                    type: 'time',
+                    name: 'Time',
+                    axisLabel: {
+                        formatter: v => {
+                            return new Date(v).toLocaleString();
+                        },
+                    },
+                },
+                yAxis3D: {
+                    type: 'category',
+                    name: 'Category',
+                    data: chartData.yAxisLabels,
+                },
+                zAxis3D: {
+                    type: 'value',
+                    name: this.dataExplorerWidget.visualizationConfig
+                        .selectedProperty.runtimeName,
+                },
+                grid3D: {
+                    viewControl: {
+                        projection: 'perspective',
+                    },
+                },
+                series: [
+                    {
+                        type: this.dataExplorerWidget.visualizationConfig
+                            .chartType,
+                        symbolSize:
+                            this.dataExplorerWidget.visualizationConfig
+                                .symbolSize,
+                        wireframe: {
+                            show: false,
+                        },
+                        data: chartData.chartData,
+                    } as any,
+                ],
+            };
+            const baseConfig =
+                this.echartsBasicOptionsGeneratorService.makeBaseConfig(
+                    this.dataExplorerWidget
+                        .baseAppearanceConfig as WidgetEchartsAppearanceConfig,
+                    {},
+                );
+            this.option = Object.assign(this.option, baseConfig);
+        } else {
+            this.showInvalidConfiguration = true;
+        }
+    }
+
+    refreshView() {
+        this.renderSubject.next();
+    }
+
+    handleUpdatedFields(
+        addedFields: DataExplorerField[],
+        removedFields: DataExplorerField[],
+    ) {}
+
+    convertChartData(input: any[]) {
+        const allSeries = input?.[0]?.allDataSeries ?? [];
+        const yAxisLabels: string[] = [];
+        const chartData: [number, number, number][] = [];
+
+        let min = Number.POSITIVE_INFINITY;
+        let max = Number.NEGATIVE_INFINITY;
+
+        allSeries.forEach((series, sensorIdx) => {
+            const tagString = Object.entries(series.tags || {})
+                .map(([k, v]) => `${k}=${v}`)
+                .join(', ');
+            yAxisLabels.push(tagString || `Sensor ${sensorIdx + 1}`);
+
+            series.rows.forEach(row => {
+                const [timestamp, distance] = row;
+                chartData.push([timestamp, sensorIdx, distance]);
+                if (distance < min) min = distance;
+                if (distance > max) max = distance;
+            });
+        });
+
+        return {
+            chartData,
+            yAxisLabels,
+            min,
+            max,
+        };
+    }
+
+    ngOnDestroy(): void {
+        this.cleanupSubscriptions();
+        this.resizeEcharts$?.unsubscribe();
+        this.renderSubject$?.unsubscribe();
+    }
+}
diff --git a/ui/src/app/data-explorer-shared/data-explorer-shared.module.ts 
b/ui/src/app/data-explorer-shared/data-explorer-shared.module.ts
index f51ced9b9e..024c69f322 100644
--- a/ui/src/app/data-explorer-shared/data-explorer-shared.module.ts
+++ b/ui/src/app/data-explorer-shared/data-explorer-shared.module.ts
@@ -98,6 +98,8 @@ import { SpTimeSeriesAppearanceConfigComponent } from 
'./components/charts/time-
 import { SpDataZoomConfigComponent } from 
'./components/chart-config/data-zoom-config/data-zoom-config.component';
 import { TranslateModule } from '@ngx-translate/core';
 import { ColorMappingOptionsConfigComponent } from 
'./components/chart-config/color-mapping-options-config/color-mapping-options-config.component';
+import { TimeSeries3dWidgetConfigComponent } from 
'./components/charts/time-series-3d/config/time-series-3d-widget-config.component';
+import { TimeSeries3dWidgetComponent } from 
'./components/charts/time-series-3d/time-series-3d-widget.component';
 
 @NgModule({
     imports: [
@@ -186,6 +188,8 @@ import { ColorMappingOptionsConfigComponent } from 
'./components/chart-config/co
         SpTimeSeriesAppearanceConfigComponent,
         SpDataZoomConfigComponent,
         ColorMappingOptionsConfigComponent,
+        TimeSeries3dWidgetConfigComponent,
+        TimeSeries3dWidgetComponent,
     ],
     exports: [DataExplorerChartContainerComponent],
 })
diff --git 
a/ui/src/app/data-explorer-shared/registry/data-explorer-chart-registry.ts 
b/ui/src/app/data-explorer-shared/registry/data-explorer-chart-registry.ts
index 31d25983f2..ca2a778873 100644
--- a/ui/src/app/data-explorer-shared/registry/data-explorer-chart-registry.ts
+++ b/ui/src/app/data-explorer-shared/registry/data-explorer-chart-registry.ts
@@ -60,6 +60,8 @@ import { TrafficLightWidgetComponent } from 
'../components/charts/traffic-light/
 import { StatusWidgetConfigComponent } from 
'../components/charts/status/config/status-widget-config.component';
 import { StatusWidgetComponent } from 
'../components/charts/status/status-widget.component';
 import { TranslateService } from '@ngx-translate/core';
+import { TimeSeries3dWidgetConfigComponent } from 
'../components/charts/time-series-3d/config/time-series-3d-widget-config.component';
+import { TimeSeries3dWidgetComponent } from 
'../components/charts/time-series-3d/time-series-3d-widget.component';
 
 @Injectable({ providedIn: 'root' })
 export class DataExplorerChartRegistry {
@@ -160,6 +162,18 @@ export class DataExplorerChartRegistry {
                     'A heatmap that lets you map specific values to a color',
                 ),
             },
+            {
+                id: 'time-series-3d',
+                label: '3D Time-Series Chart',
+                widgetAppearanceConfigurationComponent:
+                    SpEchartsWidgetAppearanceConfigComponent,
+                widgetConfigurationComponent: 
TimeSeries3dWidgetConfigComponent,
+                widgetComponent: TimeSeries3dWidgetComponent,
+                icon: 'view_column',
+                description: this.translateService.instant(
+                    'A 3D time-series chart chart that renders each dimension 
in its own space',
+                ),
+            },
             {
                 id: 'time-series-chart',
                 label: this.translateService.instant('Time Series Chart'),

Reply via email to