ADD: Temperatur and Weather Overview

This commit is contained in:
sebastian 2026-06-30 15:46:06 +02:00
parent 9bcd4e2e39
commit af273889d8
12 changed files with 544 additions and 2 deletions

33
package-lock.json generated
View File

@ -16,6 +16,8 @@
"@angular/material": "^22.0.2",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"date-fns": "^4.4.0",
"ng-apexcharts": "^2.4.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@ -4533,6 +4535,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/apexcharts": {
"version": "5.15.2",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.15.2.tgz",
"integrity": "sha512-qbd+ehDiRxo++wAqaGLmJcd683ktulTqoku4WYYyQ3XOgJIn4yhOQGV27KcdK7c2yhwxblyh4mgMm8ukI0wC0Q==",
"license": "SEE LICENSE IN LICENSE",
"peer": true
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -5308,6 +5317,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -7349,6 +7368,20 @@
"node": ">= 0.4.0"
}
},
"node_modules/ng-apexcharts": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ng-apexcharts/-/ng-apexcharts-2.4.0.tgz",
"integrity": "sha512-bNXIOQrux9ZXnpm4kxbW8M6AA4ENAEOpR31ZoaeBkc0SrfOMw1nxRN5ORkStOQFVQF5Wf5qivAA9KIgU7Ol26A==",
"dependencies": {
"tslib": "^2.8.1"
},
"peerDependencies": {
"@angular/common": ">=20.0.0",
"@angular/core": ">=20.0.0",
"apexcharts": "^5.10.3",
"rxjs": "^7.8.2"
}
},
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",

View File

@ -19,6 +19,8 @@
"@angular/material": "^22.0.2",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"date-fns": "^4.4.0",
"ng-apexcharts": "^2.4.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},

View File

@ -25,7 +25,10 @@
<mat-drawer mode="side" opened>
<app-station-list></app-station-list>
</mat-drawer>
<mat-drawer-content>Main Cointent</mat-drawer-content>
<mat-drawer-content>
<app-wettter-overview></app-wettter-overview>
<app-temperature></app-temperature>
</mat-drawer-content>
</mat-drawer-container>

View File

@ -5,10 +5,12 @@ import {MatIcon} from '@angular/material/icon';
import {MatButton, MatIconButton} from '@angular/material/button';
import {MatDrawer, MatDrawerContainer, MatDrawerContent} from '@angular/material/sidenav';
import {StationList} from './station-list/station-list';
import {WettterOverview} from './components/wettter-overview/wettter-overview';
import {TemperatureComponent} from './components/diagrams/temperature/temperature';
@Component({
selector: 'app-root',
imports: [RouterOutlet, MatToolbar, MatIcon, MatIconButton, RouterLink, MatButton, MatDrawerContainer, MatDrawer, MatDrawerContent, StationList],
imports: [RouterOutlet, MatToolbar, MatIcon, MatIconButton, RouterLink, MatButton, MatDrawerContainer, MatDrawer, MatDrawerContent, StationList, WettterOverview, TemperatureComponent],
templateUrl: './app.html',
styleUrl: './app.css'
})

View File

