added weather again

This commit is contained in:
Patrick Haßel 2025-11-21 14:38:45 +01:00
parent f7c30d71d2
commit e2b0476aaa
13 changed files with 340 additions and 22 deletions

View File

@ -9,12 +9,12 @@ import {registerLocaleData} from '@angular/common';
import localeDe from '@angular/common/locales/de'; import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de'; import localeDeExtra from '@angular/common/locales/extra/de';
import {stompServiceFactory} from './common'; import {stompServiceFactory} from './common';
import {BarController, BarElement, Chart, Legend, LinearScale, TimeScale, Tooltip} from 'chart.js'; import {BarController, BarElement, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Tooltip} from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
registerLocaleData(localeDe, 'de-DE', localeDeExtra); registerLocaleData(localeDe, 'de-DE', localeDeExtra);
Chart.register(TimeScale, LinearScale, BarController, BarElement, Tooltip, Legend); Chart.register(TimeScale, LinearScale, BarController, BarElement, Tooltip, Legend, LineController, PointElement, LineElement, Filler);
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [

View File

@ -2,6 +2,15 @@
<app-location-power [location]="location"></app-location-power> <app-location-power [location]="location"></app-location-power>
<div class="Section3">
<div class="SectionHeading">
<div class="SectionHeadingText">
Wettervorhersage
</div>
</div>
<app-weather-component [location]="location"></app-weather-component>
</div>
<app-location-energy [location]="location" [interval]="Interval.DAY" [offset]="offsetDay" unit="⌀W" [factor]="12 * 1000" [maxY]="850" [minY]="-850"> <app-location-energy [location]="location" [interval]="Interval.DAY" [offset]="offsetDay" unit="⌀W" [factor]="12 * 1000" [maxY]="850" [minY]="-850">
<div style="display: flex; width: 100%; gap: 0.25em;"> <div style="display: flex; width: 100%; gap: 0.25em;">
<div (click)="offsetDayAdd(+1)">&larr;</div> <div (click)="offsetDayAdd(+1)">&larr;</div>

View File

@ -15,6 +15,7 @@ import {SeriesType} from '../../series/SeriesType';
import {DateService} from '../../date.service'; import {DateService} from '../../date.service';
import {LocationPower} from '../power/location-power'; import {LocationPower} from '../power/location-power';
import {ConfigService} from '../../config.service'; import {ConfigService} from '../../config.service';
import {WeatherComponent} from '../../weather/plot/weather-component';
export function paramNumberOrNull(params: Params, key: string): number | null { export function paramNumberOrNull(params: Params, key: string): number | null {
const param = params[key]; const param = params[key];
@ -35,7 +36,8 @@ export function paramNumberOrNull(params: Params, key: string): number | null {
Number, Number,
SeriesSelect, SeriesSelect,
LocationEnergy, LocationEnergy,
LocationPower LocationPower,
WeatherComponent
], ],
templateUrl: './location-detail.html', templateUrl: './location-detail.html',
styleUrl: './location-detail.less', styleUrl: './location-detail.less',

View File

@ -10,8 +10,10 @@ import {formatNumber} from '@angular/common';
const COLOR_BACK_PURCHASE = "#ffb9b9"; const COLOR_BACK_PURCHASE = "#ffb9b9";
const COLOR_BACK_DELIVER = "#ff59ff"; const COLOR_BACK_DELIVER = "#ff59ff";
// noinspection JSUnusedLocalSymbols
const COLOR_BACK_PRODUCE = "#5cbcff"; const COLOR_BACK_PRODUCE = "#5cbcff";
const COLOR_BACK_SELF = "#60ff8c"; const COLOR_BACK_SELF = "#60ff8c";
// noinspection JSUnusedLocalSymbols
const COLOR_BACK_CONSUME = "#ffc07a"; const COLOR_BACK_CONSUME = "#ffc07a";
@Component({ @Component({
@ -71,7 +73,6 @@ export class EnergyCharts implements OnChanges {
locale: de locale: de
}, },
}, },
bounds: 'ticks',
}, },
y: {}, y: {},
}, },

View File

@ -0,0 +1,25 @@
import {validateDate, validateNumber} from '../common';
export class WeatherHour {
constructor(
readonly date: Date,
readonly clouds: number,
readonly irradiation: number,
readonly precipitation: number,
readonly temperature: number,
) {
//
}
static fromJson(json: any): WeatherHour {
return new WeatherHour(
validateDate(json.date),
validateNumber(json.clouds),
validateNumber(json.irradiation),
validateNumber(json.precipitation),
validateNumber(json.temperature),
);
}
}

View File

