location WIP
This commit is contained in:
parent
37beb05ca7
commit
479b5ff76e
@ -9,7 +9,6 @@ insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
# noinspection EditorConfigKeyCorrectness
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
|
||||
1
src/main/angular/.gitignore
vendored
1
src/main/angular/.gitignore
vendored
@ -36,6 +36,7 @@ yarn-error.log
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Angular
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.1.
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.7.
|
||||
|
||||
## Development server
|
||||
|
||||
|
||||
1188
src/main/angular/package-lock.json
generated
1188
src/main/angular/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -28,21 +28,15 @@
|
||||
"@angular/forms": "^20.3.0",
|
||||
"@angular/platform-browser": "^20.3.0",
|
||||
"@angular/router": "^20.3.0",
|
||||
"@fortawesome/angular-fontawesome": "^3.0.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||
"@stomp/ng2-stompjs": "^8.0.0",
|
||||
"@stomp/stompjs": "^7.2.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"ng2-charts": "^8.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
"zone.js": "~0.15.0",
|
||||
"@stomp/ng2-stompjs": "^8.0.0",
|
||||
"@stomp/stompjs": "^7.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^20.3.1",
|
||||
"@angular/cli": "^20.3.1",
|
||||
"@angular/build": "^20.3.7",
|
||||
"@angular/cli": "^20.3.7",
|
||||
"@angular/compiler-cli": "^20.3.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.9.0",
|
||||
|
||||
@ -1,350 +0,0 @@
|
||||
import {StompService} from "@stomp/ng2-stompjs";
|
||||
import {filter, map, Subject, Subscription} from "rxjs";
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {RxStompState} from '@stomp/rx-stomp';
|
||||
import {formatNumber} from '@angular/common';
|
||||
|
||||
export type FromJson<T> = (json: any) => T;
|
||||
|
||||
export type FromJsonIndexed<T> = (json: any, index: number) => T;
|
||||
|
||||
export type Next<T> = (item: T) => any;
|
||||
|
||||
export type Compare<T> = (a: T, b: T) => number;
|
||||
|
||||
export type Equals<T> = (a: T, b: T) => boolean;
|
||||
|
||||
export function stompServiceFactory() {
|
||||
const stomp = new StompService({
|
||||
url: url('ws', ['websocket']),
|
||||
debug: false,
|
||||
heartbeat_in: 2000,
|
||||
heartbeat_out: 2000,
|
||||
reconnect_delay: 2000,
|
||||
headers: {},
|
||||
});
|
||||
stomp.activate();
|
||||
return stomp;
|
||||
}
|
||||
|
||||
export function validateNumber(json: any): number {
|
||||
if (typeof json !== 'number') {
|
||||
throw new Error("Not a number: " + JSON.stringify(json));
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export function validateString(json: any): string {
|
||||
if (typeof json !== 'string') {
|
||||
throw new Error("Not a string: " + JSON.stringify(json));
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export function validateDate(json: any): Date {
|
||||
return new Date(Date.parse(validateString(json)));
|
||||
}
|
||||
|
||||
export function validateBoolean(json: any): boolean {
|
||||
if (typeof json !== 'boolean') {
|
||||
throw new Error("Not a boolean: " + JSON.stringify(json));
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export function validateEnum<T extends Record<string, string>>(value: any, enumType: T): T[keyof T] {
|
||||
const str = validateString(value);
|
||||
if (Object.values(enumType).includes(str)) {
|
||||
return str as T[keyof T];
|
||||
}
|
||||
throw new Error(`Invalid enum value: ${str}`);
|
||||
}
|
||||
|
||||
export function validateList<T>(json: any, fromJson: FromJson<T>): T[] {
|
||||
return json.map(fromJson);
|
||||
}
|
||||
|
||||
export function validateListIndexed<T>(json: any, fromJson: FromJsonIndexed<T>): T[] {
|
||||
return json.map(fromJson);
|
||||
}
|
||||
|
||||
export function url(protocol: string, path: any[]): string {
|
||||
const secure = location.protocol.endsWith('s:') ? 's' : '';
|
||||
return `${protocol}${secure}://${location.hostname}:8080/${path.join('/')}`;
|
||||
}
|
||||
|
||||
export const compareDates = (a: Date, b: Date) => a.getTime() - b.getTime();
|
||||
|
||||
export const compareStrings = (a: string, b: string) => a.localeCompare(b);
|
||||
|
||||
export const compareNumbers = (a: number, b: number) => a - b;
|
||||
|
||||
export const compareBool = (a: boolean, b: boolean) => (a ? 1 : 0) - (b ? 1 : 0);
|
||||
|
||||
export function compareNullable<T>(compare: Compare<T>): Compare<T | null | undefined> {
|
||||
return (a: T | null | undefined, b: T | null | undefined) => {
|
||||
const aNull = a === null || a === undefined;
|
||||
const bNull = b === null || b === undefined;
|
||||
if (aNull) {
|
||||
if (bNull) {
|
||||
return 0;
|
||||
} else {
|
||||
return +1;
|
||||
}
|
||||
} else {
|
||||
if (bNull) {
|
||||
return -1;
|
||||
} else {
|
||||
return compare(a, b);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mapNotNull<T, R>(t: T | null | undefined, mapper: (t: T) => R): R | null {
|
||||
if (t === null || t === undefined) {
|
||||
return null;
|
||||
}
|
||||
return mapper(t);
|
||||
}
|
||||
|
||||
export function mapNotNullOrGet<T, R>(t: T | null | undefined, mapper: (t: T) => R, getFallback: () => R): R {
|
||||
if (t === null || t === undefined) {
|
||||
return getFallback();
|
||||
}
|
||||
return mapper(t);
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly stompService: StompService,
|
||||
) {
|
||||
this.websocketConnected(() => this._websocketError = false);
|
||||
this.websocketDisconnected(() => this._websocketError = true);
|
||||
}
|
||||
|
||||
get websocketError(): boolean {
|
||||
return this._websocketError;
|
||||
}
|
||||
|
||||
getSingle<T>(path: any[], fromJson: FromJson<T>, next?: Next<T>): void {
|
||||
this.http.get<any>(url('http', path)).pipe(map(fromJson)).subscribe(next);
|
||||
}
|
||||
|
||||
getList<T>(path: any[], fromJson: FromJson<T>, next?: Next<T[]>): void {
|
||||
this.http.get<any[]>(url('http', path)).pipe(map(list => list.map(fromJson))).subscribe(next);
|
||||
}
|
||||
|
||||
postSingle<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T>): void {
|
||||
this.http.post<any>(url('http', path), data).pipe(map(fromJson)).subscribe(next);
|
||||
}
|
||||
|
||||
postList<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T[]>): void {
|
||||
this.http.post<any[]>(url('http', path), data).pipe(map(list => list.map(fromJson))).subscribe(next);
|
||||
}
|
||||
|
||||
private _websocketError: boolean = false;
|
||||
|
||||
websocketConnected(next: Next<void>): Subscription {
|
||||
return this.stompService.connectionState$.pipe(filter(state => state === RxStompState.OPEN)).subscribe(_ => next());
|
||||
}
|
||||
|
||||
websocketDisconnected(next: Next<void>): Subscription {
|
||||
return this.stompService.connectionState$.pipe(filter(state => state !== RxStompState.OPEN)).subscribe(_ => next());
|
||||
}
|
||||
|
||||
subscribe<T>(topic: any[], fromJson: FromJson<T>, next?: Next<T>): Subscription {
|
||||
return this.stompService
|
||||
.subscribe(topic.join("/"))
|
||||
.pipe(
|
||||
map(message => message.body),
|
||||
map(b => JSON.parse(b)),
|
||||
map(j => fromJson(j)),
|
||||
)
|
||||
.subscribe(next);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export abstract class CrudService<T> {
|
||||
|
||||
protected constructor(
|
||||
readonly api: ApiService,
|
||||
readonly path: any[],
|
||||
readonly fromJson: FromJson<T>,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
findAll(next: Next<T[]>): void {
|
||||
this.getList(["findAll"], next);
|
||||
}
|
||||
|
||||
protected getSingle(path: any[], next?: Next<T>) {
|
||||
this.api.getSingle<T>([...this.path, ...path], this.fromJson, next);
|
||||
}
|
||||
|
||||
protected getList(path: any[], next?: Next<T[]>) {
|
||||
this.api.getList<T>([...this.path, ...path], this.fromJson, next);
|
||||
}
|
||||
|
||||
protected postSingle(path: any[], data: any, next?: Next<T>) {
|
||||
this.api.postSingle<T>([...this.path, ...path], data, this.fromJson, next);
|
||||
}
|
||||
|
||||
subscribe(next: Next<T>, path: any[] = []): Subscription {
|
||||
const subs: Subscription[] = [];
|
||||
subs.push(this.api.subscribe([...this.path, ...path], this.fromJson, next));
|
||||
return new Subscription(() => subs.forEach(sub => sub.unsubscribe()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export abstract class ID<T extends ID<T>> {
|
||||
|
||||
abstract get id(): number;
|
||||
|
||||
abstract get deleted(): boolean;
|
||||
|
||||
hasId(id: number): boolean {
|
||||
return this.id === id;
|
||||
}
|
||||
|
||||
equals(t: T | null): boolean {
|
||||
return t !== null && t !== undefined && t.hasId(this.id);
|
||||
}
|
||||
|
||||
equals_(): (t: (T | null)) => boolean {
|
||||
return (t: T | null) => this.equals(t);
|
||||
}
|
||||
|
||||
static equals<X extends ID<X>>(a: X, b: X): boolean {
|
||||
return ID.hasId<X>(a.id)(b);
|
||||
}
|
||||
|
||||
static hasId<X extends ID<X>>(id: number): (t: X) => boolean {
|
||||
return (t: X) => t.hasId(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export abstract class EntityListService<T extends ID<T>> extends CrudService<T> {
|
||||
|
||||
private readonly subject: Subject<T> = new Subject();
|
||||
|
||||
private _list: T[] = [];
|
||||
|
||||
protected constructor(
|
||||
api: ApiService,
|
||||
path: any[],
|
||||
fromJson: FromJson<T>,
|
||||
readonly equals: Equals<T>,
|
||||
readonly compare: Compare<T>,
|
||||
) {
|
||||
super(api, path, fromJson);
|
||||
this.findAll(all => {
|
||||
this._list = all;
|
||||
all.forEach(t => this.subject.next(t));
|
||||
});
|
||||
this.subscribe(this._update);
|
||||
}
|
||||
|
||||
private readonly _update = (fresh: T) => {
|
||||
const index = this._list.findIndex(e => this.equals(e, fresh));
|
||||
if (fresh.deleted) {
|
||||
if (index >= 0) {
|
||||
this._list.splice(index, 1);
|
||||
}
|
||||
} else if (index >= 0) {
|
||||
this._list[index] = fresh;
|
||||
} else {
|
||||
this._list.push(fresh);
|
||||
}
|
||||
this._list = this._list.sort(this.compare);
|
||||
this.subject.next(fresh);
|
||||
};
|
||||
|
||||
get list(): T[] {
|
||||
return [...this._list];
|
||||
}
|
||||
|
||||
subscribeListItems(next: Next<T>): Subscription {
|
||||
return this.subject.subscribe(next);
|
||||
}
|
||||
|
||||
byId(id: number): T {
|
||||
return this._list.filter(t => t.hasId(id))[0];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function NTU<T>(v: T | null | undefined): T | undefined {
|
||||
if (v === null) {
|
||||
return undefined;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function UTN<T>(v: T | null | undefined): T | null {
|
||||
if (v === undefined) {
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function ageString(event: Date | null, now: Date, long: boolean = true) {
|
||||
if (event === null) {
|
||||
return '-';
|
||||
}
|
||||
const secondsTotal = Math.max(0, (now.getTime() - event.getTime()) / 1000);
|
||||
|
||||
const minutesTotal = secondsTotal / 60;
|
||||
const hoursTotal = minutesTotal / 60;
|
||||
const daysTotal = hoursTotal / 24;
|
||||
const yearsTotal = daysTotal / 365;
|
||||
|
||||
const secondsPart = secondsTotal % 60;
|
||||
const minutesPart = minutesTotal % 60;
|
||||
const hoursPart = hoursTotal % 24;
|
||||
const daysPart = daysTotal % 365;
|
||||
|
||||
const locale = 'de-DE';
|
||||
const longDigits = "2.0-0";
|
||||
const shortDigits = "0.0-0";
|
||||
if (yearsTotal >= 1) {
|
||||
if (long) {
|
||||
return `${formatNumber(yearsTotal, locale, shortDigits)}y ${formatNumber(daysPart, locale, longDigits)}d ${formatNumber(hoursPart, locale, longDigits)}h ${formatNumber(minutesPart, locale, longDigits)}m ${formatNumber(secondsPart, locale, longDigits)}s`;
|
||||
} else {
|
||||
return `${formatNumber(yearsTotal, locale, shortDigits)}y ${formatNumber(daysPart, locale, shortDigits)}d`;
|
||||
}
|
||||
}
|
||||
if (daysTotal >= 1) {
|
||||
if (long) {
|
||||
return `${formatNumber(daysPart, locale, shortDigits)}d ${formatNumber(hoursPart, locale, longDigits)}h ${formatNumber(minutesPart, locale, longDigits)}m ${formatNumber(secondsPart, locale, longDigits)}s`;
|
||||
} else {
|
||||
return `${formatNumber(daysPart, locale, shortDigits)}d ${formatNumber(hoursPart, locale, shortDigits)}h`;
|
||||
}
|
||||
}
|
||||
if (hoursTotal >= 1) {
|
||||
if (long) {
|
||||
return `${formatNumber(hoursPart, locale, shortDigits)}h ${formatNumber(minutesPart, locale, longDigits)}m ${formatNumber(secondsPart, locale, longDigits)}s`;
|
||||
} else {
|
||||
return `${formatNumber(hoursPart, locale, shortDigits)}h ${formatNumber(minutesPart, locale, shortDigits)}m`;
|
||||
}
|
||||
}
|
||||
if (minutesTotal >= 1) {
|
||||
if (long) {
|
||||
return `${formatNumber(minutesPart, locale, shortDigits)}m ${formatNumber(secondsPart, locale, longDigits)}s`;
|
||||
} else {
|
||||
return `${formatNumber(minutesPart, locale, shortDigits)}m ${formatNumber(secondsPart, locale, shortDigits)}s`;
|
||||
}
|
||||
}
|
||||
return `${formatNumber(secondsPart, locale, shortDigits)}s`;
|
||||
}
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import {ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection} from '@angular/core';
|
||||
import {ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners, provideZoneChangeDetection} from '@angular/core';
|
||||
import {provideRouter} from '@angular/router';
|
||||
|
||||
import 'chartjs-adapter-date-fns';
|
||||
|
||||
import {routes} from './app.routes';
|
||||
import {provideHttpClient} from '@angular/common/http';
|
||||
import {stompServiceFactory} from './COMMON';
|
||||
|
||||
import {StompService} from '@stomp/ng2-stompjs';
|
||||
import {registerLocaleData} from '@angular/common';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import localeDeExtra from '@angular/common/locales/extra/de';
|
||||
import {stompServiceFactory} from './common';
|
||||
|
||||
registerLocaleData(localeDe, 'de-DE', localeDeExtra);
|
||||
|
||||
@ -20,5 +19,6 @@ export const appConfig: ApplicationConfig = {
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
{provide: StompService, useFactory: stompServiceFactory},
|
||||
{provide: LOCALE_ID, useValue: 'de-DE'},
|
||||
]
|
||||
};
|
||||
|
||||
@ -1,21 +1 @@
|
||||
<div class="sidebar">
|
||||
@if (sidebar) {
|
||||
<div class="content">
|
||||
<div class="item" [routerLink]="['Dashboard']" routerLinkActive="itemActive" (click)="sidebar = false">Dashboard</div>
|
||||
<div class="item" [routerLink]="['PlotEditor']" routerLinkActive="itemActive" (click)="sidebar = false">Diagramm-Editor</div>
|
||||
<div class="item" [routerLink]="['TopicList']" routerLinkActive="itemActive" (click)="sidebar = false">Topics</div>
|
||||
<div class="item" [routerLink]="['SeriesList']" routerLinkActive="itemActive" (click)="sidebar = false">Messreihen</div>
|
||||
</div>
|
||||
}
|
||||
<div class="handle" (click)="sidebar = !sidebar">
|
||||
@if (sidebar) {
|
||||
<
|
||||
} @else {
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bodyContent">
|
||||
<router-outlet/>
|
||||
</div>
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
@sidebarColor: #62b0ca;
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
> * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.handle {
|
||||
padding: 0.5em;
|
||||
height: 1.5em;
|
||||
border-right: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-right-radius: 0.5em;
|
||||
background-color: @sidebarColor;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.handle:hover {
|
||||
background-color: lightyellow;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
border-right: 1px solid gray;
|
||||
background-color: @sidebarColor;
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 1em);
|
||||
|
||||
.item {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: lightyellow;
|
||||
}
|
||||
|
||||
.itemActive {
|
||||
background-color: steelblue;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.bodyContent {
|
||||
margin-left: 2em;
|
||||
}
|
||||
@ -1,14 +1,7 @@
|
||||
import {Routes} from '@angular/router';
|
||||
import {PlotEditor} from './plot/editor/plot-editor.component';
|
||||
import {DashboardComponent} from './dashboard/dashboard.component';
|
||||
import {TopicListComponent} from './topic/list/topic-list.component';
|
||||
import {SeriesListComponent} from './series/list/series-list.component';
|
||||
import {LocationList} from './location/list/location-list';
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: 'Dashboard', component: DashboardComponent},
|
||||
{path: 'TopicList', component: TopicListComponent},
|
||||
{path: 'SeriesList', component: SeriesListComponent},
|
||||
{path: 'PlotEditor', component: PlotEditor},
|
||||
{path: 'PlotEditor/:id', component: PlotEditor},
|
||||
{path: '**', redirectTo: 'Dashboard'},
|
||||
{path: 'LocationList', component: LocationList},
|
||||
{path: '**', redirectTo: 'LocationList'}
|
||||
];
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
|
||||
import {RouterOutlet} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.less'
|
||||
})
|
||||
export class App {
|
||||
|
||||
protected sidebar: boolean = false;
|
||||
|
||||
}
|
||||
|
||||
451
src/main/angular/src/app/common.ts
Normal file
451
src/main/angular/src/app/common.ts
Normal file
@ -0,0 +1,451 @@
|
||||
import {Inject, Injectable, LOCALE_ID} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {filter, map, Subscription} from 'rxjs';
|
||||
import {StompService} from '@stomp/ng2-stompjs';
|
||||
import {RxStompState} from '@stomp/rx-stomp';
|
||||
import {formatNumber} from '@angular/common';
|
||||
|
||||
export type FromJson<T> = (json: any) => T;
|
||||
|
||||
export type Next<T> = (t: T) => any;
|
||||
|
||||
export function validateBoolean(value: any): boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`Not a boolean: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateNumber(value: any): number {
|
||||
if (typeof value !== 'number') {
|
||||
throw new Error(`Not a number: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateString(value: any): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Not a string: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateDate(value: any): Date {
|
||||
return new Date(Date.parse(validateString(value)));
|
||||
}
|
||||
|
||||
export function validateList<T>(value: any, fromJson: FromJson<T>): T[] {
|
||||
return value.map(fromJson);
|
||||
}
|
||||
|
||||
export function validateEnum<T extends Record<string, string>>(value: any, enumType: T): T[keyof T] {
|
||||
const str = validateString(value);
|
||||
if (Object.values(enumType).includes(str)) {
|
||||
return str as T[keyof T];
|
||||
}
|
||||
throw new Error(`Invalid enum value: ${str}`);
|
||||
}
|
||||
|
||||
export function or<T, R, E>(t: T | null | undefined, map: (t: T) => R, orElse: E): R | E {
|
||||
return t === null || t === undefined ? orElse : map(t);
|
||||
}
|
||||
|
||||
export function url(protocol: string, path: any[]): string {
|
||||
return `${protocol}${location.protocol === 'https://' ? 's' : ''}://localhost:8080/${path.join('/')}`;
|
||||
}
|
||||
|
||||
export function stompServiceFactory() {
|
||||
const stomp = new StompService({
|
||||
url: url('ws', ['websocket']),
|
||||
debug: false,
|
||||
heartbeat_in: 2000,
|
||||
heartbeat_out: 2000,
|
||||
reconnect_delay: 2000,
|
||||
headers: {},
|
||||
});
|
||||
stomp.activate();
|
||||
return stomp;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class WebsocketService {
|
||||
constructor(
|
||||
readonly stompService: StompService,
|
||||
) {
|
||||
this.websocketConnected(() => this._websocketError = false);
|
||||
this.websocketDisconnected(() => this._websocketError = true);
|
||||
}
|
||||
|
||||
private _websocketError: boolean = false;
|
||||
|
||||
get websocketError(): boolean {
|
||||
return this._websocketError;
|
||||
}
|
||||
|
||||
websocketConnected(next: Next<void>): Subscription {
|
||||
return this.stompService.connectionState$.pipe(filter(state => state === RxStompState.OPEN)).subscribe(_ => next());
|
||||
}
|
||||
|
||||
websocketDisconnected(next: Next<void>): Subscription {
|
||||
return this.stompService.connectionState$.pipe(filter(state => state !== RxStompState.OPEN)).subscribe(_ => next());
|
||||
}
|
||||
|
||||
subscribe<T>(topic: any[], fromJson: FromJson<T>, next: Next<T>): Subscription {
|
||||
return this.stompService
|
||||
.subscribe(topic.join("/"))
|
||||
.pipe(
|
||||
map(message => message.body),
|
||||
map(b => JSON.parse(b)),
|
||||
map(j => fromJson(j)),
|
||||
)
|
||||
.subscribe(next);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class Locale {
|
||||
|
||||
private static LOCALE: string = 'de-DE';
|
||||
|
||||
static get value(): string {
|
||||
return Locale.LOCALE;
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(LOCALE_ID) readonly locale: string,
|
||||
) {
|
||||
Locale.LOCALE = locale;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
|
||||
constructor(
|
||||
readonly client: HttpClient,
|
||||
) {
|
||||
}
|
||||
|
||||
getNone<T>(path: any[], fromJson: FromJson<T>, next?: Next<void>): void {
|
||||
this.client.get<void>(url('http', path)).subscribe(next);
|
||||
}
|
||||
|
||||
getSingle<T>(path: any[], fromJson: FromJson<T>, next?: Next<T>): void {
|
||||
this.client.get<any>(url('http', path)).pipe(map(fromJson)).subscribe(next);
|
||||
}
|
||||
|
||||
getList<T>(path: any[], fromJson: FromJson<T>, next?: Next<T[]>): void {
|
||||
this.client.get<any>(url('http', path)).pipe(map(list => list.map(fromJson))).subscribe(next);
|
||||
}
|
||||
|
||||
postNone<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<void>): void {
|
||||
this.client.post<void>(url('http', path), data).subscribe(next);
|
||||
}
|
||||
|
||||
postSingle<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T>): void {
|
||||
this.client.post<any>(url('http', path), data).pipe(map(fromJson)).subscribe(next);
|
||||
}
|
||||
|
||||
postList<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T[]>): void {
|
||||
this.client.post<any>(url('http', path), data).pipe(map(list => list.map(fromJson))).subscribe(next);
|
||||
}
|
||||
|
||||
postPage<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<Page<T>>): void {
|
||||
this.client.post<any>(url('http', path), data).pipe(map(Page.fromJson(fromJson))).subscribe(next);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class Page<T> {
|
||||
|
||||
static readonly EMPTY = new Page<any>([]);
|
||||
|
||||
constructor(
|
||||
readonly content: T[],
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson<T>(fromJson: FromJson<T>): FromJson<Page<T>> {
|
||||
return (json: any) => new Page(
|
||||
validateList(json.content, fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export abstract class CrudService<T> {
|
||||
|
||||
protected constructor(
|
||||
readonly api: ApiService,
|
||||
readonly ws: WebsocketService,
|
||||
readonly path: any[],
|
||||
readonly fromJson: FromJson<T>,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
protected getNone(path: any[], next?: Next<void>): void {
|
||||
this.api.getNone<T>([...this.path, ...path], this.fromJson, next);
|
||||
}
|
||||
|
||||
protected getSingle(path: any[], next?: Next<T>): void {
|
||||
this.api.getSingle<T>([...this.path, ...path], this.fromJson, next);
|
||||
}
|
||||
|
||||
protected getList(path: any[], next?: Next<T[]>): void {
|
||||
this.api.getList<T>([...this.path, ...path], this.fromJson, next);
|
||||
}
|
||||
|
||||
protected postNone(path: any[], data: any, next?: Next<void>): void {
|
||||
this.api.postNone<T>([...this.path, ...path], data, this.fromJson, next);
|
||||
}
|
||||
|
||||
protected postSingle(path: any[], data: any, next?: Next<T>): void {
|
||||
this.api.postSingle<T>([...this.path, ...path], data, this.fromJson, next);
|
||||
}
|
||||
|
||||
protected postList(path: any[], data: any, next?: Next<T[]>): void {
|
||||
this.api.postList<T>([...this.path, ...path], data, this.fromJson, next);
|
||||
}
|
||||
|
||||
protected postPage(path: any[], data: any, next?: Next<Page<T>>): void {
|
||||
this.api.postPage<T>([...this.path, ...path], data, this.fromJson, next);
|
||||
}
|
||||
|
||||
subscribe(next: Next<T>): Subscription {
|
||||
return this.ws.subscribe([...this.path], this.fromJson, next);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export enum SortDirection {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC'
|
||||
}
|
||||
|
||||
export class SortOrder {
|
||||
|
||||
constructor(
|
||||
readonly property: string,
|
||||
readonly direction: SortDirection,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class PageFilter {
|
||||
|
||||
page: number = 0;
|
||||
|
||||
size: number = 20;
|
||||
|
||||
orders: SortOrder[] = [];
|
||||
|
||||
}
|
||||
|
||||
export type ID = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export class Duration {
|
||||
|
||||
static readonly ZERO = new Duration(0);
|
||||
|
||||
readonly seconds: number;
|
||||
|
||||
readonly minutes: number;
|
||||
|
||||
readonly hours: number;
|
||||
|
||||
readonly days: number;
|
||||
|
||||
readonly weeks: number;
|
||||
|
||||
readonly months: number;
|
||||
|
||||
readonly years: number;
|
||||
|
||||
constructor(
|
||||
readonly milliseconds: number,
|
||||
) {
|
||||
this.seconds = milliseconds / 1000;
|
||||
this.minutes = this.seconds / 60;
|
||||
this.hours = this.minutes / 60;
|
||||
this.days = this.hours / 24;
|
||||
this.weeks = this.days / 7;
|
||||
this.months = this.days / 30;
|
||||
this.years = this.days / 365;
|
||||
}
|
||||
|
||||
get short(): string {
|
||||
return `${formatNumber(this.minutes, Locale.value, '0.0-0')} min`;
|
||||
}
|
||||
|
||||
static ofMillis(millis: number) {
|
||||
return new Duration(millis);
|
||||
}
|
||||
|
||||
static ofSeconds(seconds: number) {
|
||||
return this.ofMillis(seconds * 1000);
|
||||
}
|
||||
|
||||
static ofMinutes(minutes: number) {
|
||||
return this.ofSeconds(minutes * 60);
|
||||
}
|
||||
|
||||
static ofHours(hours: number) {
|
||||
return this.ofMinutes(hours * 60);
|
||||
}
|
||||
|
||||
static ofDays(days: number): Duration {
|
||||
return this.ofHours(days * 24);
|
||||
}
|
||||
|
||||
static ofWeeks(weeks: number): Duration {
|
||||
return this.ofDays(weeks * 7);
|
||||
}
|
||||
|
||||
static ofMonths(months: number): Duration {
|
||||
return this.ofDays(months * 30);
|
||||
}
|
||||
|
||||
static ofYears(years: number): Duration {
|
||||
return this.ofDays(years * 365);
|
||||
}
|
||||
|
||||
static fromJson(json: any): Duration {
|
||||
return new Duration(validateNumber(json),);
|
||||
}
|
||||
|
||||
lessThan(other: Duration) {
|
||||
return this.milliseconds < other.milliseconds;
|
||||
}
|
||||
|
||||
times(factor: number): Duration {
|
||||
return new Duration(this.milliseconds * factor);
|
||||
}
|
||||
|
||||
addTo(date: Date): Date {
|
||||
return new Date(date.getTime() + this.milliseconds);
|
||||
}
|
||||
|
||||
subtractFrom(date: Date): Date {
|
||||
return new Date(date.getTime() - this.milliseconds);
|
||||
}
|
||||
|
||||
plus(duration: Duration) {
|
||||
return new Duration(this.milliseconds + duration.milliseconds);
|
||||
}
|
||||
|
||||
isZero() {
|
||||
return this.milliseconds === 0;
|
||||
}
|
||||
|
||||
divideBy(duration: Duration): number {
|
||||
return this.milliseconds / duration.milliseconds;
|
||||
}
|
||||
|
||||
static between(begin: Date, end: Date): Duration {
|
||||
return new Duration(end.getTime() - begin.getTime());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function toPixels(value: string): number {
|
||||
const el = document.createElement('div');
|
||||
el.style.position = 'absolute';
|
||||
el.style.visibility = 'hidden';
|
||||
el.style.width = value;
|
||||
document.body.appendChild(el);
|
||||
const pixels = el.offsetWidth;
|
||||
document.body.removeChild(el);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
export class Unique<T> {
|
||||
|
||||
private list: T[] = [];
|
||||
|
||||
get all(): T[] {
|
||||
return [...this.list];
|
||||
}
|
||||
|
||||
constructor(readonly equals: (a: T, b: T) => boolean) {
|
||||
this.equals = equals;
|
||||
}
|
||||
|
||||
readonly reset = (list: T[] = []): void => {
|
||||
this.list.length = 0;
|
||||
this.add(list);
|
||||
};
|
||||
|
||||
readonly add = (item: T | T[] | null | undefined): void => {
|
||||
if (item === null || item === undefined) {
|
||||
return;
|
||||
}
|
||||
if (item instanceof Array) {
|
||||
item.forEach(this.add);
|
||||
return;
|
||||
}
|
||||
const index = this.list.findIndex(it => this.equals(it, item));
|
||||
if (index < 0) {
|
||||
this.list.push(item);
|
||||
} else {
|
||||
this.list[index] = item;
|
||||
}
|
||||
};
|
||||
|
||||
readonly remove = (item: T | T[] | null | undefined): void => {
|
||||
if (item === null || item === undefined) {
|
||||
return;
|
||||
}
|
||||
if (item instanceof Array) {
|
||||
item.forEach(this.add);
|
||||
return;
|
||||
}
|
||||
this.removeIf(it => this.equals(it, item))
|
||||
};
|
||||
|
||||
get length(): number {
|
||||
return this.list.length;
|
||||
}
|
||||
|
||||
map<R>(mapper: (item: T) => R): R[] {
|
||||
return this.list.map(mapper);
|
||||
}
|
||||
|
||||
* [Symbol.iterator](): Generator<T, void, unknown> {
|
||||
for (let i = 0; i < this.list.length; i++) {
|
||||
yield this.list[i];
|
||||
}
|
||||
}
|
||||
|
||||
has(item: T): boolean {
|
||||
return this.list.some(it => this.equals(it, item));
|
||||
}
|
||||
|
||||
removeIf(predicate: Predicate<T>) {
|
||||
this.list = this.list.filter(not(predicate));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type Predicate<T> = (t: T) => boolean;
|
||||
|
||||
export function notNull(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
export function not<T>(predicate: Predicate<T>) {
|
||||
return (t: T) => !predicate(t);
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
export class Value {
|
||||
|
||||
readonly integer: string;
|
||||
|
||||
readonly fraction: string;
|
||||
|
||||
readonly showDot: boolean;
|
||||
|
||||
constructor(
|
||||
readonly value: number | null,
|
||||
decimals: number,
|
||||
) {
|
||||
if (value !== null) {
|
||||
const parts = (value + '').split('.');
|
||||
if (parts.length === 2) {
|
||||
this.integer = parts[0];
|
||||
this.fraction = parts[1].substring(0, decimals).padEnd(decimals, '0');
|
||||
} else {
|
||||
this.integer = parts[0];
|
||||
this.fraction = '0'.repeat(decimals);
|
||||
}
|
||||
} else {
|
||||
this.integer = '-';
|
||||
this.fraction = '';
|
||||
}
|
||||
this.showDot = value !== null && decimals > 0;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<input type="checkbox" [(ngModel)]="model" (change)="apply()">
|
||||
@ -1,33 +0,0 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkbox',
|
||||
imports: [
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './checkbox.component.html',
|
||||
styleUrl: './checkbox.component.less'
|
||||
})
|
||||
export class CheckboxComponent {
|
||||
|
||||
protected _initial: boolean = false;
|
||||
|
||||
protected model: boolean = false;
|
||||
|
||||
@Output()
|
||||
readonly onChange = new EventEmitter<boolean>();
|
||||
|
||||
@Input()
|
||||
set initial(v: boolean) {
|
||||
this._initial = v;
|
||||
this.model = this._initial;
|
||||
}
|
||||
|
||||
apply() {
|
||||
if (this.model !== this._initial) {
|
||||
this.onChange.emit(this.model);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
<div (click)="click()">
|
||||
{{ title === null ? column.title : title }}
|
||||
@if (sorter.isAscending(column)) {
|
||||
<span class="sub">↑</span>
|
||||
}
|
||||
@if (sorter.isDescending(column)) {
|
||||
<span class="sub">↓</span>
|
||||
}
|
||||
@if (sorter.showCount(column)) {
|
||||
<span class="sub">{{ sorter.indexOf(column) + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
@ -1,3 +0,0 @@
|
||||
.sub {
|
||||
font-size: 50%;
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {Column} from '../sorter/Column';
|
||||
import {Sorter} from '../sorter/Sorter';
|
||||
|
||||
@Component({
|
||||
selector: 'th[app-column]',
|
||||
imports: [],
|
||||
templateUrl: './column.component.html',
|
||||
styleUrl: './column.component.less'
|
||||
})
|
||||
export class ColumnComponent<T> {
|
||||
|
||||
@Input()
|
||||
column!: Column<T>;
|
||||
|
||||
@Input()
|
||||
sorter!: Sorter<T>;
|
||||
|
||||
@Input()
|
||||
title: string | null = null;
|
||||
|
||||
@Output()
|
||||
readonly onChange: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
click() {
|
||||
this.sorter.toggle(this.column);
|
||||
this.onChange.emit();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<input type="number" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (change)="!edit && apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">
|
||||
@ -1,53 +0,0 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-number',
|
||||
imports: [
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './number.component.html',
|
||||
styleUrl: './number.component.less'
|
||||
})
|
||||
export class NumberComponent {
|
||||
|
||||
protected _initial: number | null = null;
|
||||
|
||||
protected edit: boolean = false;
|
||||
|
||||
protected model: string = "";
|
||||
|
||||
@Output()
|
||||
readonly onChange = new EventEmitter<number | null>();
|
||||
|
||||
@Input()
|
||||
set initial(v: number | null) {
|
||||
this._initial = v;
|
||||
if (!this.edit) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.edit = true;
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.edit = false;
|
||||
const value = parseFloat(this.model);
|
||||
const value1 = isNaN(value) ? null : value;
|
||||
if (value1 !== this._initial) {
|
||||
this.onChange.emit(value1);
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.edit = false;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.model = this._initial === null ? "" : this._initial + "";
|
||||
}
|
||||
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<input type="number" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (change)="!edit && apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">
|
||||
@ -1,56 +0,0 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-number-nn',
|
||||
imports: [
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './number-n-n.component.html',
|
||||
styleUrl: './number-n-n.component.less'
|
||||
})
|
||||
export class NumberNNComponent {
|
||||
|
||||
protected _initial!: number;
|
||||
|
||||
protected edit: boolean = false;
|
||||
|
||||
protected model: string = "";
|
||||
|
||||
@Output()
|
||||
readonly onChange = new EventEmitter<number>();
|
||||
|
||||
@Input()
|
||||
set initial(v: number) {
|
||||
this._initial = v;
|
||||
if (!this.edit) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.edit = true;
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.edit = false;
|
||||
const value = parseFloat(this.model);
|
||||
if (isNaN(value)) {
|
||||
this.reset();
|
||||
return
|
||||
}
|
||||
if (value != this._initial) {
|
||||
this.onChange.emit(value);
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.edit = false;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.model = this._initial === null ? "" : this._initial + "";
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import {Compare} from '../../COMMON';
|
||||
|
||||
export class Column<T> {
|
||||
|
||||
private constructor(
|
||||
readonly name: string,
|
||||
readonly title: string,
|
||||
readonly compare: Compare<T>,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static create<T, R>(
|
||||
name: string,
|
||||
title: string,
|
||||
getter: (value: T) => R,
|
||||
compare: Compare<R>,
|
||||
) {
|
||||
return new Column<T>(
|
||||
name,
|
||||
title,
|
||||
(a, b) => compare(getter(a), getter(b)),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export enum Direction {
|
||||
ASC = "ASC",
|
||||
DESC = "DESC",
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import {Column} from "./Column";
|
||||
import {Direction} from './Direction';
|
||||
|
||||
export class Order<T> {
|
||||
|
||||
constructor(
|
||||
public column: Column<T>,
|
||||
public direction: Direction = Direction.ASC,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
toJson(): {} {
|
||||
return {
|
||||
property: this.column.name,
|
||||
direction: this.direction,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import {Order} from "./Order";
|
||||
import {Column} from "./Column";
|
||||
import {Direction} from './Direction';
|
||||
|
||||
export abstract class Sorter<T> {
|
||||
|
||||
protected constructor(
|
||||
private _orders: Order<T>[] = [],
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
get orders(): Order<T>[] {
|
||||
return this._orders;
|
||||
}
|
||||
|
||||
readonly compare = (a: T, b: T) => {
|
||||
for (const order of this._orders) {
|
||||
const diff = order.column.compare(a, b);
|
||||
if (diff !== 0) {
|
||||
return order.direction === Direction.ASC ? diff : -diff;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
toggle(column: Column<T>): void {
|
||||
const index = this.indexOf(column);
|
||||
if (index < 0) {
|
||||
this.orders.push(new Order(column));
|
||||
} else {
|
||||
const order = this.orders[index];
|
||||
if (order.direction === Direction.ASC) {
|
||||
order.direction = Direction.DESC;
|
||||
} else if (order.direction === Direction.DESC) {
|
||||
this.orders.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get count(): number {
|
||||
return this._orders.length;
|
||||
}
|
||||
|
||||
toJson(): {} {
|
||||
return this.orders.map(o => o.toJson());
|
||||
}
|
||||
|
||||
isAscending(column: Column<T>): boolean {
|
||||
return this.orders.some(o => o.column === column && o.direction === Direction.ASC);
|
||||
}
|
||||
|
||||
isDescending(column: Column<T>): boolean {
|
||||
return this.orders.some(o => o.column === column && o.direction === Direction.DESC);
|
||||
}
|
||||
|
||||
indexOf(column: Column<T>) {
|
||||
return this.orders.findIndex(o => o.column === column);
|
||||
}
|
||||
|
||||
showCount(column: Column<T>): boolean {
|
||||
return this.count > 1 && this.orders.some(o => o.column === column);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<input type="text" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">
|
||||
@ -1,51 +0,0 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-text',
|
||||
imports: [
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './text.component.html',
|
||||
styleUrl: './text.component.less'
|
||||
})
|
||||
export class TextComponent {
|
||||
|
||||
protected _initial!: string;
|
||||
|
||||
private edit: boolean = false;
|
||||
|
||||
protected model: string = "";
|
||||
|
||||
@Output()
|
||||
readonly onChange = new EventEmitter<string>();
|
||||
|
||||
@Input()
|
||||
set initial(v: string) {
|
||||
this._initial = v;
|
||||
if (!this.edit) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.edit = true;
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.edit = false;
|
||||
if (this.model !== this._initial) {
|
||||
this.onChange.emit(this.model);
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.edit = false;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.model = this._initial;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import {TopicListConfig} from './TopicListConfig';
|
||||
import {SeriesListConfig} from './SeriesListConfig';
|
||||
|
||||
export class Config {
|
||||
|
||||
constructor(
|
||||
public topicList: TopicListConfig = new TopicListConfig(),
|
||||
public seriesList: SeriesListConfig = new SeriesListConfig(),
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
toJson(): {} {
|
||||
return {
|
||||
topicList: this.topicList,
|
||||
seriesList: this.seriesList.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJson(json: any): Config {
|
||||
return new Config(
|
||||
TopicListConfig.fromJson(json.topicList),
|
||||
SeriesListConfig.fromJson(json.seriesList),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import {Interval} from '../series/Interval';
|
||||
import {SeriesSorter} from '../series/SeriesSorter';
|
||||
import {mapNotNull, validateNumber, validateString} from '../COMMON';
|
||||
|
||||
export class SeriesListConfig {
|
||||
|
||||
constructor(
|
||||
public interval: Interval | null = null,
|
||||
public offset: number = 0,
|
||||
public search: string = "",
|
||||
public sorter: SeriesSorter = new SeriesSorter(),
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
toJson(): {} {
|
||||
return {
|
||||
interval: this.interval?.name,
|
||||
offset: this.offset,
|
||||
search: this.search,
|
||||
orders: this.sorter.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJson(json: any): SeriesListConfig {
|
||||
return new SeriesListConfig(
|
||||
mapNotNull(json.interval, Interval.fromJson),
|
||||
validateNumber(json.offset),
|
||||
validateString(json.search),
|
||||
SeriesSorter.fromJson(json.orders),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import {validateBoolean} from '../COMMON';
|
||||
|
||||
export class TopicListConfig {
|
||||
|
||||
constructor(
|
||||
public hidden: boolean = true,
|
||||
public used: boolean = true,
|
||||
public unused: boolean = true,
|
||||
public ok: boolean = true,
|
||||
public details: boolean = false,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): TopicListConfig {
|
||||
return new TopicListConfig(
|
||||
validateBoolean(json.hidden),
|
||||
validateBoolean(json.used),
|
||||
validateBoolean(json.unused),
|
||||
validateBoolean(json.ok),
|
||||
validateBoolean(json.details),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Config} from './Config';
|
||||
import {ApiService, Next} from '../COMMON';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ConfigService {
|
||||
|
||||
constructor(
|
||||
readonly api: ApiService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
get(next: Next<Config>) {
|
||||
this.api.getSingle(["Config", "get"], Config.fromJson, next);
|
||||
}
|
||||
|
||||
set(config: Config, next: Next<Config>) {
|
||||
this.api.postSingle(["Config", "set"], config.toJson(), Config.fromJson, next);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
<div class="plots">
|
||||
<div class="plot">
|
||||
<app-weather-component></app-weather-component>
|
||||
</div>
|
||||
@for (plot of plots; track plot.id) {
|
||||
<div class="plot">
|
||||
<app-plot [plot]="plot"></app-plot>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@ -1,12 +0,0 @@
|
||||
.plots {
|
||||
scroll-snap-type: y mandatory;
|
||||
height: 100vh;
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
.plot {
|
||||
height: 60vw;
|
||||
scroll-snap-align: start;
|
||||
max-height: 100vh;
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {PlotService} from '../plot/plot.service';
|
||||
import {Plot} from '../plot/Plot';
|
||||
import {PlotComponent} from '../plot/plot/plot.component';
|
||||
import {WeatherComponent} from '../weather/plot/weather-component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
imports: [
|
||||
PlotComponent,
|
||||
WeatherComponent
|
||||
],
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.less'
|
||||
})
|
||||
export class DashboardComponent {
|
||||
|
||||
constructor(
|
||||
readonly plotService: PlotService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
protected get plots(): Plot[] {
|
||||
return this.plotService.list.filter(p => p.dashboard);
|
||||
}
|
||||
|
||||
}
|
||||
23
src/main/angular/src/app/location/Location.ts
Normal file
23
src/main/angular/src/app/location/Location.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {validateNumber, validateString} from '../common';
|
||||
|
||||
export class Location {
|
||||
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly name: string,
|
||||
readonly latitude: number,
|
||||
readonly longitude: number,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): Location {
|
||||
return new Location(
|
||||
validateNumber(json.id),
|
||||
validateString(json.name),
|
||||
validateNumber(json.latitude),
|
||||
validateNumber(json.longitude),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
<div class="LocationList">
|
||||
@for (location of list; track location.id) {
|
||||
<div class="Location">
|
||||
{{ location.name }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
25
src/main/angular/src/app/location/list/location-list.ts
Normal file
25
src/main/angular/src/app/location/list/location-list.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {LocationService} from '../location-service';
|
||||
import {Location} from '../Location';
|
||||
|
||||
@Component({
|
||||
selector: 'app-location-list',
|
||||
imports: [],
|
||||
templateUrl: './location-list.html',
|
||||
styleUrl: './location-list.less',
|
||||
})
|
||||
export class LocationList implements OnInit {
|
||||
|
||||
protected list: Location[] = [];
|
||||
|
||||
constructor(
|
||||
readonly locationService: LocationService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.locationService.findAll(list => this.list = list);
|
||||
}
|
||||
|
||||
}
|
||||
18
src/main/angular/src/app/location/location-service.ts
Normal file
18
src/main/angular/src/app/location/location-service.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, CrudService, Next, WebsocketService} from '../common';
|
||||
import {Location} from './Location'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LocationService extends CrudService<Location> {
|
||||
|
||||
constructor(api: ApiService, ws: WebsocketService) {
|
||||
super(api, ws, ['Location'], Location.fromJson);
|
||||
}
|
||||
|
||||
findAll(next: Next<Location[]>) {
|
||||
this.getList(['findAll'], next);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
export enum Group {
|
||||
NONE = "NONE",
|
||||
FIVE_OF_DAY = "FIVE_OF_DAY",
|
||||
HOUR_OF_DAY = "HOUR_OF_DAY",
|
||||
HOUR_OF_WEEK = "HOUR_OF_WEEK",
|
||||
HOUR_OF_MONTH = "HOUR_OF_MONTH",
|
||||
DAY_OF_WEEK = "DAY_OF_WEEK",
|
||||
DAY_OF_MONTH = "DAY_OF_MONTH",
|
||||
DAY_OF_YEAR = "DAY_OF_YEAR",
|
||||
WEEK_OF_YEAR = "WEEK_OF_YEAR",
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import {ID, validateBoolean, validateListIndexed, validateNumber, validateString} from '../COMMON';
|
||||
import {Interval} from '../series/Interval';
|
||||
import {Axis} from './axis/Axis';
|
||||
|
||||
export class Plot extends ID<Plot> {
|
||||
|
||||
readonly axes: Axis[];
|
||||
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly version: number,
|
||||
readonly deleted: boolean,
|
||||
readonly name: string,
|
||||
readonly interval: Interval,
|
||||
readonly offset: number,
|
||||
readonly duration: number,
|
||||
readonly dashboard: boolean,
|
||||
readonly position: number,
|
||||
axes: any[],
|
||||
) {
|
||||
super();
|
||||
this.axes = validateListIndexed(axes, ((json, index) => Axis.fromJson(this, index, json)));
|
||||
}
|
||||
|
||||
static fromJson(json: any): Plot {
|
||||
return new Plot(
|
||||
validateNumber(json.id),
|
||||
validateNumber(json.version),
|
||||
validateBoolean(json.deleted),
|
||||
validateString(json.name),
|
||||
Interval.fromJson(json.interval),
|
||||
validateNumber(json.offset),
|
||||
validateNumber(json.duration),
|
||||
validateBoolean(json.dashboard),
|
||||
validateNumber(json.position),
|
||||
json.axes,
|
||||
);
|
||||
}
|
||||
|
||||
static comparePosition(a: Plot, b: Plot) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
import {mapNotNull, validateBoolean, validateList, validateNumber, validateString} from "../../COMMON";
|
||||
import {Graph} from './graph/Graph';
|
||||
import {Plot} from '../Plot';
|
||||
|
||||
export class Axis {
|
||||
|
||||
readonly graphs: Graph[];
|
||||
|
||||
constructor(
|
||||
readonly plot: Plot,
|
||||
readonly id: number,
|
||||
readonly version: number,
|
||||
readonly index: number,
|
||||
readonly name: string,
|
||||
readonly unit: string,
|
||||
readonly visible: boolean,
|
||||
readonly right: boolean,
|
||||
readonly min: number | null,
|
||||
readonly minHard: boolean,
|
||||
readonly max: number | null,
|
||||
readonly maxHard: boolean,
|
||||
graphs: any[],
|
||||
) {
|
||||
this.graphs = validateList(graphs, json => Graph.fromJson(this, json))
|
||||
}
|
||||
|
||||
static fromJson(plot: Plot, index: number, json: any): Axis {
|
||||
return new Axis(
|
||||
plot,
|
||||
validateNumber(json.id),
|
||||
validateNumber(json.version),
|
||||
index,
|
||||
validateString(json.name),
|
||||
validateString(json.unit),
|
||||
validateBoolean(json.visible),
|
||||
validateBoolean(json.right),
|
||||
mapNotNull(json.min, validateNumber),
|
||||
validateBoolean(json.minHard),
|
||||
mapNotNull(json.max, validateNumber),
|
||||
validateBoolean(json.maxHard),
|
||||
json.graphs,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
import {Series} from "../../../series/Series";
|
||||
import {Group} from "../../Group";
|
||||
import {mapNotNull, validateBoolean, validateEnum, validateNumber, validateString} from "../../../COMMON";
|
||||
import {Axis} from '../Axis';
|
||||
import {SeriesType} from '../../../series/SeriesType';
|
||||
import {GraphType} from './GraphType';
|
||||
|
||||
export enum GraphOperation {
|
||||
MINUS = 'MINUS',
|
||||
PLUS = 'PLUS',
|
||||
DIVIDE = 'DIVIDE',
|
||||
}
|
||||
|
||||
export function listGraphOperation() {
|
||||
return Object.keys(GraphOperation);
|
||||
}
|
||||
|
||||
export class Graph {
|
||||
|
||||
readonly showMin: boolean;
|
||||
|
||||
readonly showMid: boolean;
|
||||
|
||||
readonly showMax: boolean;
|
||||
|
||||
constructor(
|
||||
readonly axis: Axis,
|
||||
readonly id: number,
|
||||
readonly version: number,
|
||||
readonly series: Series,
|
||||
readonly factor: number,
|
||||
readonly operation: GraphOperation,
|
||||
readonly series2: Series | null,
|
||||
readonly factor2: number,
|
||||
readonly name: string,
|
||||
readonly visible: boolean,
|
||||
readonly type: GraphType,
|
||||
readonly fill: string,
|
||||
readonly color: string,
|
||||
readonly group: Group,
|
||||
readonly stack: string,
|
||||
readonly min: boolean,
|
||||
readonly max: boolean,
|
||||
readonly avg: boolean,
|
||||
) {
|
||||
this.showMin = this.series.type === SeriesType.VARYING && this.min;
|
||||
this.showMid = this.series.type === SeriesType.BOOL || this.series.type === SeriesType.DELTA || this.avg;
|
||||
this.showMax = this.series.type === SeriesType.VARYING && this.max;
|
||||
}
|
||||
|
||||
static fromJson(axis: Axis, json: any): Graph {
|
||||
return new Graph(
|
||||
axis,
|
||||
validateNumber(json.id),
|
||||
validateNumber(json.version),
|
||||
Series.fromJson(json.series),
|
||||
validateNumber(json.factor),
|
||||
validateEnum(json.operation, GraphOperation),
|
||||
mapNotNull(json.series2, Series.fromJson),
|
||||
validateNumber(json.factor2),
|
||||
validateString(json.name),
|
||||
validateBoolean(json.visible),
|
||||
GraphType.fromJson(json.type),
|
||||
validateString(json.fill),
|
||||
validateString(json.color),
|
||||
validateString(json.group) as Group,
|
||||
validateString(json.stack),
|
||||
validateBoolean(json.min),
|
||||
validateBoolean(json.max),
|
||||
validateBoolean(json.avg),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import {validateString} from "../../../COMMON";
|
||||
|
||||
export class GraphType {
|
||||
|
||||
protected static _values: GraphType[] = [];
|
||||
|
||||
static readonly LINE = new GraphType("LINE", "line", "Linie");
|
||||
|
||||
static readonly BAR = new GraphType("BAR", "bar", "Balken");
|
||||
|
||||
private constructor(
|
||||
readonly jsonName: string,
|
||||
readonly chartJsName: "line" | "bar",
|
||||
readonly display: string,
|
||||
) {
|
||||
GraphType._values.push(this);
|
||||
}
|
||||
|
||||
static fromJson(json: any): GraphType {
|
||||
const name = validateString(json)
|
||||
const graphType = GraphType._values.filter(i => i.jsonName === name)[0];
|
||||
if (!graphType) {
|
||||
throw new Error(`Not an GraphType: ${JSON.stringify(json)}`);
|
||||
}
|
||||
return graphType;
|
||||
}
|
||||
|
||||
static get values(): GraphType[] {
|
||||
return GraphType._values;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,244 +0,0 @@
|
||||
<div class="layout">
|
||||
<div class="header">
|
||||
<select [ngModel]="plot" (ngModelChange)="plot = $event">
|
||||
@for (p of plotService.list; track p.id) {
|
||||
<option [ngValue]="p">#{{ p.position }}: {{ p.name || '---' }}</option>
|
||||
}
|
||||
</select>
|
||||
<button class="button buttonAdd" (click)="plotService.plotCreate(setPlot)">
|
||||
<fa-icon [icon]="faPlus"></fa-icon>
|
||||
</button>
|
||||
<button class="button buttonCopy" (click)="plot && plotService.plotDuplicate(plot, setPlot)" [disabled]="!plot">
|
||||
<fa-icon [icon]="faCopy"></fa-icon>
|
||||
</button>
|
||||
<button class="button buttonRemove" (click)="plot && plotService.plotDelete(plot)" [disabled]="!plot">
|
||||
<fa-icon [icon]="faTrash"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="plot">
|
||||
<app-plot [plot]="plot"></app-plot>
|
||||
</div>
|
||||
|
||||
@if (plot) {
|
||||
|
||||
<div class="Section PlotDetails">
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Intervall</th>
|
||||
<th>Verschiebung</th>
|
||||
<th>Dauer</th>
|
||||
<th class="vertical">Dash</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<app-text [initial]="plot.name" (onChange)="plotService.plotName(plot, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="plot.interval" (ngModelChange)="plotService.plotInterval(plot, $event)">
|
||||
@for (interval of Interval.values; track interval) {
|
||||
<option [ngValue]="interval">{{ interval.display }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<app-number-nn [initial]="plot.offset" (onChange)="plotService.plotOffset(plot, $event)"></app-number-nn>
|
||||
</td>
|
||||
<td>
|
||||
<app-number-nn [initial]="plot.duration" (onChange)="plotService.plotDuration(plot, $event)"></app-number-nn>
|
||||
</td>
|
||||
<td>
|
||||
<app-checkbox [initial]="plot.dashboard" (onChange)="plotService.plotDashboard(plot, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<app-number-nn [initial]="plot.position" (onChange)="plotService.plotPosition(plot, $event)"></app-number-nn>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="Section Axes">
|
||||
<table>
|
||||
<tr>
|
||||
<th>
|
||||
<fa-icon [icon]="faEye"></fa-icon>
|
||||
</th>
|
||||
<th>
|
||||
<fa-icon [icon]="faListAlt"></fa-icon>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Einheit</th>
|
||||
<th class="vertical">rechts</th>
|
||||
<th>min</th>
|
||||
<th class="vertical">fest</th>
|
||||
<th>max</th>
|
||||
<th class="vertical">fest</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
@for (axis of plot.axes; track axis.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<app-checkbox [initial]="axis.visible" (onChange)="plotService.axisVisible(axis, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
Y{{ axis.index + 1 }}
|
||||
</td>
|
||||
<td>
|
||||
<app-text [initial]="axis.name" (onChange)="plotService.axisName(axis, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<app-text [initial]="axis.unit" (onChange)="plotService.axisUnit(axis, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<app-checkbox [initial]="axis.right" (onChange)="plotService.axisRight(axis, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<app-number [initial]="axis.min" (onChange)="plotService.axisMin(axis, $event)"></app-number>
|
||||
</td>
|
||||
<td>
|
||||
<app-checkbox [initial]="axis.minHard" (onChange)="plotService.axisMinHard(axis, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<app-number [initial]="axis.max" (onChange)="plotService.axisMax(axis, $event)"></app-number>
|
||||
</td>
|
||||
<td>
|
||||
<app-checkbox [initial]="axis.maxHard" (onChange)="plotService.axisMaxHard(axis, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<button class="button buttonRemove" (click)="plotService.axisDelete(axis)">
|
||||
<fa-icon [icon]="faTrash"></fa-icon>
|
||||
</button>
|
||||
|
||||
<button class="button buttonAdd" (click)="plotService.axisAddGraph(axis)">
|
||||
<fa-icon [icon]="faChartLine"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
|
||||
<button class="button buttonAdd" (click)="plotService.plotAddAxis(plot)">
|
||||
<fa-icon [icon]="faPlus"></fa-icon>
|
||||
Achse
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="Section Graphs">
|
||||
<table>
|
||||
<tr>
|
||||
<th>
|
||||
<fa-icon [icon]="faEye"></fa-icon>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Serie</th>
|
||||
<th>Faktor</th>
|
||||
<th>Typ</th>
|
||||
<th>Farbe</th>
|
||||
<th>Aggregat</th>
|
||||
<th>Stack</th>
|
||||
<th class="vertical">min</th>
|
||||
<th>∅</th>
|
||||
<th class="vertical">max</th>
|
||||
<th>Achse</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
@for (axis of plot.axes; track axis.id) {
|
||||
@for (graph of axis.graphs; track graph.id) {
|
||||
<tr [style.background-color]="graph.color">
|
||||
<td>
|
||||
<app-checkbox [initial]="graph.visible" (onChange)="plotService.graphVisible(graph, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<app-text [initial]="graph.name" (onChange)="plotService.graphName(graph, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="graph.series.id" (ngModelChange)="plotService.graphSeries(graph, $event)">
|
||||
@for (s of seriesService.list; track s.name) {
|
||||
<option [ngValue]="s.id">{{ s.name }} [{{ s.valueString }}]</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<app-number-nn [initial]="graph.factor" (onChange)="plotService.graphFactor(graph, $event)"></app-number-nn>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="graph.operation" (ngModelChange)="plotService.graphOperation(graph, $event)">
|
||||
@for (operation of listGraphOperation(); track operation) {
|
||||
<option [ngValue]="operation">{{ operation }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="graph.series2?.id" (ngModelChange)="plotService.graphSeries2(graph, $event)">
|
||||
<option [ngValue]="null">-</option>
|
||||
@for (s of seriesService.list; track s.name) {
|
||||
<option [ngValue]="s.id">{{ s.name }} [{{ s.valueString }}]</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<app-number-nn [initial]="graph.factor2" (onChange)="plotService.graphFactor2(graph, $event)"></app-number-nn>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="graph.type" (ngModelChange)="plotService.graphType(graph, $event)">
|
||||
@for (type of GraphType.values; track type.jsonName) {
|
||||
<option [ngValue]="type">{{ type.display }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<app-text [initial]="graph.color" (onChange)="plotService.graphColor(graph, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="graph.group" (ngModelChange)="plotService.graphGroup(graph, $event)">
|
||||
@for (group of groups(); track group) {
|
||||
<option [ngValue]="group">{{ group }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<app-text [initial]="graph.stack" (onChange)="plotService.graphStack(graph, $event)"></app-text>
|
||||
</td>
|
||||
@if (graph.series.type === SeriesType.VARYING) {
|
||||
<td class="subSeries">
|
||||
<app-checkbox [initial]="graph.min" (onChange)="plotService.graphMin(graph, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td class="subSeries">
|
||||
<app-checkbox [initial]="graph.avg" (onChange)="plotService.graphAvg(graph, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td class="subSeries">
|
||||
<app-checkbox [initial]="graph.max" (onChange)="plotService.graphMax(graph, $event)"></app-checkbox>
|
||||
</td>
|
||||
} @else {
|
||||
@if (graph.series.type === SeriesType.BOOL) {
|
||||
<td colspan="3" class="subSeries">Boolean</td>
|
||||
} @else {
|
||||
<td colspan="3" class="subSeries">Delta</td>
|
||||
}
|
||||
}
|
||||
<td>
|
||||
<select [ngModel]="graph.axis.id" (ngModelChange)="plotService.graphAxis(graph, $event)">
|
||||
@for (axis of plot.axes; track axis.id) {
|
||||
<option [ngValue]="axis.id">Y{{ axis.index + 1 }} {{ axis.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button class="button buttonRemove" (click)="plotService.graphDelete(graph)">
|
||||
<fa-icon [icon]="faTrash"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
</div>
|
||||
@ -1,43 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.plot {
|
||||
height: 60vw;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.subSeries {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vertical {
|
||||
writing-mode: vertical-rl;
|
||||
rotate: 180deg;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.Section {
|
||||
border-top: 1px solid gray;
|
||||
padding: 1em 0.5em;
|
||||
|
||||
table {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.PlotDetails {
|
||||
|
||||
}
|
||||
|
||||
.Axes {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.Graphs {
|
||||
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5em;
|
||||
}
|
||||
@ -1,145 +0,0 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {BarController, BarElement, CategoryScale, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
|
||||
import {SeriesService} from "../../series/series.service";
|
||||
import {SeriesType} from '../../series/SeriesType';
|
||||
import {PlotService} from '../plot.service';
|
||||
import {Plot} from '../Plot';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
|
||||
import {faCopy, faEye, faListAlt} from '@fortawesome/free-regular-svg-icons';
|
||||
import {TextComponent} from '../../common/text/text.component';
|
||||
import {NumberComponent} from '../../common/number/number.component';
|
||||
import {CheckboxComponent} from '../../common/checkbox/checkbox.component';
|
||||
import {Group} from '../Group';
|
||||
import {NumberNNComponent} from '../../common/numberNN/number-n-n.component';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {Interval} from '../../series/Interval';
|
||||
import {faChartLine, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons';
|
||||
import {Location} from '@angular/common';
|
||||
import {PlotComponent} from '../plot/plot.component';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {GraphType} from '../axis/graph/GraphType';
|
||||
import {GraphOperation, listGraphOperation} from '../axis/graph/Graph';
|
||||
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarController,
|
||||
BarElement,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale,
|
||||
Filler,
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: 'app-plot-editor',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
TextComponent,
|
||||
NumberComponent,
|
||||
CheckboxComponent,
|
||||
NumberNNComponent,
|
||||
PlotComponent
|
||||
],
|
||||
templateUrl: './plot-editor.component.html',
|
||||
styleUrl: './plot-editor.component.less'
|
||||
})
|
||||
export class PlotEditor implements OnInit, OnDestroy {
|
||||
|
||||
protected readonly GraphType = GraphType;
|
||||
|
||||
protected readonly GraphOperation = GraphOperation;
|
||||
|
||||
protected readonly listGraphOperation = listGraphOperation;
|
||||
|
||||
protected readonly SeriesType = SeriesType;
|
||||
|
||||
protected readonly Interval = Interval;
|
||||
|
||||
protected readonly faEye = faEye;
|
||||
|
||||
protected readonly faListAlt = faListAlt;
|
||||
|
||||
protected readonly faCopy = faCopy;
|
||||
|
||||
protected readonly faPlus = faPlus;
|
||||
|
||||
protected readonly faTrash = faTrash;
|
||||
|
||||
protected readonly faChartLine = faChartLine;
|
||||
|
||||
private id: number = NaN;
|
||||
|
||||
private _plot: Plot | null = null;
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
readonly activatedRoute: ActivatedRoute,
|
||||
readonly seriesService: SeriesService,
|
||||
readonly plotService: PlotService,
|
||||
readonly location: Location
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
get plot(): Plot | null {
|
||||
return this._plot;
|
||||
}
|
||||
|
||||
set plot(value: Plot | null) {
|
||||
this._plot = value;
|
||||
if (this._plot) {
|
||||
this.id = this._plot.id;
|
||||
}
|
||||
if (isNaN(this.id)) {
|
||||
this.location.go("/PlotEditor");
|
||||
} else {
|
||||
this.location.go("/PlotEditor/" + this.id);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subs.push(this.activatedRoute.params.subscribe(params => {
|
||||
this.id = parseInt(params['id']);
|
||||
this.loadPlot();
|
||||
this.subs.push(this.plotService.subscribeListItems(fresh => {
|
||||
if (fresh.deleted) {
|
||||
if (fresh.hasId(this.id)) {
|
||||
this.id = NaN;
|
||||
}
|
||||
} else if (isNaN(this.id)) {
|
||||
}
|
||||
this.loadPlot();
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
private loadPlot() {
|
||||
if (isNaN(this.id)) {
|
||||
this.plot = this.plotService.list[0] || null;
|
||||
this.id = this.plot?.id || NaN;
|
||||
} else {
|
||||
this.plot = this.plotService.byId(this.id) || null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(subscription => subscription.unsubscribe());
|
||||
}
|
||||
|
||||
protected readonly setPlot = (plot: Plot): void => {
|
||||
this.plot = plot;
|
||||
};
|
||||
|
||||
protected groups(): string[] {
|
||||
return Object.keys(Group);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,165 +0,0 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, EntityListService, Next} from '../COMMON';
|
||||
import {Plot} from './Plot';
|
||||
import {Axis} from './axis/Axis';
|
||||
import {Graph, GraphOperation} from './axis/graph/Graph';
|
||||
import {Group} from './Group';
|
||||
import {Interval} from '../series/Interval';
|
||||
import {GraphType} from './axis/graph/GraphType';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PlotService extends EntityListService<Plot> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ["Plot"], Plot.fromJson, Plot.equals, Plot.comparePosition);
|
||||
}
|
||||
|
||||
plotCreate(next?: Next<Plot>): void {
|
||||
this.getSingle(["create"], next);
|
||||
}
|
||||
|
||||
plotDuplicate(plot: Plot, next?: Next<Plot>): void {
|
||||
this.getSingle([plot.id, "duplicate"], next);
|
||||
}
|
||||
|
||||
plotDelete(plot: Plot, next?: Next<Plot>): void {
|
||||
this.getSingle([plot.id, "delete"], next);
|
||||
}
|
||||
|
||||
plotAddAxis(plot: Plot, next?: Next<Plot>): void {
|
||||
this.getSingle([plot.id, 'addAxis'], next);
|
||||
}
|
||||
|
||||
plotName(plot: Plot, value: string, next?: Next<Plot>): void {
|
||||
this.postSingle([plot.id, 'name'], value, next);
|
||||
}
|
||||
|
||||
plotInterval(plot: Plot, value: Interval, next?: Next<Plot>): void {
|
||||
this.postSingle([plot.id, 'interval'], value.name, next);
|
||||
}
|
||||
|
||||
plotOffset(plot: Plot, value: number, next?: Next<Plot>): void {
|
||||
this.postSingle([plot.id, 'offset'], value, next);
|
||||
}
|
||||
|
||||
plotDuration(plot: Plot, value: number, next?: Next<Plot>): void {
|
||||
this.postSingle([plot.id, 'duration'], value, next);
|
||||
}
|
||||
|
||||
plotDashboard(plot: Plot, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle([plot.id, 'dashboard'], value, next);
|
||||
}
|
||||
|
||||
plotPosition(plot: Plot, value: number, next?: Next<Plot>): void {
|
||||
this.postSingle([plot.id, 'position'], value, next);
|
||||
}
|
||||
|
||||
axisAddGraph(axis: Axis, next?: Next<Plot>): void {
|
||||
this.getSingle(['Axis', axis.id, 'addGraph'], next);
|
||||
}
|
||||
|
||||
axisDelete(axis: Axis, next?: Next<Plot>): void {
|
||||
this.getSingle(['Axis', axis.id, 'delete'], next);
|
||||
}
|
||||
|
||||
axisVisible(axis: Axis, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle(['Axis', axis.id, 'visible'], value, next);
|
||||
}
|
||||
|
||||
axisName(axis: Axis, value: string, next?: Next<Plot>): void {
|
||||
this.postSingle(['Axis', axis.id, 'name'], value, next);
|
||||
}
|
||||
|
||||
axisUnit(axis: Axis, value: string, next?: Next<Plot>): void {
|
||||
this.postSingle(['Axis', axis.id, 'unit'], value, next);
|
||||
}
|
||||
|
||||
axisRight(axis: Axis, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle(['Axis', axis.id, 'right'], value, next);
|
||||
}
|
||||
|
||||
axisMin(axis: Axis, value: number | null, next?: Next<Plot>): void {
|
||||
this.postSingle(['Axis', axis.id, 'min'], value, next);
|
||||
}
|
||||
|
||||
axisMinHard(axis: Axis, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle(['Axis', axis.id, 'minHard'], value, next);
|
||||
}
|
||||
|
||||
axisMax(axis: Axis, value: number | null, next?: Next<Plot>): void {
|
||||
this.postSingle(['Axis', axis.id, 'max'], value, next);
|
||||
}
|
||||
|
||||
axisMaxHard(axis: Axis, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle(['Axis', axis.id, 'maxHard'], value, next);
|
||||
}
|
||||
|
||||
graphDelete(graph: Graph, next?: Next<Plot>): void {
|
||||
this.getSingle(['Graph', graph.id, 'delete'], next);
|
||||
}
|
||||
|
||||
graphVisible(graph: Graph, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'visible'], value, next);
|
||||
}
|
||||
|
||||
graphType(graph: Graph, value: GraphType, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'type'], value.jsonName, next);
|
||||
}
|
||||
|
||||
graphName(graph: Graph, value: string, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'name'], value, next);
|
||||
}
|
||||
|
||||
graphSeries(graph: Graph, value: number, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'series'], value, next);
|
||||
}
|
||||
|
||||
graphFactor(graph: Graph, value: number, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'factor'], value, next);
|
||||
}
|
||||
|
||||
graphOperation(graph: Graph, value: GraphOperation, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'operation'], value, next);
|
||||
}
|
||||
|
||||
graphSeries2(graph: Graph, value: number, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'series2'], value, next);
|
||||
}
|
||||
|
||||
graphFactor2(graph: Graph, value: number, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'factor2'], value, next);
|
||||
}
|
||||
|
||||
graphColor(graph: Graph, value: string, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'color'], value, next);
|
||||
}
|
||||
|
||||
graphGroup(graph: Graph, value: Group, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'group'], value, next);
|
||||
}
|
||||
|
||||
graphStack(graph: Graph, value: string, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'stack'], value, next);
|
||||
}
|
||||
|
||||
graphMin(graph: Graph, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'min'], value, next);
|
||||
}
|
||||
|
||||
graphMax(graph: Graph, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'max'], value, next);
|
||||
}
|
||||
|
||||
graphAvg(graph: Graph, value: boolean, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'avg'], value, next);
|
||||
}
|
||||
|
||||
graphAxis(graph: Graph, value: number, next?: Next<Plot>): void {
|
||||
this.postSingle(['Graph', graph.id, 'axis'], value, next);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
<div #container class="container">
|
||||
<canvas #chartCanvas></canvas>
|
||||
</div>
|
||||
@ -1,4 +0,0 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@ -1,275 +0,0 @@
|
||||
import {AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild} from '@angular/core';
|
||||
import {BarController, BarElement, CategoryScale, Chart, ChartDataset, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
|
||||
import {toAvg, toBool, toDelta, toMax, toMin} from '../../series/MinMaxAvg';
|
||||
import {SeriesType} from '../../series/SeriesType';
|
||||
import {Plot} from '../Plot';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {NTU, UTN} from '../../COMMON';
|
||||
import {Graph} from '../axis/graph/Graph';
|
||||
import {Axis} from '../axis/Axis';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {DeltaService} from '../../series/delta/delta-service';
|
||||
import {BoolService} from '../../series/bool/bool-service';
|
||||
import {VaryingService} from '../../series/varying/varying-service';
|
||||
import {Interval} from '../../series/Interval';
|
||||
import {SeriesService} from '../../series/series.service';
|
||||
import {GraphType} from '../axis/graph/GraphType';
|
||||
import {Varying} from '../../series/varying/Varying';
|
||||
import {Delta} from '../../series/delta/Delta';
|
||||
import {Bool} from '../../series/bool/Bool';
|
||||
|
||||
type Dataset = ChartDataset<any, any>[][number];
|
||||
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarController,
|
||||
BarElement,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale,
|
||||
Filler,
|
||||
);
|
||||
|
||||
export function unitInBrackets(unit: string): string {
|
||||
if (!unit) {
|
||||
return '';
|
||||
}
|
||||
return ` [${unit}]`;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-plot',
|
||||
imports: [
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './plot.component.html',
|
||||
styleUrl: './plot.component.less'
|
||||
})
|
||||
export class PlotComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
protected readonly SeriesType = SeriesType;
|
||||
|
||||
protected readonly Interval = Interval;
|
||||
|
||||
@ViewChild('chartCanvas')
|
||||
protected canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
@ViewChild('container')
|
||||
protected chartContainer!: ElementRef<HTMLDivElement>;
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
private chart!: Chart;
|
||||
|
||||
protected _plot: Plot | null = null;
|
||||
|
||||
get plot(): Plot | null {
|
||||
return this._plot;
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly seriesService: SeriesService,
|
||||
readonly boolService: BoolService,
|
||||
readonly deltaService: DeltaService,
|
||||
readonly varyingService: VaryingService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(subscription => subscription.unsubscribe());
|
||||
this.chart.destroy();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
position: 'nearest',
|
||||
mode: 'x',
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
this.plot = this.plot;
|
||||
}
|
||||
|
||||
@Input()
|
||||
set plot(plot: Plot | null) {
|
||||
this._plot = UTN(plot);
|
||||
|
||||
this.subs.forEach(s => s.unsubscribe());
|
||||
this.subs.length = 0;
|
||||
if (!this.chart?.options?.plugins) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key in this.chart.options.scales) {
|
||||
if (key.startsWith("y")) {
|
||||
delete this.chart.options.scales[key];
|
||||
}
|
||||
}
|
||||
this.chart.data.labels = [];
|
||||
this.chart.data.datasets = [];
|
||||
|
||||
this.chart.update();
|
||||
if (!this.plot) {
|
||||
return;
|
||||
|
||||
}
|
||||
this.chart.options.plugins.title = {
|
||||
display: !!this.plot.name,
|
||||
text: this.plot.name,
|
||||
};
|
||||
for (const axis of this.plot.axes) {
|
||||
this.buildAxis(axis);
|
||||
}
|
||||
};
|
||||
|
||||
private buildAxis(axis: Axis): void {
|
||||
if (!this.chart.options.scales) {
|
||||
return;
|
||||
}
|
||||
const name = axis.name + unitInBrackets(axis.unit);
|
||||
this.chart.options.scales["y" + axis.id] = {
|
||||
display: axis.visible,
|
||||
title: {
|
||||
display: !!name,
|
||||
text: name,
|
||||
},
|
||||
suggestedMin: NTU(axis.min),
|
||||
suggestedMax: NTU(axis.max),
|
||||
min: axis.minHard ? NTU(axis.min) : undefined,
|
||||
max: axis.maxHard ? NTU(axis.max) : undefined,
|
||||
position: axis.right ? 'right' : 'left',
|
||||
};
|
||||
for (const graph of axis.graphs) {
|
||||
this.buildGraph(graph);
|
||||
}
|
||||
}
|
||||
|
||||
private buildGraph(graph: Graph): void {
|
||||
if (!graph.visible) {
|
||||
return;
|
||||
}
|
||||
const midSuffix = graph.series.type === SeriesType.DELTA || graph.series.type === SeriesType.BOOL ? "" : " Ø"
|
||||
const min: Dataset = graph.showMin ? this.newDataset(graph, " min", false) : null;
|
||||
const mid: Dataset = graph.showMid ? this.newDataset(graph, midSuffix, min ? '-1' : graph.series.type === SeriesType.BOOL) : null;
|
||||
const max: Dataset = graph.showMax ? this.newDataset(graph, " max", min || mid ? '-1' : false) : null;
|
||||
switch (graph.series.type) {
|
||||
case SeriesType.BOOL:
|
||||
this.subs.push(this.boolService.subscribe(bool => this.updateBool(graph, bool, mid), [graph.series.id]));
|
||||
break;
|
||||
case SeriesType.DELTA:
|
||||
this.subs.push(this.deltaService.subscribe(delta => this.updateDelta(graph, delta, mid), [graph.series.id, graph.axis.plot.interval.name]));
|
||||
break;
|
||||
case SeriesType.VARYING:
|
||||
this.subs.push(this.varyingService.subscribe(varying => this.updateVarying(graph, varying, min, mid, max), [graph.series.id, graph.axis.plot.interval.name]));
|
||||
break;
|
||||
}
|
||||
this.points(graph, min, mid, max);
|
||||
}
|
||||
|
||||
private newDataset(graph: Graph, suffix: string, fill: string | number | boolean): Dataset {
|
||||
this.chart.data.datasets.push({
|
||||
data: [],
|
||||
label: `${graph.name || graph.series.name}${suffix}${unitInBrackets(graph.series.unit)}`,
|
||||
type: graph.type.chartJsName,
|
||||
fill: fill || (graph.stack ? 'stack' : false),
|
||||
stack: graph.stack || undefined,
|
||||
borderWidth: 1,
|
||||
barThickness: 'flex',
|
||||
borderColor: graph.color,
|
||||
pointRadius: 0, // graph.series.type === SeriesType.BOOL ? 0 : 4,
|
||||
pointBackgroundColor: graph.color,
|
||||
backgroundColor: graph.color + "33",
|
||||
yAxisID: "y" + graph.axis.id,
|
||||
pointStyle: graph.type === GraphType.BAR || graph.series.type === SeriesType.BOOL ? 'rect' : 'crossRot',
|
||||
spanGaps: graph.series.type === SeriesType.BOOL ? Infinity : graph.axis.plot.interval.spanGaps,
|
||||
});
|
||||
return this.chart.data.datasets[this.chart.data.datasets.length - 1];
|
||||
}
|
||||
|
||||
private points(graph: Graph, min: Dataset, mid: Dataset, max: Dataset): void {
|
||||
this.seriesService.oneSeriesPoints(
|
||||
graph,
|
||||
points => {
|
||||
if (graph.series.type === SeriesType.BOOL) {
|
||||
mid.data = toBool(points);
|
||||
} else if (graph.series.type === SeriesType.DELTA) {
|
||||
mid.data = toDelta(points);
|
||||
} else if (graph.series.type === SeriesType.VARYING) {
|
||||
if (min) {
|
||||
min.data = toMin(points);
|
||||
}
|
||||
if (max) {
|
||||
max.data = toMax(points);
|
||||
}
|
||||
if (mid) {
|
||||
mid.data = toAvg(points);
|
||||
}
|
||||
}
|
||||
this.chart.update();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private updateBool(graph: Graph, bool: Bool, mid: Dataset): void {
|
||||
this.updatePoint(graph, mid, bool.date, bool.state ? 1 : 0);
|
||||
}
|
||||
|
||||
private updateDelta(graph: Graph, delta: Delta, mid: Dataset): void {
|
||||
this.updatePoint(graph, mid, delta.date, delta.last - delta.first);
|
||||
}
|
||||
|
||||
private updateVarying(graph: Graph, varying: Varying, min: Dataset | null, avg: Dataset | null, max: Dataset | null): void {
|
||||
if (min) {
|
||||
this.updatePoint(graph, min, varying.date, varying.min);
|
||||
}
|
||||
if (avg) {
|
||||
this.updatePoint(graph, avg, varying.date, varying.avg);
|
||||
}
|
||||
if (max) {
|
||||
this.updatePoint(graph, max, varying.date, varying.max);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePoint(graph: Graph, dataset: Dataset, date: Date, y: number): void {
|
||||
const x = date.getTime();
|
||||
const point = dataset.data.filter((p: PointElement) => p.x === x)[0];
|
||||
if (point) {
|
||||
if (point.y !== y) {
|
||||
point.y = y * graph.factor; // TODO this is wrong. we need to take GraphOperation into account
|
||||
this.chart.update();
|
||||
}
|
||||
} else {
|
||||
dataset.data.push({x: x, y: y}); // TODO check if this is a LIVE/SCROLLING plot (right end of plot is 'now')
|
||||
this.chart.update();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import {Interval} from "./Interval";
|
||||
import {validateNumber} from '../COMMON';
|
||||
|
||||
export class AllSeriesPointRequest {
|
||||
|
||||
constructor(
|
||||
readonly interval: Interval,
|
||||
readonly offset: number,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
toJson(): {} {
|
||||
return {
|
||||
interval: this.interval.name,
|
||||
offset: this.offset,
|
||||
};
|
||||
}
|
||||
|
||||
static fromJson(json: any): AllSeriesPointRequest {
|
||||
return new AllSeriesPointRequest(
|
||||
Interval.fromJson(json.interval),
|
||||
validateNumber(json.offset),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
import {mapNotNull, validateBoolean, validateDate, validateList, validateNumber} from "../COMMON";
|
||||
import {AllSeriesPointRequest} from './AllSeriesPointRequest';
|
||||
import {Series} from './Series';
|
||||
import {Value} from '../common/Value';
|
||||
|
||||
export class AllSeriesPointResponseEntry {
|
||||
|
||||
readonly min: Value;
|
||||
|
||||
readonly avg: Value;
|
||||
|
||||
readonly max: Value;
|
||||
|
||||
readonly first: Value;
|
||||
|
||||
readonly last: Value;
|
||||
|
||||
constructor(
|
||||
readonly series: Series,
|
||||
min: number | null,
|
||||
avg: number | null,
|
||||
max: number | null,
|
||||
first: number | null,
|
||||
last: number | null,
|
||||
readonly state: boolean | null,
|
||||
readonly terminated: boolean | null,
|
||||
) {
|
||||
this.min = new Value(min, series.decimals);
|
||||
this.max = new Value(max, series.decimals);
|
||||
this.first = new Value(max, series.decimals);
|
||||
this.last = new Value(max, series.decimals);
|
||||
if (avg !== null) {
|
||||
this.avg = new Value(avg, series.decimals);
|
||||
} else if (first !== null && last !== null) {
|
||||
this.avg = new Value(last - first, series.decimals);
|
||||
} else {
|
||||
this.avg = new Value(null, series.decimals);
|
||||
}
|
||||
}
|
||||
|
||||
static fromJson(json: any): AllSeriesPointResponseEntry {
|
||||
return new AllSeriesPointResponseEntry(
|
||||
Series.fromJson(json.series),
|
||||
mapNotNull(json.point?.min, validateNumber),
|
||||
mapNotNull(json.point?.avg, validateNumber),
|
||||
mapNotNull(json.point?.max, validateNumber),
|
||||
mapNotNull(json.point?.first, validateNumber),
|
||||
mapNotNull(json.point?.last, validateNumber),
|
||||
mapNotNull(json.state, validateBoolean),
|
||||
mapNotNull(json.terminated, validateBoolean),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AllSeriesPointResponse {
|
||||
|
||||
constructor(
|
||||
readonly request: AllSeriesPointRequest,
|
||||
readonly date: Date,
|
||||
readonly seriesPoints: AllSeriesPointResponseEntry[],
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): AllSeriesPointResponse {
|
||||
return new AllSeriesPointResponse(
|
||||
AllSeriesPointRequest.fromJson(json.request),
|
||||
validateDate(json.request?.first),
|
||||
validateList(json.seriesPoints, AllSeriesPointResponseEntry.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
import {validateString} from "../COMMON";
|
||||
|
||||
export class Interval {
|
||||
|
||||
protected static _values: Interval[] = [];
|
||||
|
||||
static readonly FIVE = new Interval("FIVE", "5 Minuten", 5 * 60 * 1000);
|
||||
|
||||
static readonly HOUR = new Interval("HOUR", "Stunden", 60 * 60 * 1000);
|
||||
|
||||
static readonly DAY = new Interval("DAY", "Tage", 24 * 60 * 60 * 1000);
|
||||
|
||||
static readonly WEEK = new Interval("WEEK", "Wochen", 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
static readonly MONTH = new Interval("MONTH", "Monate", 31 * 24 * 60 * 60 * 1000);
|
||||
|
||||
static readonly YEAR = new Interval("YEAR", "Jahre", 366 * 24 * 60 * 60 * 1000);
|
||||
|
||||
private constructor(
|
||||
readonly name: string,
|
||||
readonly display: string,
|
||||
readonly spanGaps: number,
|
||||
) {
|
||||
Interval._values.push(this);
|
||||
}
|
||||
|
||||
static fromJson(json: any): Interval {
|
||||
const name = validateString(json)
|
||||
const interval = Interval._values.filter(i => i.name === name)[0];
|
||||
if (!interval) {
|
||||
throw new Error(`Not an Interval: ${JSON.stringify(json)}`);
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
|
||||
static get values(): Interval[] {
|
||||
return Interval._values;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
export class Point {
|
||||
|
||||
constructor(
|
||||
public x: number,
|
||||
public y: number,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function toBool(points: number[][]): Point[] {
|
||||
const result = [];
|
||||
let postPone: Point | null = null;
|
||||
for (const p of points) {
|
||||
const state = p[2] > 0;
|
||||
const begin = {
|
||||
x: p[0] * 1000,
|
||||
y: state ? 1 : 0,
|
||||
};
|
||||
const end = {
|
||||
x: p[1] * 1000,
|
||||
y: state ? 1 : 0,
|
||||
};
|
||||
if (postPone) {
|
||||
postPone.x = begin.x;
|
||||
result.push(postPone);
|
||||
postPone = null;
|
||||
}
|
||||
result.push(begin);
|
||||
if (begin.x !== end.x) {
|
||||
result.push(end);
|
||||
} else {
|
||||
postPone = end;
|
||||
}
|
||||
}
|
||||
if (postPone) {
|
||||
// postPone.x = Date.now();
|
||||
result.push(postPone);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toDelta(points: number[][]): Point[] {
|
||||
const result = [];
|
||||
for (const p of points) {
|
||||
result.push({
|
||||
x: p[0] * 1000,
|
||||
y: (p[1]),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toMin(points: number[][]): Point[] {
|
||||
const result = [];
|
||||
for (const p of points) {
|
||||
result.push({
|
||||
x: p[0] * 1000,
|
||||
y: p[1],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toMax(points: number[][]): Point[] {
|
||||
const result = [];
|
||||
for (const p of points) {
|
||||
result.push({
|
||||
x: p[0] * 1000,
|
||||
y: p[2],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toAvg(points: number[][]): Point[] {
|
||||
const result = [];
|
||||
for (const p of points) {
|
||||
result.push({
|
||||
x: p[0] * 1000,
|
||||
y: p[3],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export enum MinMaxAvg {
|
||||
MIN = "MIN",
|
||||
MAX = "MAX",
|
||||
AVG = "AVG",
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
import {ID, mapNotNull, validateBoolean, validateDate, validateNumber, validateString} from "../COMMON";
|
||||
import {SeriesType} from './SeriesType';
|
||||
import {formatNumber} from '@angular/common';
|
||||
import {Value} from '../common/Value';
|
||||
|
||||
export class Series extends ID<Series> {
|
||||
|
||||
readonly value: Value;
|
||||
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly version: number,
|
||||
readonly deleted: boolean,
|
||||
readonly name: string,
|
||||
readonly unit: string,
|
||||
readonly type: SeriesType,
|
||||
readonly decimals: number,
|
||||
readonly expectedEverySeconds: number,
|
||||
readonly date: Date | null,
|
||||
value: number | null,
|
||||
) {
|
||||
super();
|
||||
this.value = new Value(value, this.decimals);
|
||||
}
|
||||
|
||||
static fromJson(json: any): Series {
|
||||
return new Series(
|
||||
validateNumber(json.id),
|
||||
validateNumber(json.version),
|
||||
validateBoolean(json.deleted),
|
||||
validateString(json.name),
|
||||
validateString(json.unit),
|
||||
validateString(json.type) as SeriesType,
|
||||
validateNumber(json.decimals),
|
||||
validateNumber(json.expectedEverySeconds),
|
||||
mapNotNull(json.last, validateDate),
|
||||
mapNotNull(json.value, validateNumber),
|
||||
);
|
||||
}
|
||||
|
||||
get digitString(): string {
|
||||
return `0.${this.decimals}-${this.decimals}`;
|
||||
}
|
||||
|
||||
get valueString(): string {
|
||||
const result = (this.value.value === null ? "-" : this.type === SeriesType.BOOL ? this.value.value > 0 ? "EIN" : "AUS" : formatNumber(this.value.value, "de-DE", this.digitString)) + "";
|
||||
if (this.unit) {
|
||||
return result + " " + this.unit;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static compareName(a: Series, b: Series) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
isOld(now: Date, toleranceSeconds: number) {
|
||||
return (now?.getTime() - (this.date?.getTime() || 0)) > (this.expectedEverySeconds + toleranceSeconds) * 1000;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import {Series} from "./Series";
|
||||
import {mapNotNull, validateBoolean, validateNumber} from "../COMMON";
|
||||
import {Value} from '../common/Value';
|
||||
|
||||
export class SeriesPoint {
|
||||
|
||||
readonly min: Value;
|
||||
|
||||
readonly avg: Value;
|
||||
|
||||
readonly max: Value;
|
||||
|
||||
readonly delta: Value;
|
||||
|
||||
constructor(
|
||||
readonly series: Series,
|
||||
min: number | null,
|
||||
avg: number | null,
|
||||
max: number | null,
|
||||
delta: number | null,
|
||||
readonly state: boolean | null,
|
||||
) {
|
||||
this.min = new Value(min, series.decimals);
|
||||
this.avg = new Value(avg, series.decimals);
|
||||
this.max = new Value(max, series.decimals);
|
||||
this.delta = new Value(delta, series.decimals);
|
||||
}
|
||||
|
||||
static fromJson(json: any): SeriesPoint {
|
||||
return new SeriesPoint(
|
||||
Series.fromJson(json),
|
||||
mapNotNull(json.min, validateNumber),
|
||||
mapNotNull(json.avg, validateNumber),
|
||||
mapNotNull(json.max, validateNumber),
|
||||
mapNotNull(json.delta, validateNumber),
|
||||
mapNotNull(json.state, validateBoolean),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
import {Series} from "./Series";
|
||||
import {Sorter} from '../common/sorter/Sorter';
|
||||
import {Column} from '../common/sorter/Column';
|
||||
import {Order} from '../common/sorter/Order';
|
||||
import {Direction} from '../common/sorter/Direction';
|
||||
import {compareDates, compareNullable, compareNumbers, compareStrings, validateList, validateString} from '../COMMON';
|
||||
|
||||
export class SeriesSorter extends Sorter<Series> {
|
||||
|
||||
static readonly nam: Column<Series> = Column.create("name", "Name", s => s.name, compareStrings);
|
||||
|
||||
static readonly icon: Column<Series> = Column.create("icon", "Icon", s => s.name, compareStrings);
|
||||
|
||||
static readonly type: Column<Series> = Column.create("delta", "Type", s => s.type, compareStrings);
|
||||
|
||||
static readonly unit: Column<Series> = Column.create("unit", "Einheit", s => s.unit, compareStrings);
|
||||
|
||||
static readonly date: Column<Series> = Column.create("first", "Zuerst", s => s.date, compareNullable(compareDates));
|
||||
|
||||
static readonly decimals: Column<Series> = Column.create("decimals", "Stellen", s => s.decimals, compareNumbers);
|
||||
|
||||
static readonly value: Column<Series> = Column.create("value", "Wert", s => s.value.value, compareNullable(compareNumbers));
|
||||
|
||||
static COLUMNS: Column<Series>[] = [this.nam, this.icon, this.type, this.unit, this.date, this.decimals, this.value];
|
||||
|
||||
private static getColumn(name: string): Column<Series> {
|
||||
return SeriesSorter.COLUMNS.filter(c => c.name === name)[0];
|
||||
}
|
||||
|
||||
static fromJson(json: any): SeriesSorter {
|
||||
return new SeriesSorter(
|
||||
validateList(json, j => new Order(
|
||||
SeriesSorter.getColumn(j.property),
|
||||
validateString(j.direction) as Direction,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
orders: Order<Series>[] = [],
|
||||
) {
|
||||
super(orders);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export enum SeriesType {
|
||||
BOOL = "BOOL",
|
||||
DELTA = "DELTA",
|
||||
VARYING = "VARYING",
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import {Series} from "../Series";
|
||||
import {validateBoolean, validateDate} from "../../COMMON";
|
||||
|
||||
export class Bool {
|
||||
|
||||
constructor(
|
||||
readonly series: Series,
|
||||
readonly date: Date,
|
||||
readonly end: Date,
|
||||
readonly state: boolean,
|
||||
readonly terminated: boolean,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): Bool {
|
||||
return new Bool(
|
||||
Series.fromJson(json.series),
|
||||
validateDate(json.date),
|
||||
validateDate(json.end),
|
||||
validateBoolean(json.state),
|
||||
validateBoolean(json.terminated),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, CrudService} from '../../COMMON';
|
||||
import {Bool} from './Bool';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BoolService extends CrudService<Bool> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ["Bool"], Bool.fromJson);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import {validateDate, validateNumber} from "../../COMMON";
|
||||
import {Meter} from './meter/Meter';
|
||||
|
||||
export class Delta {
|
||||
|
||||
constructor(
|
||||
readonly meter: Meter,
|
||||
readonly date: Date,
|
||||
readonly first: number,
|
||||
readonly last: number,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): Delta {
|
||||
return new Delta(
|
||||
Meter.fromJson(json.meter),
|
||||
validateDate(json.date),
|
||||
validateNumber(json.first),
|
||||
validateNumber(json.last),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, CrudService} from '../../COMMON';
|
||||
import {Delta} from './Delta';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DeltaService extends CrudService<Delta> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ["Delta"], Delta.fromJson);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import {Series} from "../../Series";
|
||||
import {validateDate, validateNumber, validateString} from "../../../COMMON";
|
||||
|
||||
export class Meter {
|
||||
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly series: Series,
|
||||
readonly number: string,
|
||||
readonly first: Date,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): Meter {
|
||||
return new Meter(
|
||||
validateNumber(json.id),
|
||||
Series.fromJson(json.series),
|
||||
validateString(json.number),
|
||||
validateDate(json.first),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
<div class="ChoiceList">
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.interval === null" (click)="setInterval(null)">Live</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.interval === Interval.FIVE" (click)="setInterval(Interval.FIVE)">5 Min.</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.interval === Interval.HOUR" (click)="setInterval(Interval.HOUR)">Stunde</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.interval === Interval.DAY" (click)="setInterval(Interval.DAY)">Tag</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.interval === Interval.WEEK" (click)="setInterval(Interval.WEEK)">Woche</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.interval === Interval.MONTH" (click)="setInterval(Interval.MONTH)">Monat</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.interval === Interval.YEAR" (click)="setInterval(Interval.YEAR)">Jahr</div>
|
||||
</div>
|
||||
|
||||
<div class="ChoiceList" [class.ChoiceListInactive]="config.seriesList.interval === null">
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.offset === 7" (click)="setOffset(7)">-7</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.offset === 6" (click)="setOffset(6)">-6</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.offset === 5" (click)="setOffset(5)">-5</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.offset === 4" (click)="setOffset(4)">-4</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.offset === 3" (click)="setOffset(3)">-3</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.offset === 2" (click)="setOffset(2)">-2</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.offset === 1" (click)="setOffset(1)">-1</div>
|
||||
<div class="Choice" [class.ChoiceActive]="config.seriesList.offset === 0" (click)="setOffset(0)">0</div>
|
||||
</div>
|
||||
|
||||
<input type="text" [(ngModel)]="config.seriesList.search" (ngModelChange)="saveConfig(false)" placeholder="Filter">
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<th app-column class="name" [column]="SeriesSorter.nam" [sorter]="config.seriesList.sorter" (onChange)="saveConfig(false)"></th>
|
||||
<th app-column class="value" [column]="SeriesSorter.value" [sorter]="config.seriesList.sorter" (onChange)="saveConfig(false)" colspan="3"></th>
|
||||
<th app-column class="unit" [column]="SeriesSorter.unit" [sorter]="config.seriesList.sorter" (onChange)="saveConfig(false)"></th>
|
||||
<th app-column class="type" [column]="SeriesSorter.type" [sorter]="config.seriesList.sorter" (onChange)="saveConfig(false)"></th>
|
||||
</tr>
|
||||
|
||||
@if (config.seriesList.interval === null) {
|
||||
@for (series of seriesFiltered; track series.id) {
|
||||
<tr class="Series" [class.SeriesOld]="series.isOld(now,5)">
|
||||
<td class="name">{{ series.name }}</td>
|
||||
<td class="valueInteger">{{ series.value.integer }}</td>
|
||||
@if (series.value.showDot) {
|
||||
<td class="valueDot">,</td>
|
||||
} @else {
|
||||
<td class="valueDot"></td>
|
||||
}
|
||||
<td class="valueFraction">{{ series.value.fraction }}</td>
|
||||
<td class="unit">{{ series.unit }}</td>
|
||||
@switch (series.type) {
|
||||
@case (SeriesType.VARYING) {
|
||||
<td class="type">⌀</td>
|
||||
}
|
||||
@case (SeriesType.DELTA) {
|
||||
<td class="type">Δ</td>
|
||||
}
|
||||
@case (SeriesType.BOOL) {
|
||||
<td class="type">B</td>
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
@if (config.seriesList.interval !== null) {
|
||||
@for (seriesPoint of aggregateFiltered; track seriesPoint.series.id) {
|
||||
<tr class="Series">
|
||||
<td class="name">{{ seriesPoint.series.name }}</td>
|
||||
<td class="valueInteger">{{ seriesPoint.avg.integer }}</td>
|
||||
@if (seriesPoint.avg.showDot) {
|
||||
<td class="valueDot">,</td>
|
||||
} @else {
|
||||
<td class="valueDot"></td>
|
||||
}
|
||||
<td class="valueFraction">{{ seriesPoint.avg.fraction }}</td>
|
||||
<td class="unit">{{ seriesPoint.series.unit }}</td>
|
||||
<td class="type">Δ</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
</table>
|
||||
|
||||
@if (config.seriesList.interval === null) {
|
||||
<div class="hint Counts">{{ seriesFiltered.length }} / {{ seriesUnfiltered.length }}</div>
|
||||
} @else {
|
||||
<div class="hint Counts">{{ aggregateFiltered.length }} / {{ aggregateUnfiltered.length }}</div>
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
@import "../../../config";
|
||||
|
||||
.ChoiceList {
|
||||
display: flex;
|
||||
user-select: none;
|
||||
border-left: @border solid gray;
|
||||
border-right: @border solid gray;
|
||||
border-bottom: @border solid gray;
|
||||
|
||||
.Choice {
|
||||
flex: 1;
|
||||
padding: @space;
|
||||
border-left: @border solid gray;
|
||||
border-left: @border solid gray;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.Choice:first-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ChoiceActive {
|
||||
background-color: lightskyblue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.ChoiceListInactive {
|
||||
opacity: 20%;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
|
||||
th {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
td, th {
|
||||
text-align: center;
|
||||
width: 0;
|
||||
padding: @space calc(2 * @space);
|
||||
}
|
||||
|
||||
tr.Series:nth-child(even) {
|
||||
background-color: #fff6e9;
|
||||
}
|
||||
|
||||
tr.Series:nth-child(odd) {
|
||||
background-color: #fffce9;
|
||||
}
|
||||
|
||||
.name {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.valueInteger {
|
||||
padding-right: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.valueDot {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.valueFraction {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.unit {
|
||||
padding-left: @space;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.SeriesOld {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.Counts {
|
||||
text-align: right;
|
||||
}
|
||||
@ -1,155 +0,0 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Config} from '../../config/Config';
|
||||
import {Subscription, timer} from 'rxjs';
|
||||
import {ConfigService} from '../../config/config.service';
|
||||
import {SeriesService} from '../series.service';
|
||||
import {Series} from '../Series';
|
||||
import {ColumnComponent} from '../../common/column/column.component';
|
||||
|
||||
import {SeriesSorter} from '../SeriesSorter';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Interval} from '../Interval';
|
||||
import {SeriesType} from '../SeriesType';
|
||||
import {AllSeriesPointResponse, AllSeriesPointResponseEntry} from '../AllSeriesPointResponse';
|
||||
import {Varying} from '../varying/Varying';
|
||||
|
||||
@Component({
|
||||
selector: 'app-series-list',
|
||||
imports: [
|
||||
ColumnComponent,
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './series-list.component.html',
|
||||
styleUrl: './series-list.component.less'
|
||||
})
|
||||
export class SeriesListComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected readonly Series = Series;
|
||||
|
||||
protected readonly Interval = Interval;
|
||||
|
||||
protected readonly SeriesType = SeriesType;
|
||||
|
||||
protected readonly SeriesSorter = SeriesSorter;
|
||||
|
||||
protected now: Date = new Date();
|
||||
|
||||
protected seriesUnfiltered: Series[] = [];
|
||||
|
||||
protected seriesFiltered: Series[] = [];
|
||||
|
||||
protected aggregateResponse: AllSeriesPointResponse | null = null;
|
||||
|
||||
protected aggregateUnfiltered: AllSeriesPointResponseEntry[] = [];
|
||||
|
||||
protected aggregateFiltered: AllSeriesPointResponseEntry[] = [];
|
||||
|
||||
protected config: Config = new Config();
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
private saveConfigTimeout: any = undefined;
|
||||
|
||||
constructor(
|
||||
readonly configService: ConfigService,
|
||||
readonly seriesService: SeriesService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.configService.get(config => {
|
||||
this.config = config;
|
||||
this.fetchList();
|
||||
this.subs.push(this.seriesService.subscribe(this.updateSeries));
|
||||
this.subs.push(timer(1000, 1000).subscribe(() => this.now = new Date()));
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
private readonly updateSeries = (series: Series) => {
|
||||
const index = this.seriesUnfiltered.findIndex(t => t.id === series.id);
|
||||
if (index >= 0) {
|
||||
this.seriesUnfiltered.splice(index, 1, series);
|
||||
} else {
|
||||
this.seriesUnfiltered.push(series);
|
||||
}
|
||||
this.applySeriesFilter();
|
||||
};
|
||||
|
||||
saveConfig(fetch: boolean) {
|
||||
if (this.saveConfigTimeout) {
|
||||
clearTimeout(this.saveConfigTimeout);
|
||||
this.saveConfigTimeout = undefined;
|
||||
}
|
||||
if (fetch) {
|
||||
this.fetchList();
|
||||
} else {
|
||||
this.applySeriesFilter();
|
||||
this.applyAggregateFilter();
|
||||
}
|
||||
this.saveConfigTimeout = setTimeout(() => this.configService.set(this.config, config => this.config = config), 700);
|
||||
}
|
||||
|
||||
private readonly filter = (series: Series) => {
|
||||
const words = this.config.seriesList.search
|
||||
.replaceAll(/([0-9])([a-zA-Z])/g, '$1 $2')
|
||||
.replaceAll(/([a-z])([A-Z0-9])/g, '$1 $2')
|
||||
.replaceAll(/([A-Z])([0-9])/g, '$1 $2')
|
||||
.toLocaleLowerCase()
|
||||
.trim()
|
||||
.split(/\W+/);
|
||||
return words.every(word =>
|
||||
series.name.toLocaleLowerCase().includes(word)
|
||||
|| series.unit.toLocaleLowerCase().includes(word)
|
||||
|| series.value.integer.includes(word)
|
||||
|| series.name.toLocaleLowerCase().includes(word)
|
||||
|| (series.type === SeriesType.DELTA && "delta difference".includes(word))
|
||||
|| (series.type === SeriesType.VARYING && "average avg minimum maximum".includes(word))
|
||||
|| (series.type === SeriesType.BOOL && "boolean".includes(word))
|
||||
);
|
||||
}
|
||||
|
||||
protected setInterval(interval: Interval | null): void {
|
||||
if (this.config.seriesList.interval !== interval) {
|
||||
this.config.seriesList.interval = interval;
|
||||
this.saveConfig(true);
|
||||
}
|
||||
}
|
||||
|
||||
protected setOffset(offset: number): void {
|
||||
if (this.config.seriesList.offset !== offset) {
|
||||
this.config.seriesList.offset = offset;
|
||||
this.saveConfig(true);
|
||||
}
|
||||
}
|
||||
|
||||
private fetchList() {
|
||||
if (this.config.seriesList.interval === null) {
|
||||
this.seriesService.findAll(list => {
|
||||
this.seriesUnfiltered = list;
|
||||
this.applySeriesFilter();
|
||||
});
|
||||
} else {
|
||||
this.seriesService.allSeriesPoint(this.config.seriesList.interval, this.config.seriesList.offset, response => {
|
||||
this.aggregateResponse = response;
|
||||
this.aggregateUnfiltered = response.seriesPoints;
|
||||
this.applyAggregateFilter();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applySeriesFilter() {
|
||||
this.seriesFiltered = this.seriesUnfiltered.filter(this.filter).sort(this.config.seriesList.sorter.compare);
|
||||
}
|
||||
|
||||
private applyAggregateFilter() {
|
||||
this.aggregateUnfiltered = this.aggregateResponse?.seriesPoints || [];
|
||||
this.aggregateFiltered = this.aggregateUnfiltered.filter(a => this.filter(a.series)).sort((a, b) => this.config.seriesList.sorter.compare(a.series, b.series));
|
||||
}
|
||||
|
||||
protected readonly Varying = Varying;
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, EntityListService, Next, validateNumber} from "../COMMON";
|
||||
import {Series} from './Series';
|
||||
import {Interval} from './Interval';
|
||||
import {AllSeriesPointResponse} from './AllSeriesPointResponse';
|
||||
import {AllSeriesPointRequest} from './AllSeriesPointRequest';
|
||||
import {Graph} from "../plot/axis/graph/Graph";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SeriesService extends EntityListService<Series> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ['Series'], Series.fromJson, Series.equals, Series.compareName);
|
||||
}
|
||||
|
||||
oneSeriesPoints(graph: Graph, next: Next<number[][]>): void {
|
||||
const request = {
|
||||
id: graph.series.id,
|
||||
factor: graph.factor,
|
||||
operation: graph.operation,
|
||||
id2: graph.series2?.id,
|
||||
factor2: graph.factor2,
|
||||
interval: graph.axis.plot.interval.name,
|
||||
offset: graph.axis.plot.offset,
|
||||
duration: graph.axis.plot.duration,
|
||||
};
|
||||
this.api.postList([...this.path, 'oneSeriesPoints'], request, (outer: any[]) => outer.map(validateNumber), next);
|
||||
}
|
||||
|
||||
allSeriesPoint(interval: Interval, offset: number, next: (response: AllSeriesPointResponse) => void) {
|
||||
const request = new AllSeriesPointRequest(interval, offset);
|
||||
this.api.postSingle([...this.path, 'allSeriesPoint'], request.toJson(), AllSeriesPointResponse.fromJson, next);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import {Series} from "../Series";
|
||||
import {validateDate, validateNumber} from "../../COMMON";
|
||||
|
||||
export class Varying {
|
||||
|
||||
constructor(
|
||||
readonly series: Series,
|
||||
readonly date: Date,
|
||||
readonly min: number,
|
||||
readonly max: number,
|
||||
readonly avg: number,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): Varying {
|
||||
return new Varying(
|
||||
Series.fromJson(json.series),
|
||||
validateDate(json.date),
|
||||
validateNumber(json.min),
|
||||
validateNumber(json.max),
|
||||
validateNumber(json.avg),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, CrudService} from '../../COMMON';
|
||||
import {Varying} from './Varying';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class VaryingService extends CrudService<Varying> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ["Varying"], Varying.fromJson);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export enum TimestampType {
|
||||
EPOCH_MILLISECONDS = "EPOCH_MILLISECONDS",
|
||||
EPOCH_SECONDS = "EPOCH_SECONDS",
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import {TopicQuery} from "./TopicQuery";
|
||||
|
||||
import {TimestampType} from './TimestampType';
|
||||
import {mapNotNull, validateBoolean, validateDate, validateList, validateNumber, validateString} from '../COMMON';
|
||||
|
||||
export class Topic {
|
||||
|
||||
readonly used: boolean;
|
||||
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly name: string,
|
||||
readonly first: Date,
|
||||
readonly last: Date,
|
||||
readonly count: number,
|
||||
readonly enabled: boolean,
|
||||
readonly timestampType: TimestampType,
|
||||
readonly timestampQuery: string,
|
||||
readonly timestampLast: Date | null,
|
||||
readonly meterNumberQuery: string,
|
||||
readonly meterNumberLast: string,
|
||||
readonly queries: TopicQuery[],
|
||||
readonly payload: string,
|
||||
readonly error: string,
|
||||
) {
|
||||
this.used = enabled && timestampQuery.length > 0 && queries.some(q => q.series && q.valueQuery);
|
||||
}
|
||||
|
||||
static fromJson(json: any): Topic {
|
||||
return new Topic(
|
||||
validateNumber(json.id),
|
||||
validateString(json.name),
|
||||
validateDate(json.first),
|
||||
validateDate(json.last),
|
||||
validateNumber(json.count),
|
||||
validateBoolean(json.enabled),
|
||||
validateString(json.timestampType) as TimestampType,
|
||||
validateString(json.timestampQuery),
|
||||
mapNotNull(json.timestampLast, validateDate),
|
||||
validateString(json.meterNumberQuery),
|
||||
validateString(json.meterNumberLast),
|
||||
validateList(json.queries, TopicQuery.fromJson),
|
||||
validateString(json.payload),
|
||||
validateString(json.error),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import {TopicQueryFunction} from './TopicQueryFunction';
|
||||
import {Series} from '../series/Series';
|
||||
import {validateString} from '../COMMON';
|
||||
|
||||
export class TopicQuery {
|
||||
// series
|
||||
// valueQuery
|
||||
// beginQuery
|
||||
// terminatedQuery
|
||||
// function
|
||||
// factor
|
||||
constructor(
|
||||
readonly series: Series,
|
||||
readonly valueQuery: string,
|
||||
readonly func: TopicQueryFunction,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): TopicQuery {
|
||||
return new TopicQuery(
|
||||
Series.fromJson(json.series),
|
||||
validateString(json.valueQuery),
|
||||
validateString(json.function) as TopicQueryFunction,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export enum TopicQueryFunction {
|
||||
NONE = "NONE",
|
||||
ONLY_POSITIVE = "ONLY_POSITIVE",
|
||||
ONLY_NEGATIVE_BUT_NEGATE = "ONLY_NEGATIVE_BUT_NEGATE",
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
<div class="options">
|
||||
<label><input type="checkbox" [(ngModel)]="config.topicList.unused">Ungenutzte</label>
|
||||
<label><input type="checkbox" [(ngModel)]="config.topicList.used">Genutzte</label>
|
||||
</div>
|
||||
|
||||
<table class="TopicList">
|
||||
@for (topic of sorted(); track topic.id) {
|
||||
<tr class="head">
|
||||
<td class="name">{{ topic.name }}</td>
|
||||
<td class="last">{{ topic.last | date:'long':'':'de-DE' }}</td>
|
||||
<td class="payload" [class.empty]="topic.payload === null" colspan="6">{{ topic.payload }}</td>
|
||||
</tr>
|
||||
<tr class="timestamp">
|
||||
<td class="timestampQuery" [class.empty]="!topic.timestampQuery">{{ topic.timestampQuery }}</td>
|
||||
<td class="timestampType" [class.empty]="topic.timestampType === null">{{ topic.timestampType }}</td>
|
||||
<td class="timestampLast" [class.empty]="topic.timestampLast === null" colspan="6">{{ topic.timestampLast | date:'long':'':'de-DE' }}</td>
|
||||
</tr>
|
||||
@if (topic.queries[0]?.series?.type === SeriesType.DELTA) {
|
||||
<tr class="meter">
|
||||
<td class="meterNumberQuery" [class.empty]="!topic.meterNumberQuery">{{ topic.meterNumberQuery }}</td>
|
||||
<td class="meterNumberLast" [class.empty]="topic.meterNumberLast === null"></td>
|
||||
<td class="meterNumberLast" [class.empty]="topic.meterNumberLast === null" colspan="5">{{ topic.meterNumberLast }}</td>
|
||||
<td class="meterNumberLast" [class.empty]="topic.timestampLast === null">{{ topic.timestampLast | date:'long':'':'de-DE' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@for (query of topic.queries; track $index) {
|
||||
<tr class="query">
|
||||
<td class="valueQuery" [class.empty]="query.valueQuery === null">{{ query.valueQuery }}</td>
|
||||
<td class="valueFunction" [class.empty]="query.func === null">{{ query.func }}</td>
|
||||
<td class="valueSeries" [class.empty]="query.series.name === null">{{ query.series.name }}</td>
|
||||
<td class="valueSeriesValueInteger" [class.empty]="query.series.value.value === null">{{ query.series.value.integer }}</td>
|
||||
@if (query.series.value.showDot) {
|
||||
<td class="valueSeriesValueDot" [class.empty]="query.series.value.value === null">,</td>
|
||||
} @else {
|
||||
<td class="valueSeriesValueDot" [class.empty]="query.series.value.value === null"></td>
|
||||
}
|
||||
<td class="valueSeriesValueFraction" [class.empty]="query.series.value.value === null">{{ query.series.value.fraction }}</td>
|
||||
<td class="valueSeriesValueUnit" [class.empty]="query.series.value.value === null">{{ query.series.unit }}</td>
|
||||
<td class="valueSeriesDate" [class.empty]="query.series.date === null">{{ query.series.date | date:'long':'':'de-DE' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="spacer">
|
||||
<td colspan="8"> </td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
@ -1,112 +0,0 @@
|
||||
@import "../../../config";
|
||||
|
||||
table.TopicList {
|
||||
|
||||
tr {
|
||||
th, td {
|
||||
border: 1px solid gray;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
tr.head {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
tr.query {
|
||||
background-color: lightskyblue;
|
||||
}
|
||||
|
||||
tr.timestamp {
|
||||
background-color: darkseagreen;
|
||||
}
|
||||
|
||||
tr.meter {
|
||||
background-color: #ffe7bd;
|
||||
}
|
||||
|
||||
tr.spacer {
|
||||
th, td {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
background-color: #ffd3d3;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.last {
|
||||
|
||||
}
|
||||
|
||||
.payload {
|
||||
white-space: unset !important;
|
||||
}
|
||||
|
||||
.timestampType {
|
||||
|
||||
}
|
||||
|
||||
.timestampQuery {
|
||||
|
||||
}
|
||||
|
||||
.timestampLast {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.meterNumberLast {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.valueQuery {
|
||||
|
||||
}
|
||||
|
||||
.valueFunction {
|
||||
|
||||
}
|
||||
|
||||
.valueSeries {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.valueSeriesValueInteger {
|
||||
border-right: none !important;
|
||||
padding-right: 0;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.valueSeriesValueDot {
|
||||
border-right: none !important;
|
||||
padding-right: 0;
|
||||
border-left: none !important;
|
||||
padding-left: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.valueSeriesValueFraction {
|
||||
border-right: none !important;
|
||||
padding-right: 0;
|
||||
border-left: none !important;
|
||||
padding-left: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.valueSeriesValueUnit {
|
||||
border-left: none !important;
|
||||
text-align: left;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.valueSeriesDate {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {DatePipe} from '@angular/common';
|
||||
import {Topic} from '../Topic';
|
||||
import {TopicService} from '../topic.service';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Subscription, timer} from 'rxjs';
|
||||
import {TimestampType} from '../TimestampType';
|
||||
import {TopicQueryFunction} from '../TopicQueryFunction';
|
||||
import {Series} from '../../series/Series';
|
||||
import {SeriesService} from '../../series/series.service';
|
||||
import {Config} from '../../config/Config';
|
||||
import {ageString} from '../../COMMON';
|
||||
import {ConfigService} from '../../config/config.service';
|
||||
import {SeriesType} from '../../series/SeriesType';
|
||||
|
||||
@Component({
|
||||
selector: 'app-topic-list',
|
||||
imports: [
|
||||
FormsModule,
|
||||
DatePipe
|
||||
],
|
||||
templateUrl: './topic-list.component.html',
|
||||
styleUrl: './topic-list.component.less'
|
||||
})
|
||||
export class TopicListComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected readonly ageString = ageString;
|
||||
|
||||
protected readonly SeriesType = SeriesType;
|
||||
|
||||
protected now: Date = new Date();
|
||||
|
||||
protected topicList: Topic[] = [];
|
||||
|
||||
protected seriesList: Series[] = [];
|
||||
|
||||
protected config: Config = new Config();
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
readonly configService: ConfigService,
|
||||
readonly seriesService: SeriesService,
|
||||
readonly topicService: TopicService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.configService.get(config => this.config = config);
|
||||
this.seriesService.findAll(list => this.seriesList = list.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
this.topicService.findAll(list => this.topicList = list);
|
||||
this.subs.push(this.topicService.subscribe(this.update));
|
||||
this.subs.push(timer(1000, 1000).subscribe(() => this.now = new Date()));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
private readonly update = (topic: Topic) => {
|
||||
const index = this.topicList.findIndex(t => t.id === topic.id);
|
||||
if (index >= 0) {
|
||||
this.topicList.splice(index, 1, topic);
|
||||
} else {
|
||||
this.topicList.push(topic);
|
||||
}
|
||||
};
|
||||
|
||||
sorted() {
|
||||
return this.topicList.filter(this.filter).sort(this.compare);
|
||||
}
|
||||
|
||||
private readonly filter = (topic: Topic) => {
|
||||
return ((!topic.used && this.config.topicList.unused) || (topic.used && this.config.topicList.used));
|
||||
}
|
||||
|
||||
private readonly compare = (a: Topic, b: Topic) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
TimestampTypes() {
|
||||
return Object.values(TimestampType);
|
||||
}
|
||||
|
||||
TopicQueryFunctions() {
|
||||
return Object.values(TopicQueryFunction);
|
||||
}
|
||||
|
||||
configSet() {
|
||||
this.configService.set(this.config, config => this.config = config);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Topic} from './Topic';
|
||||
import {ApiService, CrudService} from '../COMMON';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TopicService extends CrudService<Topic> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ["Topic"], Topic.fromJson);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import {validateDate, validateNumber} from '../COMMON';
|
||||
|
||||
export class WeatherHour {
|
||||
|
||||
constructor(
|
||||
readonly date: Date,
|
||||
readonly clouds: number,
|
||||
readonly irradiation: number,
|
||||
readonly precipitation: number,
|
||||
readonly temperature: number,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any): WeatherHour {
|
||||
return new WeatherHour(
|
||||
validateDate(json.date),
|
||||
validateNumber(json.clouds),
|
||||
validateNumber(json.irradiation),
|
||||
validateNumber(json.precipitation),
|
||||
validateNumber(json.temperature),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
<div #container class="container">
|
||||
<canvas #chartCanvas></canvas>
|
||||
</div>
|
||||
@ -1,4 +0,0 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@ -1,204 +0,0 @@
|
||||
import {AfterViewInit, Component, ElementRef, ViewChild} from '@angular/core';
|
||||
import {WeatherService} from '../weather-service';
|
||||
import {WeatherHour} from '../WeatherHour';
|
||||
import {Chart} from 'chart.js';
|
||||
|
||||
import {de} from 'date-fns/locale';
|
||||
import {format} from 'date-fns';
|
||||
|
||||
export function toPoint(f: (hour: WeatherHour) => number): (hour: WeatherHour) => { x: number, y: number } {
|
||||
return (hour: WeatherHour) => {
|
||||
return {
|
||||
x: hour.date.getTime(),
|
||||
y: f(hour),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const TEMPERATURE_HIGH_COLOR = '#FF0000';
|
||||
const TEMPERATURE_LOW_COLOR = '#0000FF';
|
||||
const TEMPERATURE_LOW_THRESHOLD = 0;
|
||||
const PRECIPITATION_COLOR = '#0000FF';
|
||||
const CLOUDS_COLOR = '#cccccc';
|
||||
const SUN_COLOR = '#ffc400';
|
||||
|
||||
@Component({
|
||||
selector: 'app-weather-component',
|
||||
imports: [],
|
||||
templateUrl: './weather-component.html',
|
||||
styleUrl: './weather-component.less'
|
||||
})
|
||||
export class WeatherComponent implements AfterViewInit {
|
||||
|
||||
@ViewChild('chartCanvas')
|
||||
protected canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
@ViewChild('container')
|
||||
protected chartContainer!: ElementRef<HTMLDivElement>;
|
||||
|
||||
private chart!: Chart;
|
||||
|
||||
constructor(
|
||||
readonly weatherService: WeatherService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
},
|
||||
tooltip: {
|
||||
position: 'nearest',
|
||||
mode: 'index',
|
||||
usePointStyle: true,
|
||||
callbacks: {
|
||||
title: function (items) {
|
||||
const date = items[0].parsed.x as unknown as Date;
|
||||
return format(date, 'EE dd.MM. HH:mm', {locale: de});
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "Wetter",
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: "day",
|
||||
displayFormats: {
|
||||
day: "EE dd.MM"
|
||||
}
|
||||
},
|
||||
adapters: {
|
||||
date: {
|
||||
locale: de,
|
||||
}
|
||||
},
|
||||
},
|
||||
y_temperature: {
|
||||
position: "right",
|
||||
title: {
|
||||
display: true,
|
||||
text: "Temperatur [°C]",
|
||||
color: TEMPERATURE_HIGH_COLOR,
|
||||
},
|
||||
ticks: {
|
||||
color: TEMPERATURE_HIGH_COLOR,
|
||||
},
|
||||
min: 0,
|
||||
max: 35,
|
||||
},
|
||||
y_precipitation: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "Niederschlag [mm]",
|
||||
color: PRECIPITATION_COLOR,
|
||||
},
|
||||
ticks: {
|
||||
color: PRECIPITATION_COLOR,
|
||||
},
|
||||
min: 0,
|
||||
max: 15,
|
||||
},
|
||||
y_sun: {
|
||||
position: "right",
|
||||
title: {
|
||||
display: true,
|
||||
text: "Sonne [kWh/m²]",
|
||||
color: SUN_COLOR,
|
||||
},
|
||||
ticks: {
|
||||
color: SUN_COLOR,
|
||||
},
|
||||
min: 0,
|
||||
max: 1000,
|
||||
},
|
||||
y_clouds: {
|
||||
display: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: "Wolkenbedeckung [%]",
|
||||
color: CLOUDS_COLOR,
|
||||
},
|
||||
ticks: {
|
||||
color: CLOUDS_COLOR,
|
||||
},
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
this.weatherService.all(hours => {
|
||||
const now = Date.now();
|
||||
const filtered = hours.filter(h => h.date.getTime() >= now);
|
||||
this.chart.data.datasets.push({
|
||||
label: "Niederschlag",
|
||||
type: "bar",
|
||||
yAxisID: "y_precipitation",
|
||||
data: filtered.map(toPoint(h => h.precipitation)),
|
||||
borderColor: PRECIPITATION_COLOR,
|
||||
backgroundColor: PRECIPITATION_COLOR + '66',
|
||||
borderWidth: 0,
|
||||
pointStyle: "rect",
|
||||
});
|
||||
this.chart.data.datasets.push({
|
||||
label: "Sonne",
|
||||
type: "line",
|
||||
fill: "origin",
|
||||
yAxisID: "y_sun",
|
||||
data: filtered.map(toPoint(h => h.irradiation)),
|
||||
borderColor: SUN_COLOR,
|
||||
backgroundColor: SUN_COLOR + '88',
|
||||
borderWidth: 0,
|
||||
pointRadius: 0,
|
||||
});
|
||||
this.chart.data.datasets.push({
|
||||
label: "Temperatur",
|
||||
type: "line",
|
||||
yAxisID: "y_temperature",
|
||||
data: filtered.map(toPoint(h => h.temperature)),
|
||||
borderColor: function (context) {
|
||||
const value = context.dataset.data[context.dataIndex];
|
||||
return value !== null && typeof value === 'object' && value.hasOwnProperty("y") && value.y >= TEMPERATURE_LOW_THRESHOLD ? TEMPERATURE_HIGH_COLOR : TEMPERATURE_LOW_COLOR;
|
||||
},
|
||||
backgroundColor: TEMPERATURE_HIGH_COLOR + '66',
|
||||
fill: {
|
||||
target: {value: TEMPERATURE_LOW_THRESHOLD},
|
||||
above: TEMPERATURE_HIGH_COLOR + '66',
|
||||
below: TEMPERATURE_LOW_COLOR + '66',
|
||||
},
|
||||
borderWidth: 0,
|
||||
pointRadius: 5,
|
||||
pointStyle: "cross",
|
||||
});
|
||||
this.chart.data.datasets.push({
|
||||
label: "Wolken",
|
||||
type: "line",
|
||||
fill: "origin",
|
||||
yAxisID: "y_clouds",
|
||||
data: filtered.map(toPoint(h => h.clouds)),
|
||||
borderColor: CLOUDS_COLOR,
|
||||
backgroundColor: CLOUDS_COLOR + '88',
|
||||
borderWidth: 0,
|
||||
pointRadius: 0,
|
||||
});
|
||||
this.chart.update();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, CrudService, Next} from '../COMMON';
|
||||
import {WeatherHour} from './WeatherHour';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class WeatherService extends CrudService<WeatherHour> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ['Weather'], WeatherHour.fromJson);
|
||||
}
|
||||
|
||||
all(next: Next<WeatherHour[]>) {
|
||||
this.getList(['all'], next);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
@space: 0.25em;
|
||||
@border: 0.01em;
|
||||
@ -1,11 +1,11 @@
|
||||
<!doctype html>
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Angular</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -1,51 +1 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
td, th {
|
||||
padding: 0.25em;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
input:not([type=checkbox]), select {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid black;
|
||||
padding: 0.125em;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0;
|
||||
border: 1px solid gray;
|
||||
border-radius: 0.25em;
|
||||
box-shadow: 0 0 0.25em black;
|
||||
}
|
||||
|
||||
.buttonAdd {
|
||||
background-color: lightgreen;
|
||||
}
|
||||
|
||||
.buttonCopy {
|
||||
background-color: lightskyblue;
|
||||
}
|
||||
|
||||
.buttonRemove {
|
||||
background-color: indianred;
|
||||
}
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user