@ -0,0 +1,21 @@
@if(measurements) {
<mat-card>
<mat-card-header>
<mat-card-title>Temperaturverlauf</mat-card-title>
</mat-card-header>
<mat-card-content>
<apx-chart
[series]="chartOptions.series!"
[chart]="chartOptions.chart!"
[xaxis]="chartOptions.xaxis!"
[yaxis]="chartOptions.yaxis!"
[stroke]="chartOptions.stroke!"
[grid]="chartOptions.grid!"
[tooltip]="chartOptions.tooltip!"
[legend]="chartOptions.legend!"
[theme]="chartOptions.theme!"
/>
</mat-card-content>
</mat-card>
}

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Temperature } from './temperature';
describe('Temperature', () => {
let component: Temperature;
let fixture: ComponentFixture<Temperature>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Temperature],
}).compileComponents();
fixture = TestBed.createComponent(Temperature);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,120 @@
import {ChangeDetectorRef, Component, Input, OnInit} from '@angular/core';
import {DefaultService, MeasurementListResponse, MeasurementResolution, StationListResponse} from '../../../api';
import {addDays, addHours, addMonths, addYears, startOfDay} from 'date-fns';
import {ApexOptions, ChartComponent} from 'ng-apexcharts';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatProgressSpinner} from '@angular/material/progress-spinner';
export type TimeResolution = '24h' | '7d' | '30d' | '1Y' | 'All'
@Component({
selector: 'app-temperature',
imports: [
ChartComponent,
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
],
templateUrl: './temperature.html',
styleUrl: './temperature.css',
})
export class TemperatureComponent implements OnInit {
@Input() timeResolution: TimeResolution = '7d';
@Input() activeStations: StationListResponse[] = [];
resolution: MeasurementResolution = MeasurementResolution.Daily;
measurements: MeasurementListResponse | undefined;
chartOptions: ApexOptions = {};
constructor(private api: DefaultService, private cdr: ChangeDetectorRef) {}
ngOnInit() {
if (this.activeStations.length === 0) {
this.api.listStationsStationsListGet().subscribe(stations => {
this.activeStations = stations;
this.fetchMeasurements();
});
} else {
this.fetchMeasurements();
}
}
get timeIntervalStart(): string | undefined {
const now = new Date();
const map: Record<TimeResolution, Date | undefined> = {
'24h': addHours(now, -24),
'7d': startOfDay(addDays(now, -7)),
'30d': startOfDay(addMonths(now, -1)),
'1Y': startOfDay(addYears(now, -1)),
'All': undefined
};
return map[this.timeResolution]?.toISOString();
}
fetchMeasurements() {
const stationIds = this.activeStations.map(s => s.id);
const now = new Date().toISOString();
this.api.getMeasurementsMeasurementsGet(
stationIds, undefined, this.timeIntervalStart, now, this.resolution
).subscribe(data => {
this.measurements = data;
this.buildChart();
});
}
buildChart() {
if (!this.measurements) return;
this.chartOptions = {
chart: {
type: 'line',
height: 280,
toolbar: { show: false },
animations: { enabled: true },
background: 'transparent',
},
theme: { mode: 'dark' },
stroke: {
curve: 'smooth',
width: 2,
},
series: this.measurements.stations.map(s => ({
name: `${s.station.name} ${s.indoor ? '(innen)' : '(außen)'}`,
data: s.measurements.map(m => ({
x: new Date(m.timestamp).getTime(),
y: m.temperature
}))
})),
xaxis: {
type: 'datetime',
labels: {
datetimeUTC: false,
style: { colors: '#9e9e9e', fontSize: '11px' }
},
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
labels: {
style: { colors: '#9e9e9e', fontSize: '11px' },
formatter: (val: number) => `${val.toFixed(1)}°`
}
},
grid: {
borderColor: 'rgba(255,255,255,0.06)',
strokeDashArray: 4,
},
tooltip: {
theme: 'dark',
x: { format: 'dd.MM.yyyy HH:mm' },
y: { formatter: (val: number) => `${val.toFixed(1)} °C` }
},
legend: {
labels: { colors: '#9e9e9e' }
},
};
this.cdr.detectChanges()
}
}

View File

@ -0,0 +1,85 @@
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin: 2em;
}
.stat-card {
background: #1e1e1e !important;
color: #fff;
border-radius: 14px;
padding: 18px;
box-shadow:
0 4px 10px rgba(0, 0, 0, .35),
inset 0 1px 0 rgba(255,255,255,.04);
transition: all .2s ease;
&:hover {
transform: translateY(-2px);
box-shadow:
0 8px 18px rgba(0,0,0,.45),
inset 0 1px 0 rgba(255,255,255,.05);
}
}
.label {
display: flex;
align-items: center;
gap: 6px;
color: #9e9e9e;
font-size: 0.85rem;
font-weight: 500;
}
.label mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #8a8a8a;
}
.value {
margin-top: 8px;
font-size: 2rem;
font-weight: 600;
line-height: 1;
letter-spacing: -0.5px;
}
.change {
margin-top: 10px;
display: flex;
align-items: center;
gap: 4px;
font-size: .82rem;
font-weight: 500;
}
.change mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
.positive {
color: #37d67a;
}
.negative {
color: #ff6b6b;
}
.neutral {
color: #9e9e9e;
}
.forecast {
margin-top: 6px;
font-size: .78rem;
color: #9e9e9e;
font-style: italic;
}

