extracted Plot into own component + Dashboard + Graph.type + Graph.fill

This commit is contained in:
Patrick Haßel 2025-09-22 15:40:04 +02:00
parent 53fad7846b
commit 6172e888bf
27 changed files with 806 additions and 456 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
@for (plot of plots; track plot.id) {
<div class="plot">
<app-plot [plot]="plot"></app-plot>
</div>
}

View File

@ -0,0 +1,4 @@
.plot {
height: 60vw;
max-height: 100vh;
}

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

View File

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

View File

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

View File

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

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

View File

@ -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>
&nbsp;
<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>

View File

@ -2,9 +2,9 @@
display: flex;
}
.container {
width: 100%;
height: 40vh;
.plot {
height: 60vw;
max-height: 50vh;
}
.subSeries {

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package de.ph87.data.plot.axis.graph;
public enum GraphType {
LINE, BAR
}