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 @@
- +
@@ -40,9 +36,5 @@ />
- + 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 @@ +
+
+

Aggregate function

+ +
+
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 @@ +
+
+

X axis

+ + + +
+
+

Y axis

+ + + +
+
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 @@ +
+
+

Enabled

+ +
+
+

Position

+ +
+
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 @@ + +
+

Visualization

+ +
+
+ + + 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 @@ +
+
+

Enabled

+ +
+
+

Text

+ +
+
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 @@ +
+
+

Enabled

+ +
+
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 {