View File

@ -0,0 +1,85 @@
<div class="stats-grid">
<mat-card class="stat-card">
<div class="label">
<mat-icon>thermostat</mat-icon>
Außen
</div>
<div class="value">{{out_temperature}}&nbsp;°C</div>
@if (outer_temperature_diff) {
@if (outer_temperature_diff > 0) {
<div class="change negative">
<mat-icon>arrow_upward</mat-icon>
{{outer_temperature_diff | number : '1.1-2'}}° vs. gestern
</div>
} @else {
<div class="change positive">
<mat-icon>arrow_downward</mat-icon>
{{outer_temperature_diff | number : '1.1-2'}}° vs. gestern
</div>
}
}
</mat-card>
<mat-card class="stat-card">
<div class="label">
<mat-icon>home</mat-icon>
Innen
</div>
<div class="value">{{inner_temperature | number: '1.1-2'}}&nbsp;°C</div>
@if (inner_temperature_diff) {
@if (inner_temperature_diff > 0) {
<div class="change negative">
<mat-icon>arrow_upward</mat-icon>
{{inner_temperature_diff | number : '1.1-2'}}° vs. gestern
</div>
} @else {
<div class="change positive">
<mat-icon>arrow_downward</mat-icon>
{{inner_temperature_diff | number : '1.1-2'}}° vs. gestern
</div>
}
}
</mat-card>
<mat-card class="stat-card">
<div class="label">
<mat-icon>water_drop</mat-icon>
Luftfeucht.
</div>
<div class="value">{{humidity | number: '1.1-2'}}&nbsp;%</div>
@if (humidity_diff) {
@if (humidity_diff > 0) {
<div class="change negative">
<mat-icon>arrow_upward</mat-icon>
{{humidity_diff | number : '1.1-2'}}% vs. gestern
</div>
} @else {
<div class="change positive">
<mat-icon>arrow_downward</mat-icon>
{{humidity_diff | number : '1.1-2'}}% vs. gestern
</div>
}
}
</mat-card>
<mat-card class="stat-card">
<div class="label">
<mat-icon>speed</mat-icon>
Luftdruck
</div>
<div class="value">{{pressure}} hPa</div>
@if(pressure) {
<div class="change neutral">
<mat-icon>{{pressureTrendIcon}}</mat-icon>
<div class="forecast">{{weatherForecast}}</div>
</div>
}
</mat-card>
</div>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { WettterOverview } from './wettter-overview';
describe('WettterOverview', () => {
let component: WettterOverview;
let fixture: ComponentFixture<WettterOverview>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WettterOverview],
}).compileComponents();
fixture = TestBed.createComponent(WettterOverview);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,147 @@
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {DefaultService, MeasurementListResponse, MeasurementResolution, StationMeasurementResponse} from '../../api';
import {MatCard} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon';
import {addDays, endOfDay, startOfDay} from 'date-fns';
import {DecimalPipe} from '@angular/common';
@Component({
selector: 'app-wettter-overview',
imports: [
MatCard,
MatIcon,
DecimalPipe
],
templateUrl: './wettter-overview.html',
styleUrl: './wettter-overview.css',
})
export class WettterOverview implements OnInit {
last_measurement: MeasurementListResponse | undefined
out_temperature: number | undefined
inner_temperature: number | undefined
humidity: number | undefined
pressure: number | undefined
outer_temperature_diff: number | undefined
inner_temperature_diff: number | undefined
humidity_diff: number | undefined
pressureDiff: number = 0
change_class(yesterday_val: number | undefined, today_val: number | undefined) {
if (yesterday_val && today_val) {
return yesterday_val <= today_val ? 'negative' : 'positive'
}
return 'neutral'
}
constructor(private apiService: DefaultService, private cdr: ChangeDetectorRef) {}
ngOnInit() {
const today = startOfDay(new Date());
const yesterdayStart = addDays(today, -1);
const yesterdayEnd = endOfDay(yesterdayStart);
this.apiService.getMeasurementsMeasurementsGet(undefined, undefined, undefined, undefined, MeasurementResolution.Raw, 1).subscribe(measurements => {
const outdoor_stations = measurements.stations.filter(s => !s.indoor)
this.out_temperature = this.computeAvgTemperature(outdoor_stations, false)
this.inner_temperature = this.computeAvgTemperature(measurements.stations, true)
this.humidity = this.computeAvgHumidity(measurements.stations, true)
this.pressure = this.computeAvgPressure(measurements.stations, false)
this.apiService.getMeasurementsMeasurementsGet(undefined, undefined, yesterdayStart.toISOString(), yesterdayEnd.toISOString(), MeasurementResolution.Daily, 1).subscribe(measurements => {
const outdoor_stations = measurements.stations.filter(s => !s.indoor)
const yesterday_outer_temperature = this.computeAvgTemperature(outdoor_stations, false)
this.outer_temperature_diff = yesterday_outer_temperature - this.out_temperature!
const yesterday_inner_temperature = this.computeAvgTemperature(measurements.stations, true)
this.inner_temperature_diff = yesterday_inner_temperature - this.inner_temperature!
const yesterday_humidity = this.computeAvgHumidity(measurements.stations, true)
this.humidity_diff = yesterday_humidity - this.humidity!
this.cdr.detectChanges();
})
})
this.apiService.listStationsStationsListGet().subscribe(stations => {
const outer_stations = stations.find(s => !s.indoor_only)
if (outer_stations) {
const now = new Date()
const threeHoursAgo = addDays(now, -3)
this.apiService.getMeasurementsMeasurementsGet([outer_stations.id], false, threeHoursAgo.toISOString(), now.toISOString(), MeasurementResolution.Hourly).subscribe(measurements => {
const station_measurements = measurements.stations[0].measurements
station_measurements.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
const recent = station_measurements[station_measurements.length - 1].pressure; // neueste
const older = station_measurements[0].pressure; // älteste
if (recent == null || older == null) {
this.pressureDiff = 0
} else {
this.pressureDiff = recent - older
}
})
}
})
}
get pressureTrendIcon(): string {
if (this.pressureDiff > 1.5) return 'trending_up';
if (this.pressureDiff < -1.5) return 'trending_down';
return 'trending_flat';
}
get pressureTrend(): string {
if (this.pressureDiff > 1.5) return `+${this.pressureDiff.toFixed(1)} hPa · steigend`;
if (this.pressureDiff < -1.5) return `${this.pressureDiff.toFixed(1)} hPa · fallend`;
return 'stabil';
}
get pressureTrendClass(): string {
if (this.pressureDiff > 1.5) return 'positive';
if (this.pressureDiff < -1.5) return 'negative';
return 'neutral';
}
get weatherForecast(): string {
return this.getWeatherForecast(this.pressure!, this.pressureDiff);
}
getWeatherForecast(current: number, diff: number): string {
const absDiff = Math.abs(diff);
const rising = diff > 0;
if (absDiff <= 1.5) {
return current > 1015 ? '☀️ Beständig schön' :
current < 1000 ? '🌧️ Beständig trüb' : '🌤️ Gleichbleibendes Wetter';
}
if (absDiff <= 3) {
return rising ? '🌤️ Wetterbesserung möglich' : '🌦️ Wetterverschlechterung möglich';
}
if (absDiff <= 6) {
return rising ? '☀️ Wetterbesserung wahrscheinlich' : '🌧️ Regen wahrscheinlich';
}
// > 6 hPa
return rising ? '☀️ Markante Wetterbesserung' : '⛈️ Sturm möglich';
}
computeAvgTemperature(stations: StationMeasurementResponse[], inner_measurements: boolean) {
const temperatures = stations.filter(s => s.indoor === inner_measurements).map(s => s.measurements[0].temperature);
return temperatures.reduce((sum, t) => sum + t, 0) / temperatures.length;
}
computeAvgHumidity(stations: StationMeasurementResponse[], inner_measurements: boolean) {
const humidity = stations.filter(s => s.indoor === inner_measurements).map(s => s.measurements[0].humidity ? s.measurements[0].humidity : 0);
return humidity.reduce((sum, t) => sum + t, 0) / humidity.length;
}
computeAvgPressure(stations: StationMeasurementResponse[], inner_measurements: boolean) {
const pressure = stations.filter(s=> s.indoor === inner_measurements).map(s => s.measurements[0].pressure ? s.measurements[0].pressure : 0)
return pressure.reduce((sum, t) => sum + t, 0) / pressure.length;
}
}