extracted Plot into own component + Dashboard + Graph.type + Graph.fill
This commit is contained in:
parent
53fad7846b
commit
6172e888bf
@ -1,16 +1,19 @@
|
||||
import {StompService} from "@stomp/ng2-stompjs";
|
||||
import {filter, map, Subscription} from "rxjs";
|
||||
import {filter, map, Subject, Subscription} from "rxjs";
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {RxStompState} from '@stomp/rx-stomp';
|
||||
|
||||
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']),
|
||||
@ -194,6 +197,84 @@ export abstract class CrudService<T> {
|
||||
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -1 +1,17 @@
|
||||
<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>
|
||||
}
|
||||
<div class="handle" (click)="sidebar = !sidebar">
|
||||
@if (sidebar) {
|
||||
<
|
||||
} @else {
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-outlet/>
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
@sidebarColor: #62b0ca;
|
||||
|
||||
.sidebar {
|
||||
margin-left: -2em;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
import {Routes} from '@angular/router';
|
||||
import {PlotComponent} from './plot/plot.component';
|
||||
import {PlotEditor} from './plot/editor/plot-editor.component';
|
||||
import {DashboardComponent} from './dashboard/dashboard.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: 'plot', component: PlotComponent},
|
||||
{path: 'plot/:id', component: PlotComponent},
|
||||
{path: '**', redirectTo: 'plot'},
|
||||
{path: 'Dashboard', component: DashboardComponent},
|
||||
{path: 'PlotEditor', component: PlotEditor},
|
||||
{path: 'PlotEditor/:id', component: PlotEditor},
|
||||
{path: '**', redirectTo: 'Dashboard'},
|
||||
];
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import {Component, signal} from '@angular/core';
|
||||
import {RouterOutlet} from '@angular/router';
|
||||
import {Component} from '@angular/core';
|
||||
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.less'
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal('angular');
|
||||
|
||||
protected sidebar: boolean = false;
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
@for (plot of plots; track plot.id) {
|
||||
<div class="plot">
|
||||
<app-plot [plot]="plot"></app-plot>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
.plot {
|
||||
height: 60vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
26
src/main/angular/src/app/dashboard/dashboard.component.ts
Normal file
26
src/main/angular/src/app/dashboard/dashboard.component.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {PlotService} from '../plot/plot.service';
|
||||
import {Plot} from '../plot/Plot';
|
||||
import {PlotComponent} from '../plot/plot/plot.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
imports: [
|
||||
PlotComponent
|
||||
],
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import {validateBoolean, validateListIndexed, validateNumber, validateString} from '../COMMON';
|
||||
import {ID, validateBoolean, validateListIndexed, validateNumber, validateString} from '../COMMON';
|
||||
import {Interval} from '../series/Interval';
|
||||
import {Axis} from './Axis';
|
||||
import {Axis} from './axis/Axis';
|
||||
|
||||
export class Plot {
|
||||
export class Plot extends ID<Plot> {
|
||||
|
||||
readonly axes: Axis[];
|
||||
|
||||
@ -18,6 +18,7 @@ export class Plot {
|
||||
readonly position: number,
|
||||
axes: any[],
|
||||
) {
|
||||
super();
|
||||
this.axes = validateListIndexed(axes, ((json, index) => Axis.fromJson(this, index, json)));
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {mapNotNull, validateBoolean, validateList, validateNumber, validateString} from "../COMMON";
|
||||
import {Graph} from './Graph';
|
||||
import {Plot} from './Plot';
|
||||
import {mapNotNull, validateBoolean, validateList, validateNumber, validateString} from "../../COMMON";
|
||||
import {Graph} from './graph/Graph';
|
||||
import {Plot} from '../Plot';
|
||||
|
||||
export class Axis {
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import {Series} from "../series/Series";
|
||||
import {Group} from "./Group";
|
||||
import {validateBoolean, validateNumber, validateString} from "../COMMON";
|
||||
import {Axis} from './Axis';
|
||||
import {SeriesType} from '../series/SeriesType';
|
||||
import {Series} from "../../../series/Series";
|
||||
import {Group} from "../../Group";
|
||||
import {validateBoolean, validateNumber, validateString} from "../../../COMMON";
|
||||
import {Axis} from '../Axis';
|
||||
import {SeriesType} from '../../../series/SeriesType';
|
||||
import {GraphType} from './GraphType';
|
||||
|
||||
export class Graph {
|
||||
|
||||
readonly type: string = "line";
|
||||
readonly showMin: boolean;
|
||||
|
||||
readonly mid: boolean;
|
||||
readonly showMid: boolean;
|
||||
|
||||
readonly showMax: boolean;
|
||||
|
||||
constructor(
|
||||
readonly axis: Axis,
|
||||
@ -17,6 +20,8 @@ export class Graph {
|
||||
readonly series: Series,
|
||||
readonly name: string,
|
||||
readonly visible: boolean,
|
||||
readonly type: GraphType,
|
||||
readonly fill: string,
|
||||
readonly color: string,
|
||||
readonly factor: number,
|
||||
readonly group: Group,
|
||||
@ -25,7 +30,9 @@ export class Graph {
|
||||
readonly max: boolean,
|
||||
readonly avg: boolean,
|
||||
) {
|
||||
this.mid = this.avg || this.series.type === SeriesType.BOOL || this.series.type === SeriesType.DELTA;
|
||||
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 {
|
||||
@ -36,6 +43,8 @@ export class Graph {
|
||||
Series.fromJson(json.series),
|
||||
validateString(json.name),
|
||||
validateBoolean(json.visible),
|
||||
GraphType.fromJson(json.type),
|
||||
validateString(json.fill),
|
||||
validateString(json.color),
|
||||
validateNumber(json.factor),
|
||||
validateString(json.group) as Group,
|
||||
32
src/main/angular/src/app/plot/axis/graph/GraphType.ts
Normal file
32
src/main/angular/src/app/plot/axis/graph/GraphType.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {validateString} from "../../../COMMON";
|
||||
|
||||
export class GraphType {
|
||||
|
||||
protected static _values: GraphType[] = [];
|
||||
|
||||
static readonly LINE = new GraphType("LINE", "line", "Linie");
|
||||
|
||||
static readonly BAR = new GraphType("BAR", "bar", "Balken");
|
||||
|
||||
private constructor(
|
||||
readonly jsonName: string,
|
||||
readonly chartJsName: "line" | "bar",
|
||||
readonly display: string,
|
||||
) {
|
||||
GraphType._values.push(this);
|
||||
}
|
||||
|
||||
static fromJson(json: any): GraphType {
|
||||
const name = validateString(json)
|
||||
const graphType = GraphType._values.filter(i => i.jsonName === name)[0];
|
||||
if (!graphType) {
|
||||
throw new Error(`Not an GraphType: ${JSON.stringify(json)}`);
|
||||
}
|
||||
return graphType;
|
||||
}
|
||||
|
||||
static get values(): GraphType[] {
|
||||
return GraphType._values;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,23 +1,23 @@
|
||||
<div class="layout">
|
||||
<div class="header">
|
||||
<select [ngModel]="plot" (ngModelChange)="buildPlot($event)">
|
||||
@for (p of plotList; track p.id) {
|
||||
<select [ngModel]="plot" (ngModelChange)="plot = $event">
|
||||
@for (p of plotService.list; track p.id) {
|
||||
<option [ngValue]="p">#{{ p.position }}: {{ p.name || '---' }}</option>
|
||||
}
|
||||
</select>
|
||||
<button (click)="plotService.plotCreate(updateAndBuild);">
|
||||
<button class="button buttonAdd" (click)="plotService.plotCreate(setPlot)">
|
||||
<fa-icon [icon]="faPlus"></fa-icon>
|
||||
</button>
|
||||
<button (click)="plot && plotService.plotDuplicate(plot, updateAndBuild)" [disabled]="!plot">
|
||||
<button class="button buttonCopy" (click)="plot && plotService.plotDuplicate(plot, setPlot)" [disabled]="!plot">
|
||||
<fa-icon [icon]="faCopy"></fa-icon>
|
||||
</button>
|
||||
<button (click)="plot && plotService.plotDelete(plot, updatePlot)" [disabled]="!plot">
|
||||
<button class="button buttonRemove" (click)="plot && plotService.plotDelete(plot)" [disabled]="!plot">
|
||||
<fa-icon [icon]="faTrash"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div #container class="container">
|
||||
<canvas #chartCanvas></canvas>
|
||||
<div class="plot">
|
||||
<app-plot [plot]="plot"></app-plot>
|
||||
</div>
|
||||
|
||||
@if (plot) {
|
||||
@ -34,26 +34,26 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<app-text [initial]="plot.name" (onChange)="plotService.plotName(plot, $event, updatePlot)"></app-text>
|
||||
<app-text [initial]="plot.name" (onChange)="plotService.plotName(plot, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="plot.interval" (ngModelChange)="plotService.plotInterval(plot, $event, updatePlot)">
|
||||
<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, updatePlot)"></app-number-nn>
|
||||
<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, updatePlot)"></app-number-nn>
|
||||
<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, updatePlot)"></app-checkbox>
|
||||
<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, updatePlot)"></app-number-nn>
|
||||
<app-number-nn [initial]="plot.position" (onChange)="plotService.plotPosition(plot, $event)"></app-number-nn>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -81,48 +81,46 @@
|
||||
@for (axis of plot.axes; track axis.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<app-checkbox [initial]="axis.visible" (onChange)="plotService.axisVisible(axis, $event, updatePlot)"></app-checkbox>
|
||||
<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, updatePlot)"></app-text>
|
||||
<app-text [initial]="axis.name" (onChange)="plotService.axisName(axis, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<app-text [initial]="axis.unit" (onChange)="plotService.axisUnit(axis, $event, updatePlot)"></app-text>
|
||||
<app-text [initial]="axis.unit" (onChange)="plotService.axisUnit(axis, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<app-checkbox [initial]="axis.right" (onChange)="plotService.axisRight(axis, $event, updatePlot)"></app-checkbox>
|
||||
<app-checkbox [initial]="axis.right" (onChange)="plotService.axisRight(axis, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<app-number [initial]="axis.min" (onChange)="plotService.axisMin(axis, $event, updatePlot)"></app-number>
|
||||
<app-number [initial]="axis.min" (onChange)="plotService.axisMin(axis, $event)"></app-number>
|
||||
</td>
|
||||
<td>
|
||||
<app-checkbox [initial]="axis.minHard" (onChange)="plotService.axisMinHard(axis, $event, updatePlot)"></app-checkbox>
|
||||
<app-checkbox [initial]="axis.minHard" (onChange)="plotService.axisMinHard(axis, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<app-number [initial]="axis.max" (onChange)="plotService.axisMax(axis, $event, updatePlot)"></app-number>
|
||||
<app-number [initial]="axis.max" (onChange)="plotService.axisMax(axis, $event)"></app-number>
|
||||
</td>
|
||||
<td>
|
||||
<app-checkbox [initial]="axis.maxHard" (onChange)="plotService.axisMaxHard(axis, $event, updatePlot)"></app-checkbox>
|
||||
<app-checkbox [initial]="axis.maxHard" (onChange)="plotService.axisMaxHard(axis, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<button (click)="plotService.axisDelete(axis, updatePlot)">
|
||||
<button class="button buttonRemove" (click)="plotService.axisDelete(axis)">
|
||||
<fa-icon [icon]="faTrash"></fa-icon>
|
||||
Achse
|
||||
</button>
|
||||
|
||||
<button (click)="plotService.axisAddGraph(axis, updatePlot)">
|
||||
<fa-icon [icon]="faPlus"></fa-icon>
|
||||
Grafen
|
||||
<button class="button buttonAdd" (click)="plotService.axisAddGraph(axis)">
|
||||
<fa-icon [icon]="faChartLine"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
|
||||
<button (click)="plotService.plotAddAxis(plot, updatePlot)">
|
||||
<button class="button buttonAdd" (click)="plotService.plotAddAxis(plot)">
|
||||
<fa-icon [icon]="faPlus"></fa-icon>
|
||||
Achse
|
||||
</button>
|
||||
@ -138,6 +136,7 @@
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Serie</th>
|
||||
<th>Typ</th>
|
||||
<th>Farbe</th>
|
||||
<th>Faktor</th>
|
||||
<th>Aggregat</th>
|
||||
@ -152,43 +151,50 @@
|
||||
@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, updatePlot)"></app-checkbox>
|
||||
<app-checkbox [initial]="graph.visible" (onChange)="plotService.graphVisible(graph, $event)"></app-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<app-text [initial]="graph.name" (onChange)="plotService.graphName(graph, $event, updatePlot)"></app-text>
|
||||
<app-text [initial]="graph.name" (onChange)="plotService.graphName(graph, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="graph.series.id" (ngModelChange)="plotService.graphSeries(graph, $event, updatePlot)">
|
||||
@for (s of seriesList; track s.id) {
|
||||
<select [ngModel]="graph.series.id" (ngModelChange)="plotService.graphSeries(graph, $event)">
|
||||
@for (s of seriesService.list; track s.id) {
|
||||
<option [ngValue]="s.id">{{ s.name }} [{{ s.valueString }}]</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<app-text [initial]="graph.color" (onChange)="plotService.graphColor(graph, $event, updatePlot)"></app-text>
|
||||
<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-number-nn [initial]="graph.factor" (onChange)="plotService.graphFactor(graph, $event, updatePlot)"></app-number-nn>
|
||||
<app-text [initial]="graph.color" (onChange)="plotService.graphColor(graph, $event)"></app-text>
|
||||
</td>
|
||||
<td>
|
||||
<select [ngModel]="graph.group" (ngModelChange)="plotService.graphGroup(graph, $event, updatePlot)">
|
||||
<app-number-nn [initial]="graph.factor" (onChange)="plotService.graphFactor(graph, $event)"></app-number-nn>
|
||||
</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, updatePlot)"></app-text>
|
||||
<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, updatePlot)"></app-checkbox>
|
||||
<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, updatePlot)"></app-checkbox>
|
||||
<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, updatePlot)"></app-checkbox>
|
||||
<app-checkbox [initial]="graph.max" (onChange)="plotService.graphMax(graph, $event)"></app-checkbox>
|
||||
</td>
|
||||
} @else {
|
||||
@if (graph.series.type === SeriesType.BOOL) {
|
||||
@ -198,14 +204,14 @@
|
||||
}
|
||||
}
|
||||
<td>
|
||||
<select [ngModel]="graph.axis.id" (ngModelChange)="plotService.graphAxis(graph, $event, updatePlot)">
|
||||
<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 (click)="plotService.graphDelete(graph, updatePlot)">
|
||||
<button class="button buttonRemove" (click)="plotService.graphDelete(graph)">
|
||||
<fa-icon [icon]="faTrash"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
@ -2,9 +2,9 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 40vh;
|
||||
.plot {
|
||||
height: 60vw;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.subSeries {
|
||||
147
src/main/angular/src/app/plot/editor/plot-editor.component.ts
Normal file
147
src/main/angular/src/app/plot/editor/plot-editor.component.ts
Normal file
@ -0,0 +1,147 @@
|
||||
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 'chartjs-adapter-date-fns';
|
||||
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';
|
||||
|
||||
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-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 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);
|
||||
}
|
||||
|
||||
protected readonly GraphType = GraphType;
|
||||
}
|
||||
@ -1,374 +0,0 @@
|
||||
import {AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {BarController, BarElement, CategoryScale, Chart, ChartDataset, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
|
||||
import {SeriesService} from "../series/series.service";
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import {toAvg, toBool, toDelta, toMax, toMin} from '../series/MinMaxAvg';
|
||||
import {SeriesType} from '../series/SeriesType';
|
||||
import {PlotService} from './plot.service';
|
||||
import {Plot} from './Plot';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {NTU, UTN} from '../COMMON';
|
||||
import {Graph} from './Graph';
|
||||
import {Axis} from './Axis';
|
||||
import {Series} from '../series/Series';
|
||||
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 {Delta, DeltaService} from '../series/delta/delta-service';
|
||||
import {Bool, BoolService} from '../series/bool/bool-service';
|
||||
import {Varying, VaryingService} from '../series/varying/varying-service';
|
||||
import {Interval} from '../series/Interval';
|
||||
import {faPlus, faTrash} from '@fortawesome/free-solid-svg-icons';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {Location} from '@angular/common';
|
||||
|
||||
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,
|
||||
FaIconComponent,
|
||||
TextComponent,
|
||||
NumberComponent,
|
||||
CheckboxComponent,
|
||||
NumberNNComponent
|
||||
],
|
||||
templateUrl: './plot.component.html',
|
||||
styleUrl: './plot.component.less'
|
||||
})
|
||||
export class PlotComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
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;
|
||||
|
||||
@ViewChild('chartCanvas')
|
||||
canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
@ViewChild('container')
|
||||
chartContainer!: ElementRef<HTMLDivElement>;
|
||||
|
||||
private chart!: Chart;
|
||||
|
||||
protected seriesList: Series[] = [];
|
||||
|
||||
protected plotList: Plot[] = [];
|
||||
|
||||
protected plot: Plot | null = null;
|
||||
|
||||
private readonly subscriptions: Subscription[] = [];
|
||||
|
||||
private readonly graphSubscriptions: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
readonly serieService: SeriesService,
|
||||
readonly plotService: PlotService,
|
||||
readonly boolService: BoolService,
|
||||
readonly deltaService: DeltaService,
|
||||
readonly varyingService: VaryingService,
|
||||
readonly activatedRoute: ActivatedRoute,
|
||||
readonly location: Location
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscriptions.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.serieService.findAll(series => {
|
||||
this.seriesList = series.sort(Series.compareName);
|
||||
this.plotService.findAll(plots => {
|
||||
this.plotList = plots.sort(Plot.comparePosition);
|
||||
this.subscriptions.push(this.plotService.subscribe(this.updatePlot));
|
||||
this.activatedRoute.params.subscribe(params => {
|
||||
const id = parseInt(params["id"]);
|
||||
if (isNaN(id)) {
|
||||
this.buildPlot(this.plotList[0]);
|
||||
} else {
|
||||
this.buildPlot(this.plotList.filter(plot => plot.id === id)[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.subscriptions.push(this.serieService.subscribe(this.updateSeries));
|
||||
});
|
||||
}
|
||||
|
||||
readonly buildPlot = (plot: Plot | null | undefined): void => {
|
||||
this.plot = UTN(plot);
|
||||
|
||||
this.graphSubscriptions.forEach(s => s.unsubscribe());
|
||||
this.graphSubscriptions.length = 0;
|
||||
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) {
|
||||
this.location.go('/plot');
|
||||
return;
|
||||
}
|
||||
|
||||
this.location.go('/plot/' + this.plot.id);
|
||||
if (!this.chart.options.plugins) {
|
||||
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.min ? this.newDataset(graph, " min", false) : null;
|
||||
const mid: Dataset = graph.mid ? this.newDataset(graph, midSuffix, min ? '-1' : graph.series.type === SeriesType.BOOL) : null;
|
||||
const max: Dataset = graph.max ? this.newDataset(graph, " max", min || mid ? '-1' : false) : null;
|
||||
switch (graph.series.type) {
|
||||
case SeriesType.BOOL:
|
||||
this.graphSubscriptions.push(this.boolService.subscribe(bool => this.updateBool(bool, mid), [graph.series.id]));
|
||||
break;
|
||||
case SeriesType.DELTA:
|
||||
this.graphSubscriptions.push(this.deltaService.subscribe(delta => this.updateDelta(delta, mid), [graph.series.id, graph.axis.plot.interval.name]));
|
||||
break;
|
||||
case SeriesType.VARYING:
|
||||
this.graphSubscriptions.push(this.varyingService.subscribe(varying => this.updateVarying(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 as "line", // TODO
|
||||
fill: fill || (graph.stack ? 'stack' : false),
|
||||
stack: graph.stack || undefined,
|
||||
borderWidth: 1,
|
||||
// barThickness: 'flex', // TODO
|
||||
borderColor: graph.color,
|
||||
pointRadius: graph.series.type === SeriesType.BOOL ? 0 : 4,
|
||||
pointBackgroundColor: graph.color,
|
||||
backgroundColor: graph.color + "33",
|
||||
yAxisID: "y" + graph.axis.id,
|
||||
pointStyle: graph.type === '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.serieService.points(
|
||||
graph.series,
|
||||
graph.axis.plot.interval,
|
||||
graph.axis.plot.offset,
|
||||
graph.axis.plot.duration,
|
||||
points => {
|
||||
if (graph.series.type === SeriesType.BOOL) {
|
||||
mid.data = toBool(points, graph.factor);
|
||||
} else if (graph.series.type === SeriesType.DELTA) {
|
||||
mid.data = toDelta(points, graph.factor);
|
||||
} else if (graph.series.type === SeriesType.VARYING) {
|
||||
if (min) {
|
||||
min.data = toMin(points, graph.factor);
|
||||
}
|
||||
if (max) {
|
||||
max.data = toMax(points, graph.factor);
|
||||
}
|
||||
if (mid) {
|
||||
mid.data = toAvg(points, graph.factor);
|
||||
}
|
||||
}
|
||||
this.chart.update();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected readonly updatePlot = (fresh: Plot): void => {
|
||||
const index = this.plotList.findIndex(plot => plot.id === fresh.id);
|
||||
if (fresh.deleted) {
|
||||
if (index >= 0) {
|
||||
this.plotList.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
if (index < 0) {
|
||||
this.plotList.push(fresh);
|
||||
} else {
|
||||
this.plotList[index] = fresh;
|
||||
}
|
||||
}
|
||||
this.plotList = this.plotList.sort(Plot.comparePosition);
|
||||
|
||||
const selected = this.plot?.id === fresh.id;
|
||||
if (fresh.deleted) {
|
||||
if (selected) {
|
||||
const previousIndex = Math.max(0, index - 1);
|
||||
const previousPlot = this.plotList[previousIndex];
|
||||
this.buildPlot(previousPlot);
|
||||
}
|
||||
} else if (!this.plot || selected) {
|
||||
this.buildPlot(fresh);
|
||||
}
|
||||
};
|
||||
|
||||
protected readonly updateSeries = (fresh: Series): void => {
|
||||
const index = this.seriesList.findIndex(series => series.id === fresh.id);
|
||||
if (index < 0) {
|
||||
this.seriesList.push(fresh);
|
||||
} else if (fresh.deleted) {
|
||||
this.seriesList.splice(index, 1);
|
||||
} else {
|
||||
this.seriesList[index] = fresh;
|
||||
}
|
||||
this.seriesList = this.seriesList.sort(Series.compareName);
|
||||
};
|
||||
|
||||
protected groups(): string[] {
|
||||
return Object.keys(Group);
|
||||
}
|
||||
|
||||
private updateBool(bool: Bool, mid: Dataset): void {
|
||||
this.updatePoint(mid, bool.date, bool.state ? 1 : 0);
|
||||
}
|
||||
|
||||
private updateDelta(delta: Delta, mid: Dataset): void {
|
||||
this.updatePoint(mid, delta.date, delta.last - delta.first);
|
||||
}
|
||||
|
||||
private updateVarying(varying: Varying, min: Dataset | null, avg: Dataset | null, max: Dataset | null): void {
|
||||
if (min) {
|
||||
this.updatePoint(min, varying.date, varying.min);
|
||||
}
|
||||
if (avg) {
|
||||
this.updatePoint(avg, varying.date, varying.avg);
|
||||
}
|
||||
if (max) {
|
||||
this.updatePoint(max, varying.date, varying.max);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePoint(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;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
protected updateAndBuild = (plot: Plot): void => {
|
||||
this.updatePlot(plot);
|
||||
this.buildPlot(plot);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,35 +1,36 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, CrudService, Next} from '../COMMON';
|
||||
import {ApiService, EntityListService, Next} from '../COMMON';
|
||||
import {Plot} from './Plot';
|
||||
import {Axis} from './Axis';
|
||||
import {Graph} from './Graph';
|
||||
import {Axis} from './axis/Axis';
|
||||
import {Graph} 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 CrudService<Plot> {
|
||||
export class PlotService extends EntityListService<Plot> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ["Plot"], Plot.fromJson);
|
||||
super(api, ["Plot"], Plot.fromJson, Plot.equals, Plot.comparePosition);
|
||||
}
|
||||
|
||||
plotCreate(next: Next<Plot>): void {
|
||||
plotCreate(next?: Next<Plot>): void {
|
||||
this.getSingle(["create"], next);
|
||||
}
|
||||
|
||||
plotDuplicate(plot: Plot, next: Next<Plot>): void {
|
||||
plotDuplicate(plot: Plot, next?: Next<Plot>): void {
|
||||
this.getSingle([plot.id, "duplicate"], next);
|
||||
}
|
||||
|
||||
plotDelete(plot: Plot, next: Next<Plot>): void {
|
||||
plotDelete(plot: Plot, next?: Next<Plot>): void {
|
||||
this.getSingle([plot.id, "delete"], next);
|
||||
}
|
||||
|
||||
plotAddAxis(plot: Plot, next: Next<Plot>): void {
|
||||
plotAddAxis(plot: Plot, next?: Next<Plot>): void {
|
||||
this.getSingle([plot.id, 'addAxis'], next);
|
||||
}
|
||||
|
||||
@ -57,7 +58,7 @@ export class PlotService extends CrudService<Plot> {
|
||||
this.postSingle([plot.id, 'position'], value, next);
|
||||
}
|
||||
|
||||
axisAddGraph(axis: Axis, next: Next<Plot>): void {
|
||||
axisAddGraph(axis: Axis, next?: Next<Plot>): void {
|
||||
this.getSingle(['Axis', axis.id, 'addGraph'], next);
|
||||
}
|
||||
|
||||
@ -105,6 +106,10 @@ export class PlotService extends CrudService<Plot> {
|
||||
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);
|
||||
}
|
||||
|
||||
3
src/main/angular/src/app/plot/plot/plot.component.html
Normal file
3
src/main/angular/src/app/plot/plot/plot.component.html
Normal file
@ -0,0 +1,3 @@
|
||||
<div #container class="container">
|
||||
<canvas #chartCanvas></canvas>
|
||||
</div>
|
||||
4
src/main/angular/src/app/plot/plot/plot.component.less
Normal file
4
src/main/angular/src/app/plot/plot/plot.component.less
Normal file
@ -0,0 +1,4 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
277
src/main/angular/src/app/plot/plot/plot.component.ts
Normal file
277
src/main/angular/src/app/plot/plot/plot.component.ts
Normal file
@ -0,0 +1,277 @@
|
||||
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 'chartjs-adapter-date-fns';
|
||||
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 {Delta, DeltaService} from '../../series/delta/delta-service';
|
||||
import {Bool, BoolService} from '../../series/bool/bool-service';
|
||||
import {Varying, VaryingService} from '../../series/varying/varying-service';
|
||||
import {Interval} from '../../series/Interval';
|
||||
import {SeriesService} from '../../series/series.service';
|
||||
import {GraphType} from '../axis/graph/GraphType';
|
||||
|
||||
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: 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.points(
|
||||
graph.series,
|
||||
graph.axis.plot.interval,
|
||||
graph.axis.plot.offset,
|
||||
graph.axis.plot.duration,
|
||||
points => {
|
||||
if (graph.series.type === SeriesType.BOOL) {
|
||||
mid.data = toBool(points, graph.factor);
|
||||
} else if (graph.series.type === SeriesType.DELTA) {
|
||||
mid.data = toDelta(points, graph.factor);
|
||||
} else if (graph.series.type === SeriesType.VARYING) {
|
||||
if (min) {
|
||||
min.data = toMin(points, graph.factor);
|
||||
}
|
||||
if (max) {
|
||||
max.data = toMax(points, graph.factor);
|
||||
}
|
||||
if (mid) {
|
||||
mid.data = toAvg(points, graph.factor);
|
||||
}
|
||||
}
|
||||
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];
|
||||
const yMultiplied = y * graph.factor;
|
||||
if (point) {
|
||||
if (point.y !== yMultiplied) {
|
||||
point.y = yMultiplied;
|
||||
this.chart.update();
|
||||
}
|
||||
} else {
|
||||
dataset.data.push({x: x, y: yMultiplied}); // TODO check if this is a LIVE/SCROLLING plot (right end of plot is 'now')
|
||||
this.chart.update();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import {mapNotNull, validateBoolean, validateNumber, validateString} from "../COMMON";
|
||||
import {ID, mapNotNull, validateBoolean, validateNumber, validateString} from "../COMMON";
|
||||
import {SeriesType} from './SeriesType';
|
||||
import {formatNumber} from '@angular/common';
|
||||
|
||||
export class Series {
|
||||
export class Series extends ID<Series> {
|
||||
|
||||
constructor(
|
||||
readonly id: number,
|
||||
@ -14,7 +14,7 @@ export class Series {
|
||||
readonly decimals: number,
|
||||
readonly value: number | null,
|
||||
) {
|
||||
//
|
||||
super();
|
||||
}
|
||||
|
||||
static fromJson(json: any): Series {
|
||||
@ -46,4 +46,7 @@ export class Series {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
equals2() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService, CrudService, Next, validateNumber} from "../COMMON";
|
||||
import {ApiService, EntityListService, Next, validateNumber} from "../COMMON";
|
||||
import {Series} from './Series';
|
||||
import {Interval} from './Interval';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SeriesService extends CrudService<Series> {
|
||||
export class SeriesService extends EntityListService<Series> {
|
||||
|
||||
constructor(
|
||||
api: ApiService,
|
||||
) {
|
||||
super(api, ['Series'], Series.fromJson);
|
||||
super(api, ['Series'], Series.fromJson, Series.equals, Series.compareName);
|
||||
}
|
||||
|
||||
points(series: Series, interval: Interval, offset: number, duration: number, next: Next<number[][]>): void {
|
||||
|
||||
@ -3,6 +3,10 @@ html, body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
@ -27,3 +31,22 @@ input, select {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -50,6 +50,17 @@ public class Graph {
|
||||
@Column(nullable = false)
|
||||
private boolean visible = true;
|
||||
|
||||
@Setter
|
||||
@NonNull
|
||||
@Column(nullable = false)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private GraphType type = GraphType.LINE;
|
||||
|
||||
@Setter
|
||||
@NonNull
|
||||
@Column(nullable = false)
|
||||
private String fill = "";
|
||||
|
||||
@Setter
|
||||
@NonNull
|
||||
@Column(nullable = false)
|
||||
|
||||
@ -41,6 +41,11 @@ public class GraphController {
|
||||
return graphService.set(id, graph -> graph.setVisible(value));
|
||||
}
|
||||
|
||||
@PostMapping("{id}/type")
|
||||
public PlotDto type(@PathVariable final long id, @RequestBody @NonNull final String value) {
|
||||
return graphService.set(id, graph -> graph.setType(GraphType.valueOf(value)));
|
||||
}
|
||||
|
||||
@PostMapping("{id}/name")
|
||||
public PlotDto name(@PathVariable final long id, @RequestBody(required = false) @Nullable final String value) {
|
||||
return graphService.set(id, graph -> graph.setName(or(value, "")));
|
||||
|
||||
@ -19,6 +19,10 @@ public class GraphDto {
|
||||
|
||||
public final boolean visible;
|
||||
|
||||
public final GraphType type;
|
||||
|
||||
public final String fill;
|
||||
|
||||
@NonNull
|
||||
public final String color;
|
||||
|
||||
@ -42,6 +46,8 @@ public class GraphDto {
|
||||
this.series = new SeriesDto(graph.getSeries(), false);
|
||||
this.name = graph.getName();
|
||||
this.visible = graph.isVisible();
|
||||
this.type = graph.getType();
|
||||
this.fill = graph.getFill();
|
||||
this.color = graph.getColor();
|
||||
this.factor = graph.getFactor();
|
||||
this.group = graph.getGroup();
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package de.ph87.data.plot.axis.graph;
|
||||
|
||||
public enum GraphType {
|
||||
LINE, BAR
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user