diff --git a/frontend/datacat-ui/package-lock.json b/frontend/datacat-ui/package-lock.json
index 00789f9..acac713 100644
--- a/frontend/datacat-ui/package-lock.json
+++ b/frontend/datacat-ui/package-lock.json
@@ -20,6 +20,8 @@
"@primeng/themes": "^19.0.3",
"angular-gridster2": "^18.0.1",
"chart.js": "^4.4.7",
+ "chartjs-adapter-luxon": "^1.3.1",
+ "luxon": "^3.6.1",
"primeicons": "^7.0.0",
"primeng": "^18.0.2",
"rxjs": "~7.8.0",
@@ -6625,6 +6627,16 @@
"pnpm": ">=8"
}
},
+ "node_modules/chartjs-adapter-luxon": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.3.1.tgz",
+ "integrity": "sha512-yxHov3X8y+reIibl1o+j18xzrcdddCLqsXhriV2+aQ4hCR66IYFchlRXUvrJVoxglJ380pgytU7YWtoqdIgqhg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": ">=3.0.0",
+ "luxon": ">=1.0.0"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -10847,6 +10859,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/luxon": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
+ "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.11",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
diff --git a/frontend/datacat-ui/package.json b/frontend/datacat-ui/package.json
index 4415a22..a3e35c1 100644
--- a/frontend/datacat-ui/package.json
+++ b/frontend/datacat-ui/package.json
@@ -23,6 +23,8 @@
"@primeng/themes": "^19.0.3",
"angular-gridster2": "^18.0.1",
"chart.js": "^4.4.7",
+ "chartjs-adapter-luxon": "^1.3.1",
+ "luxon": "^3.6.1",
"primeicons": "^7.0.0",
"primeng": "^18.0.2",
"rxjs": "~7.8.0",
diff --git a/frontend/datacat-ui/src/features/dashboards/edit-panel/edit-panel.component.html b/frontend/datacat-ui/src/features/dashboards/edit-panel/edit-panel.component.html
index dfda1a3..25e362b 100644
--- a/frontend/datacat-ui/src/features/dashboards/edit-panel/edit-panel.component.html
+++ b/frontend/datacat-ui/src/features/dashboards/edit-panel/edit-panel.component.html
@@ -2,11 +2,7 @@
-
+
diff --git a/frontend/datacat-ui/src/features/dashboards/edit-panel/edit-panel.component.ts b/frontend/datacat-ui/src/features/dashboards/edit-panel/edit-panel.component.ts
index da093b7..0819034 100644
--- a/frontend/datacat-ui/src/features/dashboards/edit-panel/edit-panel.component.ts
+++ b/frontend/datacat-ui/src/features/dashboards/edit-panel/edit-panel.component.ts
@@ -1,5 +1,4 @@
import { AfterViewInit, Component, Input, ViewChild } from '@angular/core';
-import { PanelVisualizationComponent } from '../../../shared/ui/panel-visualization';
import { PanelVisualizationOptionsComponent } from '../../../shared/ui/panel-visualization-options';
import { PanelModule } from 'primeng/panel';
import {
@@ -25,11 +24,14 @@ import {
import { ApiService } from '../../../shared/services/datacat-generated-client';
import { ToastLoggerService } from '../../../shared/services/toast-logger.service';
import { ButtonModule } from 'primeng/button';
-import { finalize } from 'rxjs';
+import { finalize, timer } from 'rxjs';
import { TimeSeries } from '../../../entities/dashboards/data.types';
import { PanelDataService } from '../panels-grid/panel-data.service';
import { TimeRangeSelectComponent } from '../../../shared/ui/time-range-select/time-range-select.component';
import { TimeRange } from '../../../entities/dashboards/etc.types';
+import { PanelChartComponent } from '../../../shared/ui/charts/panel-chart/panel-chart.component';
+import { ChartService } from '../../../shared/ui/charts/chart.service';
+import { ChartOptionsComponent } from '../../../shared/ui/charts/options/options.component';
@Component({
standalone: true,
@@ -37,8 +39,6 @@ import { TimeRange } from '../../../entities/dashboards/etc.types';
templateUrl: './edit-panel.component.html',
styleUrl: './edit-panel.component.scss',
imports: [
- PanelVisualizationComponent,
- PanelVisualizationOptionsComponent,
PanelModule,
ReactiveFormsModule,
InputTextModule,
@@ -46,10 +46,12 @@ import { TimeRange } from '../../../entities/dashboards/etc.types';
DataSourceSelectComponent,
ButtonModule,
TimeRangeSelectComponent,
+ PanelChartComponent,
+ ChartOptionsComponent,
],
- providers: [PanelDataService],
+ providers: [ChartService],
})
-export class EditPanelComponent implements AfterViewInit {
+export class EditPanelComponent {
private _panelId?: string;
@Input() public set panelId(id: string | undefined) {
@@ -57,9 +59,6 @@ export class EditPanelComponent implements AfterViewInit {
this.refresh();
}
- @ViewChild(PanelVisualizationOptionsComponent)
- optionsComponent?: PanelVisualizationOptionsComponent;
-
protected panel?: Panel;
protected data: TimeSeries[] | null = null;
@@ -70,10 +69,15 @@ export class EditPanelComponent implements AfterViewInit {
step: '00:30:00',
from: (() => {
const date = new Date();
- date.setMinutes(date.getMinutes() - 360);
+ date.setTime(date.getTime() - 4 * 60 * 60 * 1000);
+ date.setMilliseconds(0);
+ return date;
+ })(),
+ to: (() => {
+ const date = new Date();
+ date.setMilliseconds(0);
return date;
})(),
- to: new Date(),
});
protected editForm = new FormGroup({
@@ -88,37 +92,27 @@ export class EditPanelComponent implements AfterViewInit {
constructor(
private api: ApiService,
private logger: ToastLoggerService,
- private panelDataService: PanelDataService,
+ private chartService: ChartService,
) {
- this.panelDataService.data$.subscribe((v) => (this.data = v));
this.timeRangeControl.valueChanges.subscribe((tr) => {
- if (tr) this.panelDataService.loadTimeRange(tr);
+ this.refreshPreview();
});
this.editForm.get('dataSourceId')?.valueChanges.subscribe((id) => {
if (id && this.panel) {
this.panel.dataSource!.id = id;
- this.panelDataService.panel = this.panel;
+ this.chartService.setDataSourceName(this.panel.dataSource!.name);
this.refreshPreview();
}
});
this.editForm.get('query')?.valueChanges.subscribe((q) => {
if (q && this.panel) {
this.panel.query = q;
- this.panelDataService.panel = this.panel;
+ this.chartService.setQuery(this.panel.query);
this.refreshPreview();
}
});
}
- ngAfterViewInit() {
- if (this.panel) {
- this.optionsComponent?.setVisualizationSettings(
- this.panel.visualizationType!,
- this.panel.visualizationSettings!,
- );
- }
- }
-
protected refresh() {
if (!this._panelId) return;
@@ -140,14 +134,12 @@ export class EditPanelComponent implements AfterViewInit {
data.styleConfiguration!,
) as VisualizationSettings,
};
- this.panelDataService.panel = this.panel;
+ this.chartService.setType(this.panel.visualizationType!);
+ this.chartService.setQuery(this.panel.query);
+ this.chartService.setDataSourceName(this.panel.dataSource!.name);
+ this.chartService.updateStyle(this.panel.visualizationSettings);
this.refreshPreview();
- this.optionsComponent?.setVisualizationSettings(
- this.panel.visualizationType!,
- this.panel.visualizationSettings!,
- );
-
this.editForm.setValue({
title: this.panel.title,
dataSourceId: this.panel.dataSource?.id,
@@ -161,7 +153,12 @@ export class EditPanelComponent implements AfterViewInit {
}
protected refreshPreview() {
- this.panelDataService.loadTimeRange(this.timeRangeControl.getRawValue()!);
+ const timeRange = this.timeRangeControl.getRawValue()!;
+ this.chartService.loadTimeRange(
+ timeRange.from,
+ timeRange.to,
+ timeRange.step,
+ );
}
protected saveChanges() {
@@ -169,13 +166,11 @@ export class EditPanelComponent implements AfterViewInit {
const request: any = {
title: this.editForm.get('title')?.value || '',
- type: encodeVisualizationType(this.visualizationType),
+ type: encodeVisualizationType(this.chartService.type),
rawQuery: this.editForm.get('query')?.value || '',
dataSourceId: this.editForm.get('dataSourceId')?.value || '',
layout: serializeLayout(this.panel.layout),
- styleConfiguration: encodeVisualizationSettings(
- this.visualizationSettings,
- ),
+ styleConfiguration: encodeVisualizationSettings(this.chartService.style),
};
this.editForm.disable();
diff --git a/frontend/datacat-ui/src/features/dashboards/panels-grid/panel-in-grid/panel-in-grid.component.html b/frontend/datacat-ui/src/features/dashboards/panels-grid/panel-in-grid/panel-in-grid.component.html
index 71aeae9..62a6268 100644
--- a/frontend/datacat-ui/src/features/dashboards/panels-grid/panel-in-grid/panel-in-grid.component.html
+++ b/frontend/datacat-ui/src/features/dashboards/panels-grid/panel-in-grid/panel-in-grid.component.html
@@ -24,11 +24,7 @@
@if (isError) {
Loading error
} @else {
-
+
}
@@ -45,10 +41,6 @@
-
+
diff --git a/frontend/datacat-ui/src/features/dashboards/panels-grid/panel-in-grid/panel-in-grid.component.ts b/frontend/datacat-ui/src/features/dashboards/panels-grid/panel-in-grid/panel-in-grid.component.ts
index ed022cd..e6a2cbc 100644
--- a/frontend/datacat-ui/src/features/dashboards/panels-grid/panel-in-grid/panel-in-grid.component.ts
+++ b/frontend/datacat-ui/src/features/dashboards/panels-grid/panel-in-grid/panel-in-grid.component.ts
@@ -1,16 +1,16 @@
import { Component, Input } from '@angular/core';
import { PanelModule } from 'primeng/panel';
-import { PanelVisualizationComponent } from '../../../../shared/ui/panel-visualization/panel-visualization.component';
import { Panel } from '../../../../entities';
import { ButtonModule } from 'primeng/button';
import { Router } from '@angular/router';
import * as urls from '../../../../shared/common/urls';
-import { TimeSeries } from '../../../../entities/dashboards/data.types';
import { DialogModule } from 'primeng/dialog';
import { TextareaModule } from 'primeng/textarea';
import { DividerModule } from 'primeng/divider';
-import { PanelDataService } from '../panel-data.service';
import { DashboardService } from '../dashboard.service';
+import { PanelChartComponent } from '../../../../shared/ui/charts/panel-chart/panel-chart.component';
+import { ChartService } from '../../../../shared/ui/charts/chart.service';
+import { TimeSeries } from '../../../../shared/ui/charts/chart.types';
@Component({
standalone: true,
@@ -19,20 +19,26 @@ import { DashboardService } from '../dashboard.service';
styleUrl: './panel-in-grid.component.scss',
imports: [
PanelModule,
- PanelVisualizationComponent,
+ PanelChartComponent,
ButtonModule,
DialogModule,
TextareaModule,
DividerModule,
],
- providers: [PanelDataService],
+ providers: [ChartService],
})
export class PanelInGridComponent {
@Input() set panel(p: Panel | undefined) {
this._panel = p;
- this.panelDataService.panel = p;
+ if (p) {
+ this.chartService.setType(p.visualizationType!);
+ this.chartService.setQuery(p.query);
+ this.chartService.setDataSourceName(p.dataSource!.name);
+ this.chartService.updateStyle(p.visualizationSettings);
+ }
if (this.dashboardService.timeRange) {
- this.panelDataService.loadTimeRange(this.dashboardService.timeRange);
+ const tr = this.dashboardService.timeRange;
+ this.chartService.loadTimeRange(tr.from, tr.to, tr.step);
}
}
@@ -43,13 +49,13 @@ export class PanelInGridComponent {
constructor(
private router: Router,
- private panelDataService: PanelDataService,
private dashboardService: DashboardService,
+ private chartService: ChartService,
) {
- this.panelDataService.data$.subscribe((v) => (this.data = v));
- this.panelDataService.error$.subscribe((v) => (this.isError = v));
+ this.chartService.data$.subscribe((v) => (this.data = v));
+ this.chartService.error$.subscribe((v) => (this.isError = v));
this.dashboardService.timeRange$.subscribe((tr) => {
- if (tr) this.panelDataService.loadTimeRange(tr);
+ if (tr) this.chartService.loadTimeRange(tr.from, tr.to, tr.step);
});
}
diff --git a/frontend/datacat-ui/src/features/dashboards/panels-grid/panels-grid.component.ts b/frontend/datacat-ui/src/features/dashboards/panels-grid/panels-grid.component.ts
index d6bdf8e..70170fe 100644
--- a/frontend/datacat-ui/src/features/dashboards/panels-grid/panels-grid.component.ts
+++ b/frontend/datacat-ui/src/features/dashboards/panels-grid/panels-grid.component.ts
@@ -82,6 +82,7 @@ export class PanelsGridComponent {
resizable: {
enabled: true,
},
+ outerMargin: false,
enableBoundaryControl: true,
itemChangeCallback: this.handleGridsterItemChange.bind(this),
};
diff --git a/frontend/datacat-ui/src/features/dashboards/panels-grid/panels-grid.consts.ts b/frontend/datacat-ui/src/features/dashboards/panels-grid/panels-grid.consts.ts
index bffa8aa..d78ef85 100644
--- a/frontend/datacat-ui/src/features/dashboards/panels-grid/panels-grid.consts.ts
+++ b/frontend/datacat-ui/src/features/dashboards/panels-grid/panels-grid.consts.ts
@@ -14,8 +14,13 @@ export const DEFAULT_TIME_RANGE: TimeRange = {
step: '00:30:00',
from: (() => {
const date = new Date();
- date.setMinutes(date.getMinutes() - 360);
+ date.setTime(date.getTime() - 4 * 60 * 60 * 1000);
+ date.setMilliseconds(0);
+ return date;
+ })(),
+ to: (() => {
+ const date = new Date();
+ date.setMilliseconds(0);
return date;
})(),
- to: new Date(),
};
diff --git a/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.html b/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.html
new file mode 100644
index 0000000..5724b6e
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.html
@@ -0,0 +1,9 @@
+
+ @if (isError) {
+
Error
+ } @else if (!hasData()) {
+
No data
+ } @else {
+
+ }
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.scss b/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.scss
new file mode 100644
index 0000000..8d519de
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.scss
@@ -0,0 +1,4 @@
+p {
+ text-align: center;
+ color: var(--p-surface-400);
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.ts b/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.ts
new file mode 100644
index 0000000..1eacafa
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/bar/bar.component.ts
@@ -0,0 +1,104 @@
+import { Component, ViewChild } from '@angular/core';
+import { BarStyle, BarStyleScheme } from './bar.style';
+import { TimeSeries } from '../chart.types';
+import { ChartModule, UIChart } from 'primeng/chart';
+import { ProgressSpinnerModule } from 'primeng/progressspinner';
+import { ChartService } from '../chart.service';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-bar-chart',
+ templateUrl: 'bar.component.html',
+ styleUrl: 'bar.component.scss',
+ imports: [ChartModule, ProgressSpinnerModule],
+})
+export class BarChartComponent {
+ @ViewChild(UIChart) chart?: UIChart;
+
+ protected isError: boolean = false;
+ protected chartjsOptions: any;
+ protected chartjsData: any;
+
+ constructor(private chartService: ChartService) {
+ this.chartService.style$.subscribe((style) => {
+ this.updateStyle(style);
+ });
+ this.chartService.data$.subscribe((data) => {
+ this.updateData(data);
+ });
+ this.chartService.error$.subscribe((error) => {
+ this.isError = error;
+ });
+ }
+
+ private updateStyle(style: any) {
+ const parseResult = BarStyleScheme.safeParse(style);
+ if (parseResult.success) {
+ this.chartjsOptions = this.getChartjsOptionsFromBarStyle(
+ parseResult.data,
+ );
+ this.chart?.chart?.update();
+ }
+ }
+
+ private updateData(data: TimeSeries[]): void {
+ this.chartjsData = {
+ labels: [],
+ datasets: data.map((ts) => {
+ return {
+ label: JSON.stringify(ts.labels),
+ data: ts.points.map((pt) => {
+ return {
+ x: pt.timestamp.toISOString(),
+ y: pt.value,
+ };
+ }),
+ };
+ }),
+ };
+ this.chart?.chart?.update();
+ }
+
+ private getChartjsOptionsFromBarStyle(barStyle: BarStyle): any {
+ return {
+ animation: false,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: barStyle.legend.enabled,
+ position: barStyle.legend.position,
+ },
+ title: {
+ display: barStyle.title.enabled,
+ text: barStyle.title.text,
+ },
+ tooltip: {
+ enabled: barStyle.tooltip.enabled,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ min: barStyle.axis.xAxisMin,
+ max: barStyle.axis.xAxisMax,
+ title: {
+ display: barStyle.axis.xAxisTitle != undefined,
+ text: barStyle.axis.xAxisTitle,
+ },
+ },
+ y: {
+ min: barStyle.axis.yAxisMin,
+ max: barStyle.axis.yAxisMax,
+ title: {
+ display: barStyle.axis.yAxisTitle != undefined,
+ text: barStyle.axis.yAxisTitle,
+ },
+ },
+ },
+ };
+ }
+
+ protected hasData(): boolean {
+ return this.chartjsData.datasets.length !== 0;
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/bar/bar.style.ts b/frontend/datacat-ui/src/shared/ui/charts/bar/bar.style.ts
new file mode 100644
index 0000000..831deef
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/bar/bar.style.ts
@@ -0,0 +1,41 @@
+import { z } from 'zod/v4';
+
+export const BarStyleScheme = z.object({
+ title: z
+ .object({
+ enabled: z.boolean().default(false),
+ text: z.string().default(''),
+ })
+ .default({
+ enabled: false,
+ text: '',
+ }),
+ legend: z
+ .object({
+ enabled: z.boolean().default(true),
+ position: z.enum(['top', 'left', 'bottom', 'right']).default('top'),
+ })
+ .default({
+ enabled: true,
+ position: 'top',
+ }),
+ tooltip: z
+ .object({
+ enabled: z.boolean().default(true),
+ })
+ .default({
+ enabled: true,
+ }),
+ axis: z
+ .object({
+ xAxisTitle: z.string().default('Date').nullish(),
+ xAxisMin: z.date().nullish(),
+ xAxisMax: z.date().nullish(),
+ yAxisTitle: z.string().default('Value').nullish(),
+ yAxisMin: z.number().nullish(),
+ yAxisMax: z.number().nullish(),
+ })
+ .default({}),
+});
+
+export type BarStyle = z.infer;
diff --git a/frontend/datacat-ui/src/shared/ui/charts/chart.service.ts b/frontend/datacat-ui/src/shared/ui/charts/chart.service.ts
new file mode 100644
index 0000000..2a7934b
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/chart.service.ts
@@ -0,0 +1,113 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, finalize, Observable, Subscription } from 'rxjs';
+import { DataPoint, TimeSeries } from './chart.types';
+import { ApiService } from '../../services/datacat-generated-client';
+import { VisualizationType } from '../../../entities';
+import { ThemeSelectionService } from '../../../features/appearence/select-theme/select-theme.service';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class ChartService {
+ public readonly type$: Observable;
+ public readonly data$: Observable;
+ public readonly style$: Observable;
+ public readonly error$: Observable;
+ public readonly isLoading$: Observable;
+
+ public query?: string;
+ public dataSourceName?: string;
+
+ private typeSubject = new BehaviorSubject(
+ VisualizationType.LINE,
+ );
+ private dataSubject = new BehaviorSubject([]);
+ private styleSubject = new BehaviorSubject({});
+ private errorSubject = new BehaviorSubject(false);
+ private isLoadingSubject = new BehaviorSubject(false);
+
+ public get type(): VisualizationType {
+ return this.typeSubject.value;
+ }
+
+ public get data(): any {
+ return this.dataSubject.value;
+ }
+
+ public get style(): any {
+ return this.styleSubject.value;
+ }
+
+ private loadTimeRangeSubscription?: Subscription;
+
+ constructor(private api: ApiService) {
+ this.type$ = this.typeSubject.asObservable();
+ this.data$ = this.dataSubject.asObservable();
+ this.style$ = this.styleSubject.asObservable();
+ this.error$ = this.errorSubject.asObservable();
+ this.isLoading$ = this.isLoadingSubject.asObservable();
+ }
+
+ public setType(type: VisualizationType): void {
+ this.typeSubject.next(type);
+ }
+
+ public updateStyle(style: any): void {
+ this.styleSubject.next({
+ ...this.styleSubject.value,
+ ...style,
+ });
+ }
+
+ public setQuery(query: string): void {
+ this.query = query;
+ }
+
+ public setDataSourceName(name: string): void {
+ this.dataSourceName = name;
+ }
+
+ public loadTimeRange(from: Date, to: Date, step: string): void {
+ if (!this.dataSourceName || !this.query) return;
+
+ this.loadTimeRangeSubscription?.unsubscribe();
+ this.errorSubject.next(false);
+ this.isLoadingSubject.next(true);
+ this.loadTimeRangeSubscription = this.api
+ .getApiV1MetricsQueryRange(
+ this.dataSourceName,
+ this.query,
+ 'undefined' as any,
+ null,
+ from,
+ to,
+ step,
+ )
+ .pipe(finalize(() => this.isLoadingSubject.next(false)))
+ .subscribe({
+ next: (data) => {
+ const timeSeries =
+ data.map((ts) => {
+ return {
+ name: ts.metricName!,
+ labels: ts.labels!,
+ points:
+ ts.points?.map((p) => {
+ return {
+ value: p.value!,
+ timestamp: p.timestamp!,
+ labels: p.labels!,
+ };
+ }) || [],
+ };
+ }) || [];
+ this.dataSubject.next(timeSeries);
+ },
+ error: () => {
+ this.errorSubject.next(true);
+ },
+ });
+ }
+
+ public addPointAt(date: Date): void {}
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/chart.types.ts b/frontend/datacat-ui/src/shared/ui/charts/chart.types.ts
new file mode 100644
index 0000000..bf830d3
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/chart.types.ts
@@ -0,0 +1,11 @@
+export type TimeSeries = {
+ name: string;
+ labels: { [key: string]: string };
+ points: DataPoint[];
+};
+
+export type DataPoint = {
+ value: number;
+ timestamp: Date;
+ labels: { [key: string]: string };
+};
diff --git a/frontend/datacat-ui/src/shared/ui/charts/line/line.component.html b/frontend/datacat-ui/src/shared/ui/charts/line/line.component.html
new file mode 100644
index 0000000..be9d999
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/line/line.component.html
@@ -0,0 +1,9 @@
+
+ @if (isError) {
+
Error
+ } @else if (!hasData()) {
+
No data
+ } @else {
+
+ }
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/line/line.component.scss b/frontend/datacat-ui/src/shared/ui/charts/line/line.component.scss
new file mode 100644
index 0000000..8d519de
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/line/line.component.scss
@@ -0,0 +1,4 @@
+p {
+ text-align: center;
+ color: var(--p-surface-400);
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/line/line.component.ts b/frontend/datacat-ui/src/shared/ui/charts/line/line.component.ts
new file mode 100644
index 0000000..779dabd
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/line/line.component.ts
@@ -0,0 +1,109 @@
+import { Component, ViewChild } from '@angular/core';
+import { LineStyle, LineStyleScheme } from './line.style';
+import { TimeSeries } from '../chart.types';
+import { ChartModule, UIChart } from 'primeng/chart';
+import { ProgressSpinnerModule } from 'primeng/progressspinner';
+import { ChartService } from '../chart.service';
+import 'luxon';
+import 'chartjs-adapter-luxon';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-line-chart',
+ templateUrl: 'line.component.html',
+ styleUrl: 'line.component.scss',
+ imports: [ChartModule, ProgressSpinnerModule],
+})
+export class LineChartComponent {
+ @ViewChild(UIChart) chart?: UIChart;
+
+ protected isError: boolean = false;
+ protected chartjsOptions: any;
+ protected chartjsData: any;
+
+ constructor(private chartService: ChartService) {
+ this.chartService.style$.subscribe((style) => {
+ this.updateStyle(style);
+ });
+ this.chartService.data$.subscribe((data) => {
+ this.updateData(data);
+ });
+ this.chartService.error$.subscribe((error) => {
+ this.isError = error;
+ });
+ }
+
+ private updateStyle(style: any) {
+ const parseResult = LineStyleScheme.safeParse(style);
+ if (parseResult.success) {
+ this.chartjsOptions = this.getChartjsOptionsFromLineStyle(
+ parseResult.data,
+ );
+ this.chart?.chart?.update();
+ }
+ }
+
+ private updateData(data: TimeSeries[]): void {
+ this.chartjsData = {
+ labels: [],
+ datasets: data.map((ts) => {
+ return {
+ label: (() => {
+ const { __name__, ...labels } = ts.labels;
+ return JSON.stringify(labels);
+ })(),
+ data: ts.points.map((pt) => {
+ return {
+ x: pt.timestamp.toISOString(),
+ y: pt.value,
+ };
+ }),
+ };
+ }),
+ };
+ this.chart?.chart?.update();
+ }
+
+ private getChartjsOptionsFromLineStyle(lineStyle: LineStyle): any {
+ return {
+ animation: false,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: lineStyle.legend.enabled,
+ position: lineStyle.legend.position,
+ },
+ title: {
+ display: lineStyle.title.enabled,
+ text: lineStyle.title.text,
+ },
+ tooltip: {
+ enabled: lineStyle.tooltip.enabled,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ min: lineStyle.axis.xAxisMin,
+ max: lineStyle.axis.xAxisMax,
+ title: {
+ display: lineStyle.axis.xAxisTitle != undefined,
+ text: lineStyle.axis.xAxisTitle,
+ },
+ },
+ y: {
+ min: lineStyle.axis.yAxisMin,
+ max: lineStyle.axis.yAxisMax,
+ title: {
+ display: lineStyle.axis.yAxisTitle != undefined,
+ text: lineStyle.axis.yAxisTitle,
+ },
+ },
+ },
+ };
+ }
+
+ protected hasData(): boolean {
+ return this.chartjsData.datasets.length !== 0;
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/line/line.style.ts b/frontend/datacat-ui/src/shared/ui/charts/line/line.style.ts
new file mode 100644
index 0000000..fd92828
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/line/line.style.ts
@@ -0,0 +1,41 @@
+import { z } from 'zod/v4';
+
+export const LineStyleScheme = z.object({
+ title: z
+ .object({
+ enabled: z.boolean().default(false),
+ text: z.string().default(''),
+ })
+ .default({
+ enabled: false,
+ text: '',
+ }),
+ legend: z
+ .object({
+ enabled: z.boolean().default(true),
+ position: z.enum(['top', 'left', 'bottom', 'right']).default('top'),
+ })
+ .default({
+ enabled: true,
+ position: 'top',
+ }),
+ tooltip: z
+ .object({
+ enabled: z.boolean().default(true),
+ })
+ .default({
+ enabled: true,
+ }),
+ axis: z
+ .object({
+ xAxisTitle: z.string().default('Date').nullish(),
+ xAxisMin: z.date().nullish(),
+ xAxisMax: z.date().nullish(),
+ yAxisTitle: z.string().default('Value').nullish(),
+ yAxisMin: z.number().nullish(),
+ yAxisMax: z.number().nullish(),
+ })
+ .default({}),
+});
+
+export type LineStyle = z.infer;
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/aggregate/aggregate-options.component.html b/frontend/datacat-ui/src/shared/ui/charts/options/aggregate/aggregate-options.component.html
new file mode 100644
index 0000000..213c599
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/aggregate/aggregate-options.component.html
@@ -0,0 +1,10 @@
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/aggregate/aggregate-options.component.ts b/frontend/datacat-ui/src/shared/ui/charts/options/aggregate/aggregate-options.component.ts
new file mode 100644
index 0000000..4bdf828
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/aggregate/aggregate-options.component.ts
@@ -0,0 +1,32 @@
+import { Component } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { ChartService } from '../../chart.service';
+import { SelectModule } from 'primeng/select';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-aggregate-options',
+ templateUrl: 'aggregate-options.component.html',
+ styleUrl: '../options.style.scss',
+ imports: [ReactiveFormsModule, SelectModule],
+})
+export class AggregateOptionsComponent {
+ protected form = new FormGroup({
+ function: new FormControl<'AVG' | 'MIN' | 'MAX'>('AVG'),
+ });
+
+ constructor(private chartService: ChartService) {
+ this.form.valueChanges.subscribe((options) =>
+ this.chartService.updateStyle({
+ aggregate: {
+ ...options,
+ },
+ }),
+ );
+ this.chartService.style$.subscribe((style) => {
+ if (style.aggregate) {
+ this.form.setValue(style.aggregate, { emitEvent: false });
+ }
+ });
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/axis/axis-options.component.html b/frontend/datacat-ui/src/shared/ui/charts/options/axis/axis-options.component.html
new file mode 100644
index 0000000..544d1e4
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/axis/axis-options.component.html
@@ -0,0 +1,14 @@
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/axis/axis-options.component.ts b/frontend/datacat-ui/src/shared/ui/charts/options/axis/axis-options.component.ts
new file mode 100644
index 0000000..7b1ac57
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/axis/axis-options.component.ts
@@ -0,0 +1,46 @@
+import { Component } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { CheckboxModule } from 'primeng/checkbox';
+import { ChartService } from '../../chart.service';
+import { InputTextModule } from 'primeng/inputtext';
+import { DatePickerModule } from 'primeng/datepicker';
+import { InputNumberModule } from 'primeng/inputnumber';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-axis-options',
+ templateUrl: 'axis-options.component.html',
+ styleUrl: '../options.style.scss',
+ imports: [
+ ReactiveFormsModule,
+ CheckboxModule,
+ InputTextModule,
+ InputNumberModule,
+ DatePickerModule,
+ ],
+})
+export class AxisOptionsComponent {
+ protected form = new FormGroup({
+ xAxisTitle: new FormControl(null),
+ xAxisMin: new FormControl(null),
+ xAxisMax: new FormControl(null),
+ yAxisTitle: new FormControl(null),
+ yAxisMin: new FormControl(null),
+ yAxisMax: new FormControl(null),
+ });
+
+ constructor(private chartService: ChartService) {
+ this.form.valueChanges.subscribe((options) =>
+ this.chartService.updateStyle({
+ axis: {
+ ...options,
+ },
+ }),
+ );
+ this.chartService.style$.subscribe((style) => {
+ if (style.axis) {
+ this.form.setValue(style.axis, { emitEvent: false });
+ }
+ });
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/legend/legend-options.component.html b/frontend/datacat-ui/src/shared/ui/charts/options/legend/legend-options.component.html
new file mode 100644
index 0000000..ab59de2
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/legend/legend-options.component.html
@@ -0,0 +1,14 @@
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/legend/legend-options.component.ts b/frontend/datacat-ui/src/shared/ui/charts/options/legend/legend-options.component.ts
new file mode 100644
index 0000000..55770f5
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/legend/legend-options.component.ts
@@ -0,0 +1,34 @@
+import { Component } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { CheckboxModule } from 'primeng/checkbox';
+import { SelectModule } from 'primeng/select';
+import { ChartService } from '../../chart.service';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-legend-options',
+ templateUrl: 'legend-options.component.html',
+ styleUrl: '../options.style.scss',
+ imports: [ReactiveFormsModule, CheckboxModule, SelectModule],
+})
+export class LegendOptionsComponent {
+ protected form = new FormGroup({
+ enabled: new FormControl(true),
+ position: new FormControl<'top' | 'bottom' | 'left' | 'right'>('top'),
+ });
+
+ constructor(private chartService: ChartService) {
+ this.form.valueChanges.subscribe((options) =>
+ this.chartService.updateStyle({
+ legend: {
+ ...options,
+ },
+ }),
+ );
+ this.chartService.style$.subscribe((style) => {
+ if (style.legend) {
+ this.form.setValue(style.legend, { emitEvent: false });
+ }
+ });
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/options.component.html b/frontend/datacat-ui/src/shared/ui/charts/options/options.component.html
new file mode 100644
index 0000000..66eb621
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/options.component.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+ Title
+
+
+
+
+
+ Legend
+
+
+
+
+
+ Tooltip
+
+
+
+
+
+
+ @switch (type) {
+ @case (VisualizationType.LINE) {
+
+ Axes
+
+
+
+
+ }
+ @case (VisualizationType.BAR) {
+
+ Axes
+
+
+
+
+ }
+ @case (VisualizationType.PIE) {
+
+ Aggregate
+
+
+
+
+ }
+ @default {}
+ }
+
+
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/options.component.scss b/frontend/datacat-ui/src/shared/ui/charts/options/options.component.scss
new file mode 100644
index 0000000..84d79da
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/options.component.scss
@@ -0,0 +1,31 @@
+.sub {
+ display: flex;
+ flex-direction: column;
+ gap: var(--p-padding-md);
+}
+
+.attr {
+ display: flex;
+ flex-direction: column;
+ gap: var(--p-padding-xs);
+}
+
+.attr p {
+ color: var(--p-surface-400);
+}
+
+.pad-lr {
+ padding-left: var(--p-padding-md);
+ padding-right: var(--p-padding-md);
+}
+
+.pad-tb {
+ padding-top: var(--p-padding-md);
+ padding-bottom: var(--p-padding-md);
+}
+
+.options {
+ margin-bottom: -1px;
+ overflow: hidden;
+ border-radius: var(--p-panel-border-radius);
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/options.component.ts b/frontend/datacat-ui/src/shared/ui/charts/options/options.component.ts
new file mode 100644
index 0000000..0f070ca
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/options.component.ts
@@ -0,0 +1,54 @@
+import { Component } from '@angular/core';
+import { ChartService } from '../chart.service';
+import { VisualizationType } from '../../../../entities';
+import { PanelModule } from 'primeng/panel';
+import { AccordionModule } from 'primeng/accordion';
+import { SelectModule } from 'primeng/select';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { LegendOptionsComponent } from './legend/legend-options.component';
+import { TitleOptionsComponent } from './title/title-options.component';
+import { TooltipOptionsComponent } from './tooltip/tooltip-options.component';
+import { AxisOptionsComponent } from './axis/axis-options.component';
+import { AggregateOptionsComponent } from './aggregate/aggregate-options.component';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-chart-options',
+ templateUrl: 'options.component.html',
+ styleUrl: 'options.component.scss',
+ imports: [
+ ReactiveFormsModule,
+ PanelModule,
+ AccordionModule,
+ SelectModule,
+ LegendOptionsComponent,
+ TitleOptionsComponent,
+ TooltipOptionsComponent,
+ AxisOptionsComponent,
+ AggregateOptionsComponent,
+ ],
+})
+export class ChartOptionsComponent {
+ protected VisualizationType = VisualizationType;
+ protected selectableTypes = Object.values(VisualizationType).filter(
+ (t) => t !== VisualizationType.UNKNOWN,
+ );
+ protected typeControl = new FormControl(
+ VisualizationType.LINE,
+ );
+
+ protected get type(): VisualizationType {
+ return this.typeControl.value!;
+ }
+
+ constructor(private chartService: ChartService) {
+ this.chartService.type$.subscribe((type) => {
+ this.typeControl.setValue(type, { emitEvent: false });
+ });
+ this.typeControl.valueChanges.subscribe((type) => {
+ if (type) {
+ this.chartService.setType(type);
+ }
+ });
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/options.style.scss b/frontend/datacat-ui/src/shared/ui/charts/options/options.style.scss
new file mode 100644
index 0000000..af74e90
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/options.style.scss
@@ -0,0 +1,15 @@
+form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--p-padding-md);
+}
+
+div {
+ display: flex;
+ flex-direction: column;
+ gap: var(--p-padding-sm);
+}
+
+p {
+ color: var(--p-surface-400);
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/title/title-options.component.html b/frontend/datacat-ui/src/shared/ui/charts/options/title/title-options.component.html
new file mode 100644
index 0000000..9ddd0ed
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/title/title-options.component.html
@@ -0,0 +1,10 @@
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/title/title-options.component.ts b/frontend/datacat-ui/src/shared/ui/charts/options/title/title-options.component.ts
new file mode 100644
index 0000000..98d39ce
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/title/title-options.component.ts
@@ -0,0 +1,34 @@
+import { Component } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { CheckboxModule } from 'primeng/checkbox';
+import { ChartService } from '../../chart.service';
+import { InputTextModule } from 'primeng/inputtext';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-title-options',
+ templateUrl: 'title-options.component.html',
+ styleUrl: '../options.style.scss',
+ imports: [ReactiveFormsModule, CheckboxModule, InputTextModule],
+})
+export class TitleOptionsComponent {
+ protected form = new FormGroup({
+ enabled: new FormControl(true),
+ text: new FormControl(''),
+ });
+
+ constructor(private chartService: ChartService) {
+ this.form.valueChanges.subscribe((options) =>
+ this.chartService.updateStyle({
+ title: {
+ ...options,
+ },
+ }),
+ );
+ this.chartService.style$.subscribe((style) => {
+ if (style.title) {
+ this.form.setValue(style.title, { emitEvent: false });
+ }
+ });
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/tooltip/tooltip-options.component.html b/frontend/datacat-ui/src/shared/ui/charts/options/tooltip/tooltip-options.component.html
new file mode 100644
index 0000000..d892279
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/tooltip/tooltip-options.component.html
@@ -0,0 +1,6 @@
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/options/tooltip/tooltip-options.component.ts b/frontend/datacat-ui/src/shared/ui/charts/options/tooltip/tooltip-options.component.ts
new file mode 100644
index 0000000..51be979
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/options/tooltip/tooltip-options.component.ts
@@ -0,0 +1,33 @@
+import { Component } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { CheckboxModule } from 'primeng/checkbox';
+import { SelectModule } from 'primeng/select';
+import { ChartService } from '../../chart.service';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-tooltip-options',
+ templateUrl: 'tooltip-options.component.html',
+ styleUrl: '../options.style.scss',
+ imports: [ReactiveFormsModule, CheckboxModule, SelectModule],
+})
+export class TooltipOptionsComponent {
+ protected form = new FormGroup({
+ enabled: new FormControl(true),
+ });
+
+ constructor(private chartService: ChartService) {
+ this.form.valueChanges.subscribe((options) =>
+ this.chartService.updateStyle({
+ tooltip: {
+ ...options,
+ },
+ }),
+ );
+ this.chartService.style$.subscribe((style) => {
+ if (style.tooltip) {
+ this.form.setValue(style.tooltip, { emitEvent: false });
+ }
+ });
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.html b/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.html
new file mode 100644
index 0000000..6fb4ec5
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.html
@@ -0,0 +1,20 @@
+@if (loading) {
+
+} @else {
+ @switch (type) {
+ @case (VisualizationType.LINE) {
+
+ }
+ @case (VisualizationType.BAR) {
+
+ }
+ @case (VisualizationType.PIE) {
+
+ }
+ @default {
+
+ }
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.scss b/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.scss
new file mode 100644
index 0000000..cc26754
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.scss
@@ -0,0 +1,3 @@
+div {
+ text-align: center;
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.ts b/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.ts
new file mode 100644
index 0000000..5a1a1d8
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/panel-chart/panel-chart.component.ts
@@ -0,0 +1,37 @@
+import { Component, Input } from '@angular/core';
+import { VisualizationType } from '../../../../entities';
+import { LineChartComponent } from '../line/line.component';
+import { UnsupportedChartComponent } from '../unsupported/unsupported.component';
+import { ChartService } from '../chart.service';
+import { ProgressSpinnerModule } from 'primeng/progressspinner';
+import { BarChartComponent } from '../bar/bar.component';
+import { PieChartComponent } from '../pie/pie.component';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-panel-chart',
+ templateUrl: 'panel-chart.component.html',
+ styleUrl: 'panel-chart.component.scss',
+ imports: [
+ ProgressSpinnerModule,
+ LineChartComponent,
+ BarChartComponent,
+ PieChartComponent,
+ UnsupportedChartComponent,
+ ],
+})
+export class PanelChartComponent {
+ protected loading: boolean = false;
+
+ protected type?: VisualizationType;
+ protected VisualizationType = VisualizationType;
+
+ constructor(private chartService: ChartService) {
+ this.chartService.type$.subscribe((type) => {
+ this.type = type;
+ });
+ this.chartService.isLoading$.subscribe((isLoading) => {
+ this.loading = isLoading;
+ });
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.html b/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.html
new file mode 100644
index 0000000..813e3f8
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.html
@@ -0,0 +1,9 @@
+
+ @if (isError) {
+
Error
+ } @else if (!hasData()) {
+
No data
+ } @else {
+
+ }
+
diff --git a/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.scss b/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.scss
new file mode 100644
index 0000000..8d519de
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.scss
@@ -0,0 +1,4 @@
+p {
+ text-align: center;
+ color: var(--p-surface-400);
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.ts b/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.ts
new file mode 100644
index 0000000..dc8dcbf
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/pie/pie.component.ts
@@ -0,0 +1,118 @@
+import { Component, ViewChild } from '@angular/core';
+import { PieStyle, PieStyleScheme } from './pie.style';
+import { TimeSeries } from '../chart.types';
+import { ChartModule, UIChart } from 'primeng/chart';
+import { ProgressSpinnerModule } from 'primeng/progressspinner';
+import { ChartService } from '../chart.service';
+import { parse } from 'zod/v4';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-pie-chart',
+ templateUrl: 'pie.component.html',
+ styleUrl: 'pie.component.scss',
+ imports: [ChartModule, ProgressSpinnerModule],
+})
+export class PieChartComponent {
+ @ViewChild(UIChart) chart?: UIChart;
+
+ protected isError: boolean = false;
+ protected chartjsOptions: any;
+ protected chartjsData: any;
+
+ protected aggregationFunction: string = 'AVG';
+
+ constructor(private chartService: ChartService) {
+ this.chartService.style$.subscribe((style) => {
+ this.updateStyle(style);
+ });
+ this.chartService.data$.subscribe((data) => {
+ this.updateData(data);
+ });
+ this.chartService.error$.subscribe((error) => {
+ this.isError = error;
+ });
+ }
+
+ private updateStyle(style: any) {
+ const parseResult = PieStyleScheme.safeParse(style);
+ if (parseResult.success) {
+ this.chartjsOptions = this.getChartjsOptionsFromPieStyle(
+ parseResult.data,
+ );
+ this.aggregationFunction = parseResult.data.aggregate.function;
+ this.updateData(this.chartService.data);
+ this.chart?.chart?.update();
+ }
+ }
+
+ private updateData(data: TimeSeries[]): void {
+ this.chartjsData = {
+ labels: data.map((ts) => JSON.stringify(ts.labels)) || [],
+ datasets: [
+ {
+ data:
+ data.map((ts) =>
+ this.aggregateByType(this.aggregationFunction, ts),
+ ) || [],
+ },
+ ],
+ };
+ this.chart?.chart?.update();
+ }
+
+ private getChartjsOptionsFromPieStyle(pieStyle: PieStyle): any {
+ return {
+ animation: false,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: pieStyle.legend.enabled,
+ position: pieStyle.legend.position,
+ },
+ title: {
+ display: pieStyle.title.enabled,
+ text: pieStyle.title.text,
+ },
+ tooltip: {
+ enabled: pieStyle.tooltip.enabled,
+ },
+ },
+ };
+ }
+
+ protected hasData(): boolean {
+ return this.chartjsData.datasets.length !== 0;
+ }
+
+ protected aggregateByType(type: string, ts: TimeSeries): number {
+ if (ts.points.length !== 0) {
+ switch (type) {
+ case 'AVG': {
+ return (
+ ts.points
+ .map((pt) => pt.value)
+ .reduce((sum, curr) => sum + curr, 0) / ts.points.length
+ );
+ }
+ case 'MIN': {
+ return ts.points
+ .map((pt) => pt.value)
+ .reduce(
+ (min, curr) => (curr < min ? curr : min),
+ ts.points[0].value,
+ );
+ }
+ case 'MAX': {
+ return ts.points
+ .map((pt) => pt.value)
+ .reduce(
+ (max, curr) => (curr < max ? max : curr),
+ ts.points[0].value,
+ );
+ }
+ }
+ }
+ return 0;
+ }
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/pie/pie.style.ts b/frontend/datacat-ui/src/shared/ui/charts/pie/pie.style.ts
new file mode 100644
index 0000000..99d28d4
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/pie/pie.style.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod/v4';
+
+export const PieStyleScheme = z.object({
+ title: z
+ .object({
+ enabled: z.boolean().default(false),
+ text: z.string().default(''),
+ })
+ .default({
+ enabled: false,
+ text: '',
+ }),
+ legend: z
+ .object({
+ enabled: z.boolean().default(true),
+ position: z.enum(['top', 'left', 'bottom', 'right']).default('top'),
+ })
+ .default({
+ enabled: true,
+ position: 'top',
+ }),
+ tooltip: z
+ .object({
+ enabled: z.boolean().default(true),
+ })
+ .default({
+ enabled: true,
+ }),
+ aggregate: z
+ .object({
+ function: z.string().default('AVG'),
+ })
+ .default({
+ function: 'AVG',
+ }),
+});
+
+export type PieStyle = z.infer;
diff --git a/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.html b/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.html
new file mode 100644
index 0000000..c8ee7ee
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.html
@@ -0,0 +1 @@
+Unsupported chart
diff --git a/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.scss b/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.scss
new file mode 100644
index 0000000..f9cc014
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.scss
@@ -0,0 +1,4 @@
+div {
+ text-align: center;
+ color: var(--p-surface-400);
+}
diff --git a/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.ts b/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.ts
new file mode 100644
index 0000000..2321fc2
--- /dev/null
+++ b/frontend/datacat-ui/src/shared/ui/charts/unsupported/unsupported.component.ts
@@ -0,0 +1,9 @@
+import { Component } from '@angular/core';
+
+@Component({
+ standalone: true,
+ selector: 'datacat-unsupported-chart',
+ templateUrl: 'unsupported.component.html',
+ styleUrl: 'unsupported.component.scss',
+})
+export class UnsupportedChartComponent {}
diff --git a/frontend/datacat-ui/src/shared/ui/panel-visualization/panel-visualization.component.ts b/frontend/datacat-ui/src/shared/ui/panel-visualization/panel-visualization.component.ts
index afe7bc3..2077ac5 100644
--- a/frontend/datacat-ui/src/shared/ui/panel-visualization/panel-visualization.component.ts
+++ b/frontend/datacat-ui/src/shared/ui/panel-visualization/panel-visualization.component.ts
@@ -56,6 +56,24 @@ export class PanelVisualizationComponent {
this.chartjsOptions.plugins.tooltip.enabled = settings.tooltip?.enabled;
+ switch (this.visualizationType) {
+ case VisualizationType.LINE: {
+ this.chartjsOptions = {
+ ...this.chartjsOptions,
+ x: {
+ type: 'time',
+ time: {
+ unit: 'day',
+ },
+ title: {
+ display: true,
+ text: 'Date',
+ },
+ },
+ };
+ }
+ }
+
this.chartRef?.chart?.update();
}
diff --git a/frontend/datacat-ui/src/shared/ui/time-range-select/time-range-select.component.scss b/frontend/datacat-ui/src/shared/ui/time-range-select/time-range-select.component.scss
index b87a400..d455a20 100644
--- a/frontend/datacat-ui/src/shared/ui/time-range-select/time-range-select.component.scss
+++ b/frontend/datacat-ui/src/shared/ui/time-range-select/time-range-select.component.scss
@@ -1,7 +1,8 @@
.container {
display: flex;
+ flex-direction: column;
gap: var(--p-padding-md);
- align-items: center;
+ align-items: start;
}
.item {