@ -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,238 @@
import {AfterViewInit, Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild} from '@angular/core';
import {WeatherService} from '../weather-service';
import {WeatherHour} from '../WeatherHour';
import {Chart} from 'chart.js';
import {de} from 'date-fns/locale';
import {format} from 'date-fns';
import {Location} from '../../location/Location';
import {formatNumber} from '@angular/common';
export function toPoint(f: (hour: WeatherHour) => number): (hour: WeatherHour) => { x: number, y: number } {
return (hour: WeatherHour) => {
return {
x: hour.date.getTime(),
y: f(hour),
};
};
}
export function temperatureColor(value: number | null | undefined) {
if (!value) {
return "black";
} else if (value < 0) {
return 'blue';
} else if (value < 20) {
return '#c1b100';
} else if (value < 30) {
return '#ff8100';
}
return TEMPERATURE_HIGH_COLOR;
}
const TEMPERATURE_HIGH_COLOR = '#FF0000';
const PRECIPITATION_COLOR = '#0000FF';
const CLOUDS_COLOR = '#cccccc';
const SUN_COLOR = '#ffc400';
@Component({
selector: 'app-weather-component',
imports: [],
templateUrl: './weather-component.html',
styleUrl: './weather-component.less'
})
export class WeatherComponent implements AfterViewInit {
@ViewChild('chartCanvas')
protected canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('container')
protected chartContainer!: ElementRef<HTMLDivElement>;
private chart!: Chart;
@Input()
location!: Location;
constructor(
@Inject(LOCALE_ID) readonly locale: string,
readonly weatherService: WeatherService,
) {
//
}
ngAfterViewInit(): void {
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'line',
data: {
datasets: [],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false,
},
tooltip: {
mode: 'index',
intersect: false,
itemSort: (a, b) => b.datasetIndex - a.datasetIndex,
usePointStyle: true,
callbacks: {
title: function (items) {
const date = items[0].parsed.x as unknown as Date;
return format(date, 'EE dd.MM. HH:mm', {locale: de});
},
label: ((ctx: any) => {
const groups = /^(?<name>.+)\[(?<unit>.+)]$/.exec(ctx.dataset.label || '')?.groups;
if (groups) {
const value = ctx.parsed.y === null ? '-' : formatNumber(Math.abs(ctx.parsed.y), this.locale, '0.2-2');
return `${value} ${groups['unit']} ${groups['name']}`;
}
return ctx.dataset.label;
}) as any,
},
},
title: {
display: true,
text: "Wetter",
}
},
scales: {
x: {
type: 'time',
time: {
unit: "day",
displayFormats: {
day: "EE dd.MM"
}
},
adapters: {
date: {
locale: de,
}
},
},
y_temperature: {
display: true,
position: "right",
title: {
display: false,
text: "Temperatur [°C]",
color: TEMPERATURE_HIGH_COLOR,
},
ticks: {
color: context => temperatureColor(context.tick.value),
},
min: -10,
max: 35,
},
y_precipitation: {
display: false,
grid: {
display: false,
},
title: {
display: false,
text: "Niederschlag [mm]",
color: PRECIPITATION_COLOR,
},
ticks: {
color: PRECIPITATION_COLOR,
},
min: 0,
max: 15,
},
y_sun: {
display: false,
grid: {
display: false,
},
position: "right",
title: {
display: false,
text: "Sonne [kWh/m²]",
color: SUN_COLOR,
},
ticks: {
color: SUN_COLOR,
},
min: 0,
max: 1000,
},
y_clouds: {
display: false,
title: {
display: true,
text: "Wolkenbedeckung [%]",
color: CLOUDS_COLOR,
},
ticks: {
color: CLOUDS_COLOR,
},
min: 0,
max: 100,
},
},
},
});
this.weatherService.forLocation(this.location, hours => {
const now = Date.now();
const filtered = hours.filter(h => h.date.getTime() >= now);
this.chart.data.datasets.push({
label: "Niederschlag [mm]",
categoryPercentage: 1.0,
barPercentage: 1.0,
type: "bar",
yAxisID: "y_precipitation",
data: filtered.map(toPoint(h => h.precipitation)),
borderColor: PRECIPITATION_COLOR,
backgroundColor: PRECIPITATION_COLOR + '66',
borderWidth: 0,
pointStyle: "rect",
});
this.chart.data.datasets.push({
label: "Sonne [W/m²]",
type: "line",
fill: "origin",
yAxisID: "y_sun",
data: filtered.map(toPoint(h => h.irradiation)),
borderColor: SUN_COLOR,
backgroundColor: SUN_COLOR + '88',
borderWidth: 0,
pointRadius: 0,
});
this.chart.data.datasets.push({
label: "Temperatur [°C]",
type: "line",
yAxisID: "y_temperature",
data: filtered.map(toPoint(h => h.temperature)),
backgroundColor: TEMPERATURE_HIGH_COLOR + '66',
borderWidth: 0,
pointRadius: 1,
pointBackgroundColor: context => temperatureColor(context.parsed.y),
pointStyle: "point",
});
this.chart.data.datasets.push({
label: "Bewölkung [%]",
type: "line",
fill: "origin",
yAxisID: "y_clouds",
data: filtered.map(toPoint(h => h.clouds)),
borderColor: CLOUDS_COLOR,
backgroundColor: CLOUDS_COLOR + '88',
borderWidth: 0,
pointRadius: 0,
});
this.chart.update();
});
}
}

View File

