From 7e4b1d50d71dbc93478a6d47f4e0a909a5889208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Thu, 4 Dec 2025 13:04:08 +0100 Subject: [PATCH] Nice things --- CLAUDE.md | 10 +- .../fitpub/controller/ActivityController.java | 43 +++ .../fitpub/model/entity/WeatherData.java | 118 ++++++++ .../repository/WeatherDataRepository.java | 38 +++ .../fitpub/service/FitFileService.java | 9 + .../fitpub/service/WeatherService.java | 257 ++++++++++++++++++ src/main/resources/application.yml | 5 + .../V11__create_weather_data_table.sql | 28 ++ .../templates/activities/detail.html | 131 +++++++++ 9 files changed, 635 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/operaton/fitpub/model/entity/WeatherData.java create mode 100644 src/main/java/org/operaton/fitpub/repository/WeatherDataRepository.java create mode 100644 src/main/java/org/operaton/fitpub/service/WeatherService.java create mode 100644 src/main/resources/db/migration/V11__create_weather_data_table.sql diff --git a/CLAUDE.md b/CLAUDE.md index 3c86620..e1cfc9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -837,13 +837,15 @@ For ActivityPub federated posts and thumbnails: - [x] Database migration V10__create_analytics_tables.sql - [x] Integration with FitFileService (auto-update analytics on activity save) - [x] Security configuration updated (analytics routes and API endpoints) -- [ ] Weather data integration +- [x] Weather data integration (WeatherData entity, WeatherDataRepository, WeatherService with OpenWeatherMap API) +- [x] Weather database migration V11__create_weather_data_table.sql +- [x] Weather API configuration (fitpub.weather.enabled, fitpub.weather.api-key) +- [x] Weather fetching on activity upload (automatic for activities within 5 days) +- [x] Weather API endpoint GET /api/activities/{id}/weather +- [x] Weather display on activity detail page (temperature, humidity, wind, pressure, precipitation) ### Phase 4: Enhanced Federation - [ ] Rich preview cards for activities -- [ ] Media attachments (photos from workout) -- [ ] Activity challenges (federated events) -- [ ] Group/club support - [ ] Cross-platform activity sync ### Phase 5: Mobile & Integrations diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityController.java b/src/main/java/org/operaton/fitpub/controller/ActivityController.java index 0701e02..3732830 100644 --- a/src/main/java/org/operaton/fitpub/controller/ActivityController.java +++ b/src/main/java/org/operaton/fitpub/controller/ActivityController.java @@ -43,6 +43,7 @@ public class ActivityController { private final UserRepository userRepository; private final FederationService federationService; private final ActivityImageService activityImageService; + private final org.operaton.fitpub.service.WeatherService weatherService; @Value("${fitpub.base-url}") private String baseUrl; @@ -530,4 +531,46 @@ public class ActivityController { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } + + /** + * Get weather data for an activity. + * + * @param id the activity ID + * @return weather data or 404 if not found + */ + @GetMapping("/{id}/weather") + public ResponseEntity getActivityWeather(@PathVariable UUID id) { + try { + return weatherService.getWeatherForActivity(id) + .map(weatherData -> { + Map response = new HashMap<>(); + response.put("id", weatherData.getId()); + response.put("activityId", weatherData.getActivityId()); + response.put("temperatureCelsius", weatherData.getTemperatureCelsius()); + response.put("feelsLikeCelsius", weatherData.getFeelsLikeCelsius()); + response.put("humidity", weatherData.getHumidity()); + response.put("pressure", weatherData.getPressure()); + response.put("windSpeedMps", weatherData.getWindSpeedMps()); + response.put("windSpeedKmh", weatherData.getWindSpeedKmh()); + response.put("windDirection", weatherData.getWindDirection()); + response.put("windDirectionCardinal", weatherData.getWindDirectionCardinal()); + response.put("weatherCondition", weatherData.getWeatherCondition()); + response.put("weatherDescription", weatherData.getWeatherDescription()); + response.put("weatherIcon", weatherData.getWeatherIcon()); + response.put("weatherEmoji", weatherData.getWeatherEmoji()); + response.put("cloudiness", weatherData.getCloudiness()); + response.put("visibilityMeters", weatherData.getVisibilityMeters()); + response.put("precipitationMm", weatherData.getPrecipitationMm()); + response.put("snowMm", weatherData.getSnowMm()); + response.put("sunrise", weatherData.getSunrise()); + response.put("sunset", weatherData.getSunset()); + response.put("dataSource", weatherData.getDataSource()); + return ResponseEntity.ok(response); + }) + .orElse(ResponseEntity.notFound().build()); + } catch (Exception e) { + log.error("Error retrieving weather data for activity {}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } } diff --git a/src/main/java/org/operaton/fitpub/model/entity/WeatherData.java b/src/main/java/org/operaton/fitpub/model/entity/WeatherData.java new file mode 100644 index 0000000..5267572 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/WeatherData.java @@ -0,0 +1,118 @@ +package org.operaton.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entity representing weather data associated with an activity. + * Weather data is fetched from external APIs (e.g., OpenWeatherMap) based on + * activity location and timestamp. + */ +@Entity +@Table(name = "weather_data") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WeatherData { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "activity_id", nullable = false, unique = true) + private UUID activityId; + + @Column(name = "temperature_celsius", precision = 5, scale = 2) + private BigDecimal temperatureCelsius; + + @Column(name = "feels_like_celsius", precision = 5, scale = 2) + private BigDecimal feelsLikeCelsius; + + @Column(name = "humidity") + private Integer humidity; // percentage 0-100 + + @Column(name = "pressure") + private Integer pressure; // hPa + + @Column(name = "wind_speed_mps", precision = 5, scale = 2) + private BigDecimal windSpeedMps; // meters per second + + @Column(name = "wind_direction") + private Integer windDirection; // degrees 0-360 + + @Column(name = "weather_condition", length = 50) + private String weatherCondition; // e.g., "Clear", "Clouds", "Rain" + + @Column(name = "weather_description", length = 100) + private String weatherDescription; // e.g., "light rain", "overcast clouds" + + @Column(name = "weather_icon", length = 10) + private String weatherIcon; // OpenWeatherMap icon code (e.g., "10d") + + @Column(name = "cloudiness") + private Integer cloudiness; // percentage 0-100 + + @Column(name = "visibility_meters") + private Integer visibilityMeters; + + @Column(name = "precipitation_mm", precision = 6, scale = 2) + private BigDecimal precipitationMm; // rainfall/snowfall in mm + + @Column(name = "snow_mm", precision = 6, scale = 2) + private BigDecimal snowMm; // snowfall in mm (if applicable) + + @Column(name = "sunrise") + private LocalDateTime sunrise; + + @Column(name = "sunset") + private LocalDateTime sunset; + + @Column(name = "fetched_at", nullable = false) + private LocalDateTime fetchedAt = LocalDateTime.now(); + + @Column(name = "data_source", length = 50) + private String dataSource = "openweathermap"; + + /** + * Get wind direction as cardinal direction (N, NE, E, SE, S, SW, W, NW) + */ + public String getWindDirectionCardinal() { + if (windDirection == null) return null; + + String[] directions = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}; + int index = (int) Math.round(((windDirection % 360) / 45.0)) % 8; + return directions[index]; + } + + /** + * Get wind speed in km/h + */ + public BigDecimal getWindSpeedKmh() { + if (windSpeedMps == null) return null; + return windSpeedMps.multiply(BigDecimal.valueOf(3.6)); + } + + /** + * Get emoji representation of weather condition + */ + public String getWeatherEmoji() { + if (weatherCondition == null) return "🌡️"; + + return switch (weatherCondition.toLowerCase()) { + case "clear" -> "☀️"; + case "clouds" -> "☁️"; + case "rain" -> "🌧️"; + case "drizzle" -> "🌦️"; + case "thunderstorm" -> "⛈️"; + case "snow" -> "❄️"; + case "mist", "fog" -> "🌫️"; + default -> "🌡️"; + }; + } +} diff --git a/src/main/java/org/operaton/fitpub/repository/WeatherDataRepository.java b/src/main/java/org/operaton/fitpub/repository/WeatherDataRepository.java new file mode 100644 index 0000000..1a4fe52 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/WeatherDataRepository.java @@ -0,0 +1,38 @@ +package org.operaton.fitpub.repository; + +import org.operaton.fitpub.model.entity.WeatherData; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for accessing weather data. + */ +@Repository +public interface WeatherDataRepository extends JpaRepository { + + /** + * Find weather data for a specific activity. + * + * @param activityId the activity ID + * @return optional weather data + */ + Optional findByActivityId(UUID activityId); + + /** + * Check if weather data exists for an activity. + * + * @param activityId the activity ID + * @return true if weather data exists + */ + boolean existsByActivityId(UUID activityId); + + /** + * Delete weather data for an activity. + * + * @param activityId the activity ID + */ + void deleteByActivityId(UUID activityId); +} diff --git a/src/main/java/org/operaton/fitpub/service/FitFileService.java b/src/main/java/org/operaton/fitpub/service/FitFileService.java index a71c5b5..5c3bb4e 100644 --- a/src/main/java/org/operaton/fitpub/service/FitFileService.java +++ b/src/main/java/org/operaton/fitpub/service/FitFileService.java @@ -49,6 +49,7 @@ public class FitFileService { private final AchievementService achievementService; private final TrainingLoadService trainingLoadService; private final ActivitySummaryService activitySummaryService; + private final WeatherService weatherService; /** * Processes an uploaded FIT file and creates an activity. @@ -116,6 +117,14 @@ public class FitFileService { trainingLoadService.updateTrainingLoad(savedActivity); activitySummaryService.updateSummariesForActivity(savedActivity); + // Fetch weather data (async, non-blocking) + try { + weatherService.fetchWeatherForActivity(savedActivity); + } catch (Exception e) { + log.warn("Failed to fetch weather data for activity {}: {}", savedActivity.getId(), e.getMessage()); + // Don't fail the activity creation if weather fetching fails + } + return savedActivity; } catch (IOException e) { throw new FitFileProcessingException("Failed to read FIT file", e); diff --git a/src/main/java/org/operaton/fitpub/service/WeatherService.java b/src/main/java/org/operaton/fitpub/service/WeatherService.java new file mode 100644 index 0000000..206712d --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/WeatherService.java @@ -0,0 +1,257 @@ +package org.operaton.fitpub.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.WeatherData; +import org.operaton.fitpub.repository.WeatherDataRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import java.math.BigDecimal; +import java.net.URI; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; +import java.util.UUID; + +/** + * Service for fetching and managing weather data for activities. + * Uses OpenWeatherMap API to retrieve historical weather data. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class WeatherService { + + private final WeatherDataRepository weatherDataRepository; + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${fitpub.weather.api-key:}") + private String apiKey; + + @Value("${fitpub.weather.enabled:false}") + private boolean weatherEnabled; + + private static final String OPENWEATHERMAP_API_URL = "https://api.openweathermap.org/data/2.5/weather"; + private static final String OPENWEATHERMAP_TIMEMACHINE_URL = "https://api.openweathermap.org/data/3.0/onecall/timemachine"; + + /** + * Fetch and store weather data for an activity. + * Uses the activity's start location and timestamp to get weather conditions. + * + * @param activity the activity + * @return the weather data, or empty if fetching failed + */ + @Transactional + public Optional fetchWeatherForActivity(Activity activity) { + if (!weatherEnabled || apiKey == null || apiKey.isBlank()) { + log.debug("Weather fetching is disabled or API key is not configured"); + return Optional.empty(); + } + + // Check if weather data already exists + if (weatherDataRepository.existsByActivityId(activity.getId())) { + log.debug("Weather data already exists for activity {}", activity.getId()); + return weatherDataRepository.findByActivityId(activity.getId()); + } + + // Extract start location from track + if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) { + log.debug("No track points available for activity {}", activity.getId()); + return Optional.empty(); + } + + try { + // Get first track point for location + JsonNode trackPoints = objectMapper.readTree(activity.getTrackPointsJson()); + if (!trackPoints.isArray() || trackPoints.isEmpty()) { + return Optional.empty(); + } + + JsonNode firstPoint = trackPoints.get(0); + double lat = firstPoint.get("lat").asDouble(); + double lon = firstPoint.get("lon").asDouble(); + + // Check if activity is recent (within 5 days) - use current weather API + // Otherwise use historical data API (requires paid plan) + long activityTimestamp = activity.getStartedAt().atZone(ZoneId.systemDefault()).toEpochSecond(); + long currentTimestamp = Instant.now().getEpochSecond(); + long daysDifference = (currentTimestamp - activityTimestamp) / 86400; + + WeatherData weatherData; + if (daysDifference <= 5) { + weatherData = fetchCurrentWeather(lat, lon, activity.getId()); + } else { + log.debug("Activity is older than 5 days, historical weather data requires paid API plan"); + // For historical data, we would use the Time Machine API + // weatherData = fetchHistoricalWeather(lat, lon, activityTimestamp, activity.getId()); + return Optional.empty(); + } + + if (weatherData != null) { + return Optional.of(weatherDataRepository.save(weatherData)); + } + + } catch (Exception e) { + log.error("Error fetching weather data for activity {}: {}", activity.getId(), e.getMessage()); + } + + return Optional.empty(); + } + + /** + * Fetch current weather data from OpenWeatherMap. + */ + private WeatherData fetchCurrentWeather(double lat, double lon, UUID activityId) { + try { + String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric", + OPENWEATHERMAP_API_URL, lat, lon, apiKey); + + log.debug("Fetching current weather from: {}", url.replace(apiKey, "***")); + + String response = restTemplate.getForObject(URI.create(url), String.class); + if (response == null) { + return null; + } + + return parseWeatherResponse(response, activityId); + + } catch (Exception e) { + log.error("Error fetching current weather: {}", e.getMessage()); + return null; + } + } + + /** + * Parse OpenWeatherMap API response and create WeatherData entity. + */ + private WeatherData parseWeatherResponse(String response, UUID activityId) { + try { + JsonNode root = objectMapper.readTree(response); + + WeatherData weatherData = new WeatherData(); + weatherData.setActivityId(activityId); + + // Main temperature data + if (root.has("main")) { + JsonNode main = root.get("main"); + weatherData.setTemperatureCelsius(getBigDecimal(main, "temp")); + weatherData.setFeelsLikeCelsius(getBigDecimal(main, "feels_like")); + weatherData.setHumidity(getInteger(main, "humidity")); + weatherData.setPressure(getInteger(main, "pressure")); + } + + // Wind data + if (root.has("wind")) { + JsonNode wind = root.get("wind"); + weatherData.setWindSpeedMps(getBigDecimal(wind, "speed")); + weatherData.setWindDirection(getInteger(wind, "deg")); + } + + // Weather condition + if (root.has("weather") && root.get("weather").isArray() && !root.get("weather").isEmpty()) { + JsonNode weather = root.get("weather").get(0); + weatherData.setWeatherCondition(getString(weather, "main")); + weatherData.setWeatherDescription(getString(weather, "description")); + weatherData.setWeatherIcon(getString(weather, "icon")); + } + + // Clouds + if (root.has("clouds")) { + weatherData.setCloudiness(getInteger(root.get("clouds"), "all")); + } + + // Visibility + if (root.has("visibility")) { + weatherData.setVisibilityMeters(root.get("visibility").asInt()); + } + + // Rain + if (root.has("rain")) { + JsonNode rain = root.get("rain"); + if (rain.has("1h")) { + weatherData.setPrecipitationMm(BigDecimal.valueOf(rain.get("1h").asDouble())); + } + } + + // Snow + if (root.has("snow")) { + JsonNode snow = root.get("snow"); + if (snow.has("1h")) { + weatherData.setSnowMm(BigDecimal.valueOf(snow.get("1h").asDouble())); + } + } + + // Sun times + if (root.has("sys")) { + JsonNode sys = root.get("sys"); + if (sys.has("sunrise")) { + weatherData.setSunrise(LocalDateTime.ofInstant( + Instant.ofEpochSecond(sys.get("sunrise").asLong()), ZoneId.systemDefault())); + } + if (sys.has("sunset")) { + weatherData.setSunset(LocalDateTime.ofInstant( + Instant.ofEpochSecond(sys.get("sunset").asLong()), ZoneId.systemDefault())); + } + } + + weatherData.setFetchedAt(LocalDateTime.now()); + weatherData.setDataSource("openweathermap"); + + return weatherData; + + } catch (Exception e) { + log.error("Error parsing weather response: {}", e.getMessage()); + return null; + } + } + + /** + * Get weather data for an activity. + * + * @param activityId the activity ID + * @return optional weather data + */ + public Optional getWeatherForActivity(UUID activityId) { + return weatherDataRepository.findByActivityId(activityId); + } + + /** + * Delete weather data for an activity. + * + * @param activityId the activity ID + */ + @Transactional + public void deleteWeatherForActivity(UUID activityId) { + weatherDataRepository.deleteByActivityId(activityId); + } + + // Helper methods to safely extract values from JSON + private BigDecimal getBigDecimal(JsonNode node, String field) { + if (node.has(field)) { + return BigDecimal.valueOf(node.get(field).asDouble()); + } + return null; + } + + private Integer getInteger(JsonNode node, String field) { + if (node.has(field)) { + return node.get(field).asInt(); + } + return null; + } + + private String getString(JsonNode node, String field) { + if (node.has(field)) { + return node.get(field).asText(); + } + return null; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 77fe338..a992757 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -81,6 +81,11 @@ fitpub: osm-tiles: enabled: ${OSM_TILES_ENABLED:true} + # Weather API settings + weather: + enabled: ${WEATHER_ENABLED:false} + api-key: ${OPENWEATHERMAP_API_KEY:} + # Logging configuration logging: level: diff --git a/src/main/resources/db/migration/V11__create_weather_data_table.sql b/src/main/resources/db/migration/V11__create_weather_data_table.sql new file mode 100644 index 0000000..8fc4672 --- /dev/null +++ b/src/main/resources/db/migration/V11__create_weather_data_table.sql @@ -0,0 +1,28 @@ +-- Create weather_data table for storing weather conditions during activities +CREATE TABLE weather_data ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + activity_id UUID NOT NULL UNIQUE, + temperature_celsius DECIMAL(5, 2), + feels_like_celsius DECIMAL(5, 2), + humidity INTEGER, -- percentage 0-100 + pressure INTEGER, -- hPa + wind_speed_mps DECIMAL(5, 2), -- meters per second + wind_direction INTEGER, -- degrees 0-360 + weather_condition VARCHAR(50), -- e.g., "Clear", "Clouds", "Rain" + weather_description VARCHAR(100), -- e.g., "light rain", "overcast clouds" + weather_icon VARCHAR(10), -- OpenWeatherMap icon code + cloudiness INTEGER, -- percentage 0-100 + visibility_meters INTEGER, + precipitation_mm DECIMAL(6, 2), -- rainfall/snowfall in mm + snow_mm DECIMAL(6, 2), -- snowfall in mm (if applicable) + sunrise TIMESTAMP, + sunset TIMESTAMP, + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + data_source VARCHAR(50) DEFAULT 'openweathermap', + CONSTRAINT fk_weather_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE +); + +-- Indexes for efficient queries +CREATE INDEX idx_weather_activity_id ON weather_data(activity_id); +CREATE INDEX idx_weather_temperature ON weather_data(temperature_celsius); +CREATE INDEX idx_weather_condition ON weather_data(weather_condition); diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 0a8a8fe..4053695 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -77,6 +77,65 @@ + + +
@@ -369,6 +428,9 @@ renderMap(activity.simplifiedTrack); } + // Load weather data + loadWeatherData(activity.id); + // Render elevation chart if data exists if (activity.trackPoints && activity.trackPoints.length > 0) { const hasElevation = activity.trackPoints.some(p => p.elevation != null); @@ -420,6 +482,75 @@ } } + async function loadWeatherData(activityId) { + try { + const response = await fetch(`/api/activities/${activityId}/weather`); + if (response.ok) { + const weather = await response.json(); + displayWeather(weather); + } else if (response.status === 404) { + // No weather data available, hide section + console.debug('No weather data available for activity'); + } + } catch (error) { + console.error('Error loading weather data:', error); + // Silently fail - weather is optional + } + } + + function displayWeather(weather) { + // Show weather section + document.getElementById('weatherSection').style.display = 'block'; + + // Display emoji and condition + document.getElementById('weatherEmoji').textContent = weather.weatherEmoji || '🌡️'; + document.getElementById('weatherCondition').textContent = weather.weatherDescription || weather.weatherCondition || '--'; + + // Temperature + if (weather.temperatureCelsius != null) { + document.getElementById('weatherTemp').textContent = Math.round(weather.temperatureCelsius) + '°C'; + } + + // Feels like + if (weather.feelsLikeCelsius != null) { + document.getElementById('weatherFeelsLike').textContent = Math.round(weather.feelsLikeCelsius) + '°C'; + } + + // Humidity + if (weather.humidity != null) { + document.getElementById('weatherHumidity').textContent = weather.humidity + '%'; + } + + // Wind + if (weather.windSpeedKmh != null) { + const windText = Math.round(weather.windSpeedKmh) + ' km/h'; + const direction = weather.windDirectionCardinal ? ' ' + weather.windDirectionCardinal : ''; + document.getElementById('weatherWind').textContent = windText + direction; + } + + // Pressure + if (weather.pressure != null) { + document.getElementById('weatherPressure').textContent = weather.pressure + ' hPa'; + } + + // Visibility + if (weather.visibilityMeters != null) { + const visibilityKm = (weather.visibilityMeters / 1000).toFixed(1); + document.getElementById('weatherVisibility').textContent = visibilityKm + ' km'; + } + + // Cloudiness + if (weather.cloudiness != null) { + document.getElementById('weatherCloudiness').textContent = weather.cloudiness + '%'; + } + + // Precipitation + if (weather.precipitationMm != null && weather.precipitationMm > 0) { + document.getElementById('weatherPrecipSection').style.display = 'block'; + document.getElementById('weatherPrecip').textContent = weather.precipitationMm.toFixed(1) + ' mm'; + } + } + function renderMap(simplifiedTrack) { // Parse GeoJSON from simplifiedTrack const geoJson = {