diff --git a/src/main/angular/package-lock.json b/src/main/angular/package-lock.json index b6eafa5..9916596 100644 --- a/src/main/angular/package-lock.json +++ b/src/main/angular/package-lock.json @@ -19,6 +19,8 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@stomp/ng2-stompjs": "^8.0.0", "@stomp/stompjs": "^7.2.1", + "chartjs-adapter-date-fns": "^3.0.0", + "ng2-charts": "^8.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -423,6 +425,22 @@ } } }, + "node_modules/@angular/cdk": { + "version": "20.2.13", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.13.tgz", + "integrity": "sha512-h1jTkCmJ/rEQQMkxgKFMCBOrMfjZEnppgdekNmSTerwdVp4vdosTDTzFH/kwiOGFeRClffmvqQ2XLG8mQOKOtA==", + "license": "MIT", + "peer": true, + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^20.0.0 || ^21.0.0", + "@angular/core": "^20.0.0 || ^21.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "20.3.7", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.3.7.tgz", @@ -1901,6 +1919,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT", + "peer": true + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz", @@ -4198,6 +4223,29 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -4561,6 +4609,17 @@ "dev": true, "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", @@ -6767,6 +6826,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -7366,6 +7431,24 @@ "node": ">= 0.6" } }, + "node_modules/ng2-charts": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-8.0.0.tgz", + "integrity": "sha512-nofsNHI2Zt+EAwT+BJBVg0kgOhNo9ukO4CxULlaIi7VwZSr7I1km38kWSoU41Oq6os6qqIh5srnL+CcV+RFPFA==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.15", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": ">=19.0.0", + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0", + "@angular/platform-browser": ">=19.0.0", + "chart.js": "^3.4.0 || ^4.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", @@ -7904,7 +7987,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -7958,7 +8040,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" diff --git a/src/main/angular/package.json b/src/main/angular/package.json index f721450..6287d7d 100644 --- a/src/main/angular/package.json +++ b/src/main/angular/package.json @@ -35,7 +35,9 @@ "@stomp/stompjs": "^7.2.1", "@fortawesome/angular-fontawesome": "^3.0.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", - "@fortawesome/free-solid-svg-icons": "^7.1.0" + "@fortawesome/free-solid-svg-icons": "^7.1.0", + "ng2-charts": "^8.0.0", + "chartjs-adapter-date-fns": "^3.0.0" }, "devDependencies": { "@angular/build": "^20.3.7", diff --git a/src/main/angular/src/app/app.config.ts b/src/main/angular/src/app/app.config.ts index 63643d0..2155a8a 100644 --- a/src/main/angular/src/app/app.config.ts +++ b/src/main/angular/src/app/app.config.ts @@ -9,9 +9,13 @@ import {registerLocaleData} from '@angular/common'; import localeDe from '@angular/common/locales/de'; import localeDeExtra from '@angular/common/locales/extra/de'; import {stompServiceFactory} from './common'; +import {Chart, registerables} from 'chart.js'; +import 'chartjs-adapter-date-fns'; registerLocaleData(localeDe, 'de-DE', localeDeExtra); +Chart.register(...registerables); + export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), diff --git a/src/main/angular/src/app/common.ts b/src/main/angular/src/app/common.ts index 9242470..2287eab 100644 --- a/src/main/angular/src/app/common.ts +++ b/src/main/angular/src/app/common.ts @@ -47,6 +47,16 @@ export function validateEnum>(value: any, enumT throw new Error(`Invalid enum value: ${str}`); } +export function maxDate(a: Date | null | undefined, b: Date | null | undefined): Date | null { + if (!a || !b) { + return null; + } + if (a.getTime() < b.getTime()) { + return a; + } + return b; +} + export function or(t: T | null | undefined, map: (t: T) => R, orElse: E): R | E { return t === null || t === undefined ? orElse : map(t); } @@ -218,6 +228,10 @@ export abstract class CrudService { this.api.postSingle([...this.path, ...path], data, this.fromJson, next); } + protected postSingle2(path: any[], data: any, fromJson: FromJson, next?: Next): void { + this.api.postSingle([...this.path, ...path], data, fromJson, next); + } + protected postList(path: any[], data: any, next?: Next): void { this.api.postList([...this.path, ...path], data, this.fromJson, next); } diff --git a/src/main/angular/src/app/location/energy/charts/energy-charts.html b/src/main/angular/src/app/location/energy/charts/energy-charts.html new file mode 100644 index 0000000..e92bf38 --- /dev/null +++ b/src/main/angular/src/app/location/energy/charts/energy-charts.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/main/angular/src/app/location/energy/charts/energy-charts.less b/src/main/angular/src/app/location/energy/charts/energy-charts.less new file mode 100644 index 0000000..0b0b352 --- /dev/null +++ b/src/main/angular/src/app/location/energy/charts/energy-charts.less @@ -0,0 +1,3 @@ +div { + aspect-ratio: 2; +} diff --git a/src/main/angular/src/app/location/energy/charts/energy-charts.ts b/src/main/angular/src/app/location/energy/charts/energy-charts.ts new file mode 100644 index 0000000..0e45e3e --- /dev/null +++ b/src/main/angular/src/app/location/energy/charts/energy-charts.ts @@ -0,0 +1,159 @@ +import {Component, Input, ViewChild} from '@angular/core'; +import {Interval} from '../../../series/Interval'; +import {PointService} from '../../../point/point-service'; +import {Location} from '../../Location'; +import {BaseChartDirective} from 'ng2-charts'; +import {ChartConfiguration} from 'chart.js'; +import {de} from 'date-fns/locale'; +import {PointSeries} from '../../../point/PointSeries'; +import {formatNumber} from '@angular/common'; + +const COLOR_BACK_PURCHASE = "#ffb9b9"; +const COLOR_BACK_DELIVER = "#ff59ff"; +const COLOR_BACK_PRODUCE = "#5cbcff"; +const COLOR_BACK_SELF = "#60ff8c"; +const COLOR_BACK_CONSUME = "#ffc07a"; + +@Component({ + selector: 'app-energy-charts', + imports: [ + BaseChartDirective + ], + templateUrl: './energy-charts.html', + styleUrl: './energy-charts.less', +}) +class EnergyCharts { + + private _offset!: number | null; + + @Input() + set offset(value: number | null) { + this._offset = value; + this.fetch(); + } + + @ViewChild(BaseChartDirective) + chart?: BaseChartDirective; + + private _interval!: Interval | null; + + @Input() + set interval(value: Interval | null) { + this._interval = value; + this.fetch(); + } + + private _location!: Location | null; + + @Input() + set location(value: Location | null) { + this._location = value; + this.fetch(); + } + + constructor( + readonly pointService: PointService, + ) { + // + } + + fetch(): void { + if (!this._location || !this._interval || this._offset == null) { + return; + } + const series = [ + this._location.energyPurchase, + this._location.energyDeliver, + this._location.energyProduce, + ]; + const location = this._location; + const interval = this._interval; + const offset = this._offset; + this.pointService.relative(series, interval, offset, 1, interval.inner, result => { + const energyPurchase = result.series.filter(s => s.series.id === location.energyPurchase?.id)[0] || null; + const energyDeliver = result.series.filter(s => s.series.id === location.energyDeliver?.id)[0] || null; + const energyProduce = result.series.filter(s => s.series.id === location.energyProduce?.id)[0] || null; + const energySelf = energyProduce?.merge(energyDeliver, "Energie Selbst", (a, b) => a - b) || null; + this.data.datasets.length = 0; + this.add(energyDeliver, COLOR_BACK_DELIVER, -1, "a"); + this.add(energySelf, COLOR_BACK_SELF, 1, "a"); + this.add(energyPurchase, COLOR_BACK_PURCHASE, 1, "a"); + this.chart?.update(); + }); + } + + private add(pointSeries: PointSeries | null, color: string, factor: number, stack: string) { + if (!pointSeries) { + return; + } + this.data.datasets.push({ + type: 'bar', + categoryPercentage: 1.0, + barPercentage: 1.0, + data: pointSeries.points.map(p => { + return {x: p[0] * 1000, y: p[1] * factor}; + }), + label: `${pointSeries.series.name} [${pointSeries.series.unit}]`, + borderColor: color, + backgroundColor: color, + stack: stack ? stack : undefined, + }); + } + + protected data: ChartConfiguration['data'] = { + datasets: [], + }; + + protected options: ChartConfiguration['options'] = { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { + type: 'time', + time: { + displayFormats: { + minute: "HH:mm", + hour: "HH:mm", + day: "dd.MM" + }, + }, + adapters: { + date: { + locale: de + }, + }, + }, + y: { + suggestedMax: 0.5, + suggestedMin: -0.1, + } + }, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: 'index', + intersect: false, + itemSort: (a, b) => b.datasetIndex - a.datasetIndex, + callbacks: { + label: (ctx) => { + const groups = /^Energie (?.+)\[(?.+)]$/.exec(ctx.dataset.label || '')?.groups; + if (groups) { + return `${groups['name']}: ${ctx.parsed.y === null ? '-' : formatNumber(ctx.parsed.y, 'de-DE', '0.2-2')} ${groups['unit']}`; + } + return ctx.label; + }, + }, + }, + }, + }; + +} + +export default EnergyCharts diff --git a/src/main/angular/src/app/location/energy/location-energy.html b/src/main/angular/src/app/location/energy/location-energy.html index fd5159a..2fa6d6a 100644 --- a/src/main/angular/src/app/location/energy/location-energy.html +++ b/src/main/angular/src/app/location/energy/location-energy.html @@ -78,6 +78,6 @@ - + diff --git a/src/main/angular/src/app/location/energy/location-energy.ts b/src/main/angular/src/app/location/energy/location-energy.ts index 84bad26..f595d31 100644 --- a/src/main/angular/src/app/location/energy/location-energy.ts +++ b/src/main/angular/src/app/location/energy/location-energy.ts @@ -1,4 +1,4 @@ -import {AfterViewInit, Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {AfterViewInit, Component, Input, OnDestroy, OnInit, signal} from '@angular/core'; import {Location} from '../Location'; import {Series} from '../../series/Series'; import {Next} from '../../common'; @@ -7,18 +7,21 @@ import {PointService} from '../../point/point-service'; import {SeriesService} from '../../series/series-service'; import {Subscription} from 'rxjs'; import {Value} from '../../series/Value'; -import {EnergyPlot} from './plot/energy-plot'; +import EnergyCharts from './charts/energy-charts'; @Component({ selector: 'app-location-energy', imports: [ - EnergyPlot + EnergyCharts, + EnergyCharts ], templateUrl: './location-energy.html', styleUrl: './location-energy.less', }) export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy { + protected readonly signal = signal; + protected readonly Interval = Interval; private readonly subs: Subscription[] = []; diff --git a/src/main/angular/src/app/location/energy/plot/EnergyPoint.ts b/src/main/angular/src/app/location/energy/plot/EnergyPoint.ts deleted file mode 100644 index 4b55f58..0000000 --- a/src/main/angular/src/app/location/energy/plot/EnergyPoint.ts +++ /dev/null @@ -1,101 +0,0 @@ -export class EnergyPoint { - - readonly epochSeconds: number; - - readonly date: Date; - - private _purchase: number | null = null; - - private _produce: number | null = null; - - private _deliver: number | null = null; - - private _self: number | null = null; - - private _consume: number | null = null; - - private _purchaseY: number | null = null; - - private _selfY: number | null = null; - - private _deliverY: number | null = null; - - getPurchaseY(yFactor: number): number { - return this.getPurchaseH(yFactor) + this.getSelfH(yFactor); - } - - getPurchaseH(yFactor: number): number { - if (this._purchaseY === null) { - this._purchaseY = (this.purchase || 0) * yFactor; - } - return this._purchaseY; - } - - getSelfY(yFactor: number): number { - return this.getSelfH(yFactor); - } - - getSelfH(yFactor: number): number { - if (this._selfY === null) { - this._selfY = (this.self || 0) * yFactor; - } - return this._selfY; - } - - getDeliverH(yFactor: number): number { - if (this._deliverY === null) { - this._deliverY = (this.deliver || 0) * yFactor; - } - return this._deliverY; - } - - constructor( - point: number[], - ) { - this.epochSeconds = point[0]; - this.date = new Date(this.epochSeconds * 1000); - } - - set deliver(value: number | null) { - this._deliver = value; - this.update(); - } - - set produce(value: number | null) { - this._produce = value; - this.update(); - } - - set purchase(value: number | null) { - this._purchase = value; - this.update(); - } - - private update() { - if (this._purchase !== null && this._produce !== null && this._deliver !== null) { - this._self = Math.max(0, this._produce - this._deliver); - this._consume = Math.max(0, this._purchase + this._self); - } - } - - get consume(): number | null { - return this._consume; - } - - get self(): number | null { - return this._self; - } - - get deliver(): number | null { - return this._deliver; - } - - get produce(): number | null { - return this._produce; - } - - get purchase(): number | null { - return this._purchase; - } - -} diff --git a/src/main/angular/src/app/location/energy/plot/energy-plot.html b/src/main/angular/src/app/location/energy/plot/energy-plot.html deleted file mode 100644 index 6ab888b..0000000 --- a/src/main/angular/src/app/location/energy/plot/energy-plot.html +++ /dev/null @@ -1,42 +0,0 @@ - - @for (point of points; track point.epochSeconds) { - - - Bezug: {{ location.energyPurchase?.toValue(point.purchase, point.date)?.toValueString(null) }} - Solar: {{ location.energyProduce?.toValue(point.produce, point.date)?.toValueString(null) }} - Selbst: {{ location.energyPurchase?.toValue(point.self, point.date)?.toValueString(null) }} - Einsp.: {{ location.energyDeliver?.toValue(point.deliver, point.date)?.toValueString(null) }} - Verbrauch: {{ location.energyPurchase?.toValue(point.consume, point.date)?.toValueString(null) }} - - - - - - } - - diff --git a/src/main/angular/src/app/location/energy/plot/energy-plot.less b/src/main/angular/src/app/location/energy/plot/energy-plot.less deleted file mode 100644 index 788f7a8..0000000 --- a/src/main/angular/src/app/location/energy/plot/energy-plot.less +++ /dev/null @@ -1,5 +0,0 @@ -@import "../../../../colors"; - -g:hover { - stroke: black; -} diff --git a/src/main/angular/src/app/location/energy/plot/energy-plot.ts b/src/main/angular/src/app/location/energy/plot/energy-plot.ts deleted file mode 100644 index b2eeb23..0000000 --- a/src/main/angular/src/app/location/energy/plot/energy-plot.ts +++ /dev/null @@ -1,151 +0,0 @@ -import {AfterViewInit, Component, Input, OnDestroy, OnInit} from '@angular/core'; -import {Location} from '../../Location'; -import {Interval} from '../../../series/Interval'; -import {PointService} from '../../../point/point-service'; -import {PointResponse} from '../../../point/PointResponse'; -import {PointSeries} from '../../../point/PointSeries'; -import {EnergyPoint} from './EnergyPoint'; -import {Subscription, timer} from 'rxjs'; - -@Component({ - selector: 'app-energy-plot', - imports: [], - templateUrl: './energy-plot.html', - styleUrl: './energy-plot.less', -}) -export class EnergyPlot implements OnInit, OnDestroy, AfterViewInit { - - readonly widthPx = 800; - - readonly heightPx = 100; - - private _location!: Location; - - private _interval: Interval = Interval.FIVE; - - private _offset: number = 0; - - private _count: number = 1; - - protected points: EnergyPoint[] = []; - - protected yMin: number = 0; - - protected yMinPx: number = 0; - - protected yMax: number = 0; - - protected yFactor: number = 0; - - protected xMin: number = 0; - - protected xMax: number = 0; - - protected xFactor: number = 0; - - protected xWidthPx: number = 0; - - private readonly subs: Subscription[] = []; - - constructor( - readonly pointService: PointService, - ) { - // - } - - ngOnInit(): void { - this.subs.push(timer(60000, 60000).subscribe(() => this.ngAfterViewInit())); - } - - ngOnDestroy(): void { - this.subs.forEach(sub => sub.unsubscribe()); - } - - ngAfterViewInit(): void { - if (!this._location.energyPurchase) { - return; - } - if (!this._location.energyProduce) { - return; - } - if (!this._location.energyDeliver) { - return; - } - const series = [this._location.energyPurchase, this._location.energyProduce, this._location.energyDeliver]; - this.pointService.relative(series, this._interval, this._offset, this._count, this._interval.inner, this.update); - } - - public readonly update = (response: PointResponse): void => { - this.points.length = 0; - this.add(response.series[0], (p, v) => p.purchase = v); - this.add(response.series[1], (p, v) => p.produce = v); - this.add(response.series[2], (p, v) => p.deliver = v); - this.yMax = -Infinity; - this.yMin = Infinity; - for (let point of this.points) { - this.yMax = Math.max(this.yMax, point.consume || 0); - this.yMin = Math.min(this.yMin, -(point.deliver || 0)); - } - this.yMinPx = this.yMin * this.yFactor; - this.yFactor = this.heightPx / (this.yMax - this.yMin); - this.xMin = response.begin.getTime() / 1000; - this.xMax = response.end.getTime() / 1000; - this.xFactor = this.widthPx / (this.xMax - this.xMin); - this.xWidthPx = this.widthPx / response.expectedCount; - }; - - private add(series: PointSeries, setter: (p: EnergyPoint, v: number) => void): void { - for (const point of series.points) { - const index = this.insert(point, setter); - if (index >= 0) { - const fresh = new EnergyPoint(point); - setter(fresh, point[1]) - this.points.splice(index, 0, fresh); - } - } - } - - private insert(point: number[], setter: (p: EnergyPoint, v: number) => any): number { - let index = 0; - for (let old of this.points) { - const age = old.epochSeconds - point[0]; - if (age === 0) { - setter(old, point[1]) - return -1; - } else if (age < 0) { - return index; - } - index++; - } - return index; - } - - @Input() - set location(value: Location) { - this._location = value; - this.ngAfterViewInit(); - } - - get location(): Location { - return this._location; - } - - @Input() - set interval(value: Interval) { - this._interval = value; - this.ngAfterViewInit(); - } - - @Input() - set offset(value: number) { - this._offset = value; - this.ngAfterViewInit(); - } - - @Input() - set count(value: number) { - this._count = value; - this.ngAfterViewInit(); - } - -} diff --git a/src/main/angular/src/app/point/PointSeries.ts b/src/main/angular/src/app/point/PointSeries.ts index 6438121..c938dce 100644 --- a/src/main/angular/src/app/point/PointSeries.ts +++ b/src/main/angular/src/app/point/PointSeries.ts @@ -1,5 +1,5 @@ import {Series} from "../series/Series"; -import {validateList, validateNumber} from "../common"; +import {maxDate, validateList, validateNumber} from "../common"; export class PointSeries { @@ -17,4 +17,41 @@ export class PointSeries { ); } + merge(that: PointSeries | null, name: string, fun: (a: number, b: number) => number): PointSeries | null { + if (!that) { + return null; + } + if (this.series.type !== that.series.type) { + throw new Error(`Cannot combine PointSeries of different Series.type: this=${this.series.name}/${this.series.type}, that=${that.series.name}/${that.series.type}`); + } + const a = [...this.points]; + const b = [...that.points]; + let ai = 0; + let bi = 0; + const result: number[][] = []; + while (ai < a.length && ai < b.length) { + const av = a[ai]; + const bv = b[bi]; + const at = av[0]; + const bt = bv[0]; + if (at < bt) { + ai++; + } else if (bt < at) { + bi++; + } else { + const r = [at]; + for (let i = 1; i < bv.length; i++) { + r.push(fun(av[i], bv[i])); + } + result.push(r) + ai++; + bi++; + } + } + const value = fun(this.series.value.value, that.series.value.value); + const last = maxDate(this.series.last, that.series.last); + const series = new Series(-1, name, this.series.precision, this.series.seconds, this.series.type, value, this.series.unit, last); + return new PointSeries(series, result); + } + } diff --git a/src/main/angular/src/app/point/point-service.ts b/src/main/angular/src/app/point/point-service.ts index d48746f..1b4d8e4 100644 --- a/src/main/angular/src/app/point/point-service.ts +++ b/src/main/angular/src/app/point/point-service.ts @@ -4,6 +4,8 @@ import {PointResponse} from './PointResponse'; import {Series} from '../series/Series'; import {Interval} from '../series/Interval'; +const notNull = (v: T | null | undefined): v is T => v != null; + @Injectable({ providedIn: 'root' }) @@ -16,9 +18,9 @@ export class PointService extends CrudService { super(api, ws, ['Point'], PointResponse.fromJson); } - relative(series: Series[], outer: Interval, offset: number, count: number, interval: Interval, next: Next): void { + relative(series: (Series | null | undefined)[], outer: Interval, offset: number, count: number, interval: Interval, next: Next): void { const request = { - ids: series.map(s => s.id), + ids: series.filter(notNull).map(s => s.id), outerInterval: outer.name, offset: offset, count: count, diff --git a/src/main/angular/src/app/series/Value.ts b/src/main/angular/src/app/series/Value.ts index e3f4a02..0aa736f 100644 --- a/src/main/angular/src/app/series/Value.ts +++ b/src/main/angular/src/app/series/Value.ts @@ -29,6 +29,9 @@ export class Value { return `--- ${this.unit}` } const scale = Math.floor(Math.log10(this.value)); + if(isNaN(scale)) { + return '0'; + } const rest = scale - this.precision + 1; if (rest >= 0) { return `${Math.round(this.value)} ${this.unit}`; diff --git a/src/main/java/de/ph87/data/point/PointController.java b/src/main/java/de/ph87/data/point/PointController.java index 60d3cae..aceaa65 100644 --- a/src/main/java/de/ph87/data/point/PointController.java +++ b/src/main/java/de/ph87/data/point/PointController.java @@ -1,5 +1,6 @@ package de.ph87.data.point; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.PostMapping; @@ -15,6 +16,7 @@ public class PointController { private final PointService pointService; + @NonNull @PostMapping("relative") public PointResponse points(@RequestBody final PointRequestRelative request) { return pointService.points(request);