@ -0,0 +1,22 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, Next, WebsocketService} from '../common';
import {WeatherHour} from './WeatherHour';
import {Location} from '../location/Location';
@Injectable({
providedIn: 'root'
})
export class WeatherService extends CrudService<WeatherHour> {
constructor(
api: ApiService,
ws: WebsocketService,
) {
super(api, ws, ['Weather'], WeatherHour.fromJson);
}
forLocation(location: Location, next: Next<WeatherHour[]>) {
this.getList(['forLocation', location.id], next);
}
}

View File

@ -71,6 +71,7 @@ div {
> .SectionHeading { > .SectionHeading {
display: flex; display: flex;
color: dimgray; color: dimgray;
font-size: 80%;
margin-top: -1.25em; margin-top: -1.25em;
> .SectionHeadingText { > .SectionHeadingText {

View File

@ -11,10 +11,6 @@ public class WeatherConfig {
private String urlPattern = "https://api.brightsky.dev/weather?date={date}&lat={latitude}&lon={longitude}&units=dwd"; private String urlPattern = "https://api.brightsky.dev/weather?date={date}&lat={latitude}&lon={longitude}&units=dwd";
private double latitude = 49.320789191091194;
private double longitude = 7.102111982262271;
private int pastDays = 9; private int pastDays = 9;
private int futureDays = 9; private int futureDays = 9;

View File

@ -3,6 +3,7 @@ package de.ph87.data.weather;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -16,9 +17,9 @@ public class WeatherController {
private final WeatherService weatherService; private final WeatherService weatherService;
@GetMapping("all") @GetMapping("forLocation/{id}")
public List<WeatherHour> all() { public List<WeatherHour> forLocation(@PathVariable final long id) {
return weatherService.all(); return weatherService.forLocation(id);
} }
} }

View File

@ -1,13 +1,17 @@
package de.ph87.data.weather; package de.ph87.data.weather;
import de.ph87.data.location.LocationDto;
import de.ph87.data.location.LocationRepository;
import lombok.NonNull; import lombok.NonNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import java.io.IOException; import java.io.IOException;
@ -20,7 +24,9 @@ import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
@Slf4j @Slf4j
@Service @Service
@ -28,43 +34,53 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class WeatherService { public class WeatherService {
private List<WeatherHour> hours = new ArrayList<>(); private final LocationRepository locationRepository;
private final Map<Long, List<WeatherHour>> locationHours = new HashMap<>();
private final WeatherConfig weatherConfig; private final WeatherConfig weatherConfig;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@NonNull @NonNull
public List<WeatherHour> all() { public List<WeatherHour> forLocation(final long id) {
final List<WeatherHour> hours = this.locationHours.get(id);
if (hours == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return new ArrayList<>(hours); return new ArrayList<>(hours);
} }
@Scheduled(cron = "0 0 * * * *") @Scheduled(cron = "0 0 * * * *")
@EventListener(ApplicationStartedEvent.class) @EventListener(ApplicationStartedEvent.class)
public void update() { public void updateAll() {
locationRepository.findAllDto().forEach(this::update);
}
private void update(@NonNull final LocationDto location) {
try { try {
final LocalDate today = LocalDate.now(); final LocalDate today = LocalDate.now();
final LocalDate first = today.minusDays(weatherConfig.getPastDays()); final LocalDate first = today.minusDays(weatherConfig.getPastDays());
final LocalDate end = today.plusDays(weatherConfig.getFutureDays()); final LocalDate end = today.plusDays(weatherConfig.getFutureDays());
final List<WeatherHour> hours = new ArrayList<>(); final List<WeatherHour> hours = new ArrayList<>();
log.debug("Updating Weather..."); log.debug("Updating Weather for Location: {}", location.name);
for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) { for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) {
fetchDay(day).getWeather().stream().map(WeatherHour::new).forEach(hours::add); fetchDay(location, day).getWeather().stream().map(WeatherHour::new).forEach(hours::add);
} }
this.hours = hours; this.locationHours.put(location.id, hours);
log.info("Weather update complete"); log.info("Weather update complete for Location: {}", location.name);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed fetching Weather data: {}", e.toString()); log.error("Failed fetching Weather data for Location: {}: {}", location.name, e.toString());
} }
} }
@NonNull @NonNull
public BrightSkyDto fetchDay(@NonNull final LocalDate day) throws IOException { public BrightSkyDto fetchDay(@NonNull final LocationDto location, @NonNull final LocalDate day) throws IOException {
final String url = weatherConfig final String url = weatherConfig
.getUrlPattern() .getUrlPattern()
.replace("{date}", ZonedDateTime.of(day, LocalTime.MIDNIGHT, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) .replace("{date}", ZonedDateTime.of(day, LocalTime.MIDNIGHT, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
.replace("{latitude}", weatherConfig.getLatitude() + "") .replace("{latitude}", location.getLatitude() + "")
.replace("{longitude}", weatherConfig.getLongitude() + ""); .replace("{longitude}", location.getLongitude() + "");
final HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); final HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection();
final int responseCode = connection.getResponseCode(); final int responseCode = connection.getResponseCode();
final byte[] bytes = connection.getInputStream().readAllBytes(); final byte[] bytes = connection.getInputStream().readAllBytes();