location WIP

This commit is contained in:
Patrick Haßel 2025-10-28 10:17:30 +01:00
parent 37beb05ca7
commit 479b5ff76e
91 changed files with 903 additions and 4490 deletions

View File

@ -9,7 +9,6 @@ insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
# noinspection EditorConfigKeyCorrectness
quote_type = single
ij_typescript_use_double_quotes = false

View File

@ -36,6 +36,7 @@ yarn-error.log
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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`;
}

View File

@ -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'},
]
};

View File

@ -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) {
&lt;
} @else {
&gt;
}
</div>
</div>
<div class="bodyContent">
<router-outlet/>
</div>

View File

@ -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;
}

View File

@ -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'}
];

View File

@ -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;
}

View 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);
}

View File

@ -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;
}
}

View File

@ -1 +0,0 @@
<input type="checkbox" [(ngModel)]="model" (change)="apply()">

View File

@ -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);
}
}
}

View File

@ -1,12 +0,0 @@
<div (click)="click()">
{{ title === null ? column.title : title }}
@if (sorter.isAscending(column)) {
<span class="sub">&uarr;</span>
}
@if (sorter.isDescending(column)) {
<span class="sub">&darr;</span>
}
@if (sorter.showCount(column)) {
<span class="sub">{{ sorter.indexOf(column) + 1 }}</span>
}
</div>

View File

@ -1,3 +0,0 @@
.sub {
font-size: 50%;
}

View File

@ -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();
}
}

View File

@ -1 +0,0 @@
<input type="number" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (change)="!edit && apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">

View File

@ -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 + "";
}
}

View File

@ -1 +0,0 @@
<input type="number" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (change)="!edit && apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">

View File

@ -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 + "";
}
}

View File

@ -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)),
);
}
}

View File

@ -1,4 +0,0 @@
export enum Direction {
ASC = "ASC",
DESC = "DESC",
}

View File

@ -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,
};
}
}

View File

@ -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);
}
}

View File

@ -1 +0,0 @@
<input type="text" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">

View File

@ -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;
}
}

View File

@ -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),
);
}
}

View File

@ -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),
);
}
}

View File

@ -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),
);
}
}

View File

@ -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);
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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);
}
}

View 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),
);
}
}

View File

@ -0,0 +1,7 @@
<div class="LocationList">
@for (location of list; track location.id) {
<div class="Location">
{{ location.name }}
</div>
}
</div>

View 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);
}
}

View 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);
}
}

View File

@ -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",
}

View File

@ -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;
}
}

View File

@ -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,
);
}
}

View File

@ -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),
);
}
}

View File

@ -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;
}
}

View File

@ -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>&nbsp;</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>
&nbsp;
<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>&empty;</th>
<th class="vertical">max</th>
<th>Achse</th>
<th>&nbsp;</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>

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -1,3 +0,0 @@
<div #container class="container">
<canvas #chartCanvas></canvas>
</div>

View File

@ -1,4 +0,0 @@
.container {
width: 100%;
height: 100%;
}

View File

@ -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();
}
}
}

View File

@ -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),
);
}
}

View File

@ -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),
);
}
}

View File

@ -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;
}
}

View File

@ -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",
}

View File

@ -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;
}
}

View File

@ -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),
)
}
}

View File

@ -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);
}
}

View File

@ -1,5 +0,0 @@
export enum SeriesType {
BOOL = "BOOL",
DELTA = "DELTA",
VARYING = "VARYING",
}

View File

@ -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),
);
}
}

View File

@ -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);
}
}

View File

@ -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),
);
}
}

View File

@ -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);
}
}

View File

@ -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),
);
}
}

View File

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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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),
);
}
}

View File

@ -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);
}
}

View File

@ -1,4 +0,0 @@
export enum TimestampType {
EPOCH_MILLISECONDS = "EPOCH_MILLISECONDS",
EPOCH_SECONDS = "EPOCH_SECONDS",
}

View File

@ -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),
);
}
}

View File

@ -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,
);
}
}

View File

@ -1,5 +0,0 @@
export enum TopicQueryFunction {
NONE = "NONE",
ONLY_POSITIVE = "ONLY_POSITIVE",
ONLY_NEGATIVE_BUT_NEGATE = "ONLY_NEGATIVE_BUT_NEGATE",
}

View File

@ -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">&nbsp;</td>
</tr>
}
</table>

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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),
);
}
}

View File

@ -1,3 +0,0 @@
<div #container class="container">
<canvas #chartCanvas></canvas>
</div>

View File

@ -1,4 +0,0 @@
.container {
width: 100%;
height: 100%;
}

View File

@ -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();
});
}
}

View File

@ -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);
}
}

View File

@ -1,2 +0,0 @@
@space: 0.25em;
@border: 0.01em;

View File

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

View File

@ -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 */