From 4af2f433bd8a194a9e1d1d3954deac772077e558 Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 30 Jul 2026 10:36:43 +0300 Subject: [PATCH 01/37] feat(map): implement OpenWeatherMap tile API client and tile layer enum --- .../service/integration/MapTileLayer.java | 42 ++++++ .../integration/WeatherTileClient.java | 88 ++++++++++++ .../service/integration/MapTileLayerTest.java | 43 ++++++ .../integration/WeatherTileClientTest.java | 128 ++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 src/main/java/com/weatherviewer/service/integration/MapTileLayer.java create mode 100644 src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java create mode 100644 src/test/java/com/weatherviewer/service/integration/MapTileLayerTest.java create mode 100644 src/test/java/com/weatherviewer/service/integration/WeatherTileClientTest.java diff --git a/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java b/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java new file mode 100644 index 0000000..236f1f1 --- /dev/null +++ b/src/main/java/com/weatherviewer/service/integration/MapTileLayer.java @@ -0,0 +1,42 @@ +package com.weatherviewer.service.integration; + +import com.weatherviewer.rest.MapTileController; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Optional; + +/** + * Whitelist of OpenWeatherMap map-tile overlays exposed via + * {@link MapTileController} for the + * {@code /map} page. + *

+ * The enum name (lowercased) is what {@code map.html} requests in the tile + * URL path; {@link #getOwmCode()} is OpenWeatherMap's own layer identifier, + * used only when building the upstream request. Resolving through this + * whitelist — rather than passing whatever path segment the client sent + * straight through to OpenWeatherMap — keeps the proxy from being usable + * to reach arbitrary OpenWeatherMap tile endpoints. + */ +@Getter +public enum MapTileLayer { + + PRECIPITATION("precipitation_new"), + CLOUDS("clouds_new"), + TEMPERATURE("temp_new"), + WIND("wind_new"); + + private final String owmCode; + + MapTileLayer(String owmCode) { + this.owmCode = owmCode; + } + + /** Resolves a request-path layer segment (case-insensitive) to a whitelisted layer, or empty if it doesn't match one. */ + public static Optional fromRequestValue(String value) { + return Arrays.stream(values()) + .filter(layer -> layer.name().equalsIgnoreCase(value)) + .findFirst(); + } + +} diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java b/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java new file mode 100644 index 0000000..2413e9e --- /dev/null +++ b/src/main/java/com/weatherviewer/service/integration/WeatherTileClient.java @@ -0,0 +1,88 @@ +package com.weatherviewer.service.integration; + +import com.weatherviewer.exception.ExternalHttpCallException; +import com.weatherviewer.rest.MapTileController; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.regex.Pattern; + +/** + * Thin HTTP client for OpenWeatherMap's map-tile endpoints (precipitation, + * clouds, temperature, and wind overlays for the {@code /map} page), used + * by {@link MapTileController}. + *

+ * Deliberately kept separate from {@link WeatherApiClient}: tiles are a + * different traffic shape (a single pan/zoom can request dozens at once), + * non-critical (a missing tile just doesn't render, unlike a failed + * weather lookup), and cacheable for a long time — not worth a circuit + * breaker of its own, so this only gets a lightweight retry plus a cache. + *

+ * Unlike {@link WeatherApiClient}/{@link WeatherApiCache}, retry and + * caching live on the same method here rather than being split across two + * classes — safe because both are still applied via an external Spring + * proxy call (from {@code MapTileController}), so there's no self-invocation + * concern, and one client/cache method is simple enough not to need the + * extra separation. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class WeatherTileClient { + + private static final Pattern APPID_PATTERN = Pattern.compile("(?i)([?&]appid=)[^&]*"); + + private final RestClient restClient; + + @Value("${weather.api.key}") + private String apiKey; + + @Value("${weather.tile.base.url}") + private String tileBaseUrl; + + /** + * Fetches a single PNG tile for the given layer/zoom/coordinates. + * Cached by {@code mapTileCache} (see {@code application.properties} + * for its TTL) since the same tile is requested repeatedly across + * users and across pans/zooms that revisit the same area. + */ + @Retry(name = "mapTile") + @Cacheable("mapTileCache") + public byte[] fetchTile(MapTileLayer layer, int z, int x, int y) { + String url = UriComponentsBuilder + .fromUri(URI.create(tileBaseUrl + "/" + layer.getOwmCode() + "/" + z + "/" + x + "/" + y + ".png")) + .queryParam("appid", apiKey) + .build() + .toUriString(); + + try { + byte[] tile = restClient.get() + .uri(URI.create(url)) + .retrieve() + .body(byte[].class); + log.debug("Fetched map tile layer={} z={} x={} y={}", layer, z, x, y); + return tile; + } catch (RestClientResponseException e) { + log.warn("Map tile API returned {} for URL: {}", e.getStatusCode(), maskApiKey(url)); + boolean retryable = e.getStatusCode() == null || !e.getStatusCode().is4xxClientError(); + throw new ExternalHttpCallException("Map tile API error: " + e.getStatusCode(), retryable); + } catch (Exception e) { + log.warn("Map tile fetch failed due to network or connection issues for URL: {}", maskApiKey(url)); + throw new ExternalHttpCallException("Map tile fetch failed due to network or connection issues"); + } + } + + /** Masks the {@code appid} query parameter so the API key never reaches application logs. */ + private static String maskApiKey(String url) { + return APPID_PATTERN.matcher(url).replaceAll("$1***"); + } + +} diff --git a/src/test/java/com/weatherviewer/service/integration/MapTileLayerTest.java b/src/test/java/com/weatherviewer/service/integration/MapTileLayerTest.java new file mode 100644 index 0000000..c3ed5aa --- /dev/null +++ b/src/test/java/com/weatherviewer/service/integration/MapTileLayerTest.java @@ -0,0 +1,43 @@ +package com.weatherviewer.service.integration; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class MapTileLayerTest { + + @Test + void fromRequestValue_knownLayer_resolves() { + assertThat(MapTileLayer.fromRequestValue("precipitation")).contains(MapTileLayer.PRECIPITATION); + assertThat(MapTileLayer.fromRequestValue("clouds")).contains(MapTileLayer.CLOUDS); + assertThat(MapTileLayer.fromRequestValue("temperature")).contains(MapTileLayer.TEMPERATURE); + assertThat(MapTileLayer.fromRequestValue("wind")).contains(MapTileLayer.WIND); + } + + @Test + void fromRequestValue_isCaseInsensitive() { + assertThat(MapTileLayer.fromRequestValue("PreCIPitation")).contains(MapTileLayer.PRECIPITATION); + } + + @Test + void fromRequestValue_unknownLayer_returnsEmpty() { + Optional result = MapTileLayer.fromRequestValue("radar"); + assertThat(result).isEmpty(); + } + + @Test + void fromRequestValue_rejectsOwmCodeDirectly() { + Optional result = MapTileLayer.fromRequestValue("precipitation_new"); + assertThat(result).isEmpty(); + } + + @Test + void everyLayerHasAnOwmCode() { + for (MapTileLayer layer : MapTileLayer.values()) { + assertThat(layer.getOwmCode()).isNotBlank(); + } + } + +} diff --git a/src/test/java/com/weatherviewer/service/integration/WeatherTileClientTest.java b/src/test/java/com/weatherviewer/service/integration/WeatherTileClientTest.java new file mode 100644 index 0000000..9db733b --- /dev/null +++ b/src/test/java/com/weatherviewer/service/integration/WeatherTileClientTest.java @@ -0,0 +1,128 @@ +package com.weatherviewer.service.integration; + +import com.weatherviewer.exception.ExternalHttpCallException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatusCode; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WeatherTileClientTest { + + private static final String SECRET_API_KEY = "test-api-key-super-secret"; + + @Mock + private RestClient restClient; + + @InjectMocks + private WeatherTileClient client; + + @Mock + private RestClient.RequestHeadersUriSpec requestHeadersUriSpec; + + @Mock + private RestClient.RequestHeadersSpec requestHeadersSpec; + + @Mock + private RestClient.ResponseSpec responseSpec; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(client, "apiKey", SECRET_API_KEY); + ReflectionTestUtils.setField(client, "tileBaseUrl", "https://tile.openweathermap.org/map"); + + lenient().doReturn(requestHeadersUriSpec).when(restClient).get(); + lenient().doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri(any(URI.class)); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + } + + @Test + void fetchTile_returnsBytesFromClient() { + byte[] pngBytes = new byte[]{1, 2, 3, 4}; + when(responseSpec.body(byte[].class)).thenReturn(pngBytes); + + byte[] result = client.fetchTile(MapTileLayer.CLOUDS, 5, 10, 12); + + assertThat(result).isEqualTo(pngBytes); + } + + @Test + void fetchTile_buildsUrlWithOwmCodeAndAppid() { + when(responseSpec.body(byte[].class)).thenReturn(new byte[]{1}); + + client.fetchTile(MapTileLayer.PRECIPITATION, 5, 10, 12); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(requestHeadersUriSpec).uri(uriCaptor.capture()); + String uri = uriCaptor.getValue().toString(); + + assertThat(uri).startsWith("https://tile.openweathermap.org/map/precipitation_new/5/10/12.png"); + assertThat(uri).contains("appid=" + SECRET_API_KEY); + } + + @Test + void fetchTile_apiKeyNeverAppearsInThrownExceptionMessage() { + RestClientResponseException upstreamException = mock(RestClientResponseException.class); + when(upstreamException.getStatusCode()).thenReturn(HttpStatusCode.valueOf(500)); + when(responseSpec.body(byte[].class)).thenThrow(upstreamException); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.WIND, 1, 1, 1)) + .isInstanceOf(ExternalHttpCallException.class) + .hasMessageNotContaining(SECRET_API_KEY); + } + + @Test + void fetchTile_upstream5xx_isRetryable() { + RestClientResponseException upstreamException = mock(RestClientResponseException.class); + when(upstreamException.getStatusCode()).thenReturn(HttpStatusCode.valueOf(503)); + when(responseSpec.body(byte[].class)).thenThrow(upstreamException); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.WIND, 1, 1, 1)) + .isInstanceOfSatisfying(ExternalHttpCallException.class, + ex -> assertThat(ex.isRetryable()).isTrue()); + } + + @Test + void fetchTile_upstream4xx_isNotRetryable() { + RestClientResponseException upstreamException = mock(RestClientResponseException.class); + when(upstreamException.getStatusCode()).thenReturn(HttpStatusCode.valueOf(404)); + when(responseSpec.body(byte[].class)).thenThrow(upstreamException); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.WIND, 1, 1, 1)) + .isInstanceOfSatisfying(ExternalHttpCallException.class, + ex -> assertThat(ex.isRetryable()).isFalse()); + } + + @Test + void fetchTile_upstreamStatusCodeNull_isRetryable() { + RestClientResponseException upstreamException = mock(RestClientResponseException.class); + when(upstreamException.getStatusCode()).thenReturn(null); + when(responseSpec.body(byte[].class)).thenThrow(upstreamException); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.WIND, 1, 1, 1)) + .isInstanceOfSatisfying(ExternalHttpCallException.class, + ex -> assertThat(ex.isRetryable()).isTrue()); + } + + @Test + void fetchTile_networkFailure_wrappedAsExternalHttpCallException() { + when(responseSpec.body(byte[].class)).thenThrow(new RuntimeException("connection reset")); + + assertThatThrownBy(() -> client.fetchTile(MapTileLayer.TEMPERATURE, 2, 2, 2)) + .isInstanceOf(ExternalHttpCallException.class); + } + +} From 294754bf9d664c16a2227d5f3be4d34e1c5fde76 Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 30 Jul 2026 10:37:50 +0300 Subject: [PATCH 02/37] feat(map): expose REST endpoints for proxying weather map tile layers --- .../weatherviewer/config/SecurityConfig.java | 6 +- .../weatherviewer/rest/MapTileController.java | 53 ++++++++++++++ .../rest/MapTileControllerTest.java | 72 +++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/weatherviewer/rest/MapTileController.java create mode 100644 src/test/java/com/weatherviewer/rest/MapTileControllerTest.java diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index 39530b6..f526b76 100644 --- a/src/main/java/com/weatherviewer/config/SecurityConfig.java +++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java @@ -121,10 +121,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .contentSecurityPolicy(csp -> csp .policyDirectives( "default-src 'self'; " + - "script-src 'self' https://cdn.jsdelivr.net; " + - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + + "script-src 'self' https://cdn.jsdelivr.net https://unpkg.com; " + + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://use.fontawesome.com https://cdnjs.cloudflare.com; " + - "connect-src 'self' https://cdn.jsdelivr.net; " + + "connect-src 'self' https://cdn.jsdelivr.net https://unpkg.com; " + "img-src 'self' data: https:;" ) ) diff --git a/src/main/java/com/weatherviewer/rest/MapTileController.java b/src/main/java/com/weatherviewer/rest/MapTileController.java new file mode 100644 index 0000000..6205866 --- /dev/null +++ b/src/main/java/com/weatherviewer/rest/MapTileController.java @@ -0,0 +1,53 @@ +package com.weatherviewer.rest; + +import com.weatherviewer.service.integration.MapTileLayer; +import com.weatherviewer.service.integration.WeatherTileClient; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.Optional; + +/** + * Proxies OpenWeatherMap's map-tile endpoints for the {@code /map} page. + *

+ * Without this, {@code map.html} would have to request tiles directly + * from OpenWeatherMap in the browser, which means putting the API key + * (OWM's tile API is authenticated via an {@code appid} query parameter, + * same as every other OWM endpoint this app calls) directly into a URL + * visible in the page's network requests. Routing through here instead + * keeps the key server-side, same as every other OpenWeatherMap call in + * this application. + *

+ * {@code layer} is resolved against {@link MapTileLayer}'s whitelist + * before anything is fetched — an unrecognized value is rejected as a bad + * request rather than passed through to OpenWeatherMap. + */ +@RestController +@RequiredArgsConstructor +public class MapTileController { + + private final WeatherTileClient weatherTileClient; + + @GetMapping(value = "/map/tiles/{layer}/{z}/{x}/{y}", produces = MediaType.IMAGE_PNG_VALUE) + public ResponseEntity tile(@PathVariable String layer, + @PathVariable int z, + @PathVariable int x, + @PathVariable int y) { + Optional resolvedLayer = MapTileLayer.fromRequestValue(layer); + if (resolvedLayer.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + byte[] tile = weatherTileClient.fetchTile(resolvedLayer.get(), z, x, y); + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic()) + .body(tile); + } + +} diff --git a/src/test/java/com/weatherviewer/rest/MapTileControllerTest.java b/src/test/java/com/weatherviewer/rest/MapTileControllerTest.java new file mode 100644 index 0000000..5a73dd9 --- /dev/null +++ b/src/test/java/com/weatherviewer/rest/MapTileControllerTest.java @@ -0,0 +1,72 @@ +package com.weatherviewer.rest; + +import com.weatherviewer.model.enums.UnitSystem; +import com.weatherviewer.security.SecUser; +import com.weatherviewer.service.integration.MapTileLayer; +import com.weatherviewer.service.integration.WeatherTileClient; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Set; +import java.util.UUID; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(MapTileController.class) +class MapTileControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + WeatherTileClient weatherTileClient; + + private SecUser secUser() { + return new SecUser( + UUID.randomUUID(), "john@example.com", "hashed", Set.of(), + true, "John Doe", UnitSystem.METRIC, null + ); + } + + @Test + void tile_knownLayer_returnsPngWithCacheHeaders() throws Exception { + byte[] fakePng = new byte[]{1, 2, 3}; + when(weatherTileClient.fetchTile(MapTileLayer.PRECIPITATION, 5, 10, 12)).thenReturn(fakePng); + + mockMvc.perform(get("/map/tiles/precipitation/5/10/12").with(user(secUser()))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.IMAGE_PNG)) + .andExpect(content().bytes(fakePng)) + .andExpect(header().exists("Cache-Control")); + } + + @Test + void tile_layerNameIsCaseInsensitive() throws Exception { + when(weatherTileClient.fetchTile(MapTileLayer.CLOUDS, 3, 1, 1)).thenReturn(new byte[]{9}); + + mockMvc.perform(get("/map/tiles/CLOUDS/3/1/1").with(user(secUser()))) + .andExpect(status().isOk()); + } + + @Test + void tile_unknownLayer_returns400AndNeverCallsClient() throws Exception { + mockMvc.perform(get("/map/tiles/not-a-real-layer/5/10/12").with(user(secUser()))) + .andExpect(status().isBadRequest()); + + verify(weatherTileClient, never()).fetchTile(eq(MapTileLayer.PRECIPITATION), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + void tile_nonNumericCoordinate_returns400() throws Exception { + mockMvc.perform(get("/map/tiles/precipitation/abc/10/12").with(user(secUser()))) + .andExpect(status().isBadRequest()); + } + +} From d1956d76bdfe94deb6879d2567a5e98fe3393859 Mon Sep 17 00:00:00 2001 From: podlLev Date: Thu, 30 Jul 2026 10:38:17 +0300 Subject: [PATCH 03/37] feat(map): add MapController and i18n properties for map page --- .../controller/MapController.java | 85 +++++++++++++++ src/main/resources/application.properties | 9 ++ src/main/resources/messages.properties | 16 +++ src/main/resources/messages_uk.properties | 16 +++ .../controller/MapControllerTest.java | 100 ++++++++++++++++++ src/test/resources/application.properties | 1 + 6 files changed, 227 insertions(+) create mode 100644 src/main/java/com/weatherviewer/controller/MapController.java create mode 100644 src/test/java/com/weatherviewer/controller/MapControllerTest.java diff --git a/src/main/java/com/weatherviewer/controller/MapController.java b/src/main/java/com/weatherviewer/controller/MapController.java new file mode 100644 index 0000000..40baf20 --- /dev/null +++ b/src/main/java/com/weatherviewer/controller/MapController.java @@ -0,0 +1,85 @@ +package com.weatherviewer.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.weatherviewer.dto.LocationDto; +import com.weatherviewer.rest.MapTileController; +import com.weatherviewer.security.SecUser; +import com.weatherviewer.service.LocationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Renders the {@code /map} page: a Leaflet world map with a marker for + * each of the current user's saved locations, and a toggleable + * OpenWeatherMap overlay (precipitation/clouds/temperature/wind) proxied + * through {@link MapTileController} so the OpenWeatherMap API key never + * reaches the browser. + */ +@Controller +@RequiredArgsConstructor +@Slf4j +public class MapController { + + private final LocationService locationService; + private final MessageSource messageSource; + private final ObjectMapper objectMapper; + + @GetMapping("/map") + public String map(Model model, @AuthenticationPrincipal SecUser user) { + List locations = locationService.getByUserId(user.getId()); + log.info("Map page requested by user '{}', {} saved location(s)", user.getUsername(), locations.size()); + + model.addAttribute("login", user.getFullName()); + model.addAttribute("locations", locations); + model.addAttribute("mapDataJson", buildMapDataJson(locations)); + return "map"; + } + + /** + * Serializes the data {@code map-init.js} needs (saved locations plus + * translated overlay labels) into a single JSON string, built and + * escaped entirely server-side rather than via Thymeleaf's + * JavaScript-inlining ({@code /*[[...]]*}{@code /}) syntax. That syntax + * relies on comment markers which are only ever stripped for elements + * Thymeleaf recognizes as JavaScript; since {@code map-data} is + * {@code type="application/json"} (deliberately, so it's inert data + * rather than a script the CSP {@code script-src} directive would need + * to allow), nothing guarantees those markers get removed, and raw + * {@code /* ... *}{@code /} comments aren't valid JSON regardless. This + * produces plain, comment-free JSON up front instead. + */ + private String buildMapDataJson(List locations) { + Map layerLabels = new LinkedHashMap<>(); + layerLabels.put("precipitation", messageSource.getMessage("map.layer.precipitation", null, LocaleContextHolder.getLocale())); + layerLabels.put("clouds", messageSource.getMessage("map.layer.clouds", null, LocaleContextHolder.getLocale())); + layerLabels.put("temperature", messageSource.getMessage("map.layer.temperature", null, LocaleContextHolder.getLocale())); + layerLabels.put("wind", messageSource.getMessage("map.layer.wind", null, LocaleContextHolder.getLocale())); + + Map labels = new LinkedHashMap<>(); + labels.put("forecast", messageSource.getMessage("map.popup.forecast", null, LocaleContextHolder.getLocale())); + + Map data = new LinkedHashMap<>(); + data.put("locations", locations); + data.put("layerLabels", layerLabels); + data.put("labels", labels); + data.put("forecastUrl", "/forecast"); + + try { + return objectMapper.writeValueAsString(data).replace(" Date: Thu, 30 Jul 2026 10:38:34 +0300 Subject: [PATCH 04/37] feat(ui): add Leaflet map view, tile overlay switcher, and navigation link --- .../controller/SearchController.java | 29 +++-- src/main/resources/static/css/theme.css | 36 ++++++ src/main/resources/static/js/map-init.js | 109 ++++++++++++++++ .../resources/templates/header-component.html | 4 + src/main/resources/templates/map.html | 121 ++++++++++++++++++ 5 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/static/js/map-init.js create mode 100644 src/main/resources/templates/map.html diff --git a/src/main/java/com/weatherviewer/controller/SearchController.java b/src/main/java/com/weatherviewer/controller/SearchController.java index 1c4fc5d..356d396 100644 --- a/src/main/java/com/weatherviewer/controller/SearchController.java +++ b/src/main/java/com/weatherviewer/controller/SearchController.java @@ -5,6 +5,7 @@ import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; import com.weatherviewer.service.WeatherApiService; +import com.weatherviewer.utils.SafeRedirectUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -55,19 +56,29 @@ public String searchResults(@RequestParam("q") String query, Model model, } /** - * Saves a chosen search result as a new location for the signed-in - * user. The submitted {@code userId} is always overwritten with the - * caller's own ID before validation, so a location can only ever be - * added to the signer's own account. On validation failure (blank - * name, out-of-range coordinates, or a duplicate name/coordinates for - * this user), the errors are flashed and the request redirects back to - * the dashboard without saving anything. + * Saves a chosen search result (or a point picked directly on the + * {@code /map} page) as a new location for the signed-in user. The + * submitted {@code userId} is always overwritten with the caller's own + * ID before validation, so a location can only ever be added to the + * signer's own account. On validation failure (blank name, + * out-of-range coordinates, or a duplicate name/coordinates for this + * user), the errors are flashed and the request redirects back without + * saving anything. + *

+ * {@code redirectTo} lets a caller (e.g. the map page's "add location" + * form) choose where the request lands afterward instead of always + * going to the dashboard; it's validated by + * {@link SafeRedirectUtils} and falls back to {@code /} if missing or + * unsafe, so it can't be used to redirect off-site. */ @PostMapping("/search/add") public String addLocation(@ModelAttribute("addLocation") AddLocationDto addLocationDto, + @RequestParam(value = "redirectTo", required = false) String redirectTo, @AuthenticationPrincipal SecUser secUser, BindingResult bindingResult, RedirectAttributes redirectAttributes) { + String target = SafeRedirectUtils.sanitize(redirectTo, "/"); + addLocationDto.setUserId(secUser.getId()); validator.validate(addLocationDto, bindingResult); @@ -78,13 +89,13 @@ public String addLocation(@ModelAttribute("addLocation") AddLocationDto addLocat log.info("Failed to add location for user '{}': {}", secUser.getUsername(), errorMessages); redirectAttributes.addFlashAttribute("errorMessages", errorMessages); - return "redirect:/"; + return "redirect:" + target; } locationService.add(addLocationDto); log.info("Location '{}' added successfully for user '{}'", addLocationDto.getName(), secUser.getUsername()); redirectAttributes.addFlashAttribute("successMessage", "Location added successfully!"); - return "redirect:/"; + return "redirect:" + target; } } diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css index dfa4004..a91bd4c 100644 --- a/src/main/resources/static/css/theme.css +++ b/src/main/resources/static/css/theme.css @@ -46,6 +46,42 @@ body { min-height: 650px; } +/* Leaflet map controls (map.html) — Leaflet's chrome is plain CSS/PNG + with no awareness of data-bs-theme, so it's re-skinned here to match + the app's dark mode instead of sitting as a white panel over the dark + basemap that map-init.js switches to. */ +[data-bs-theme="dark"] .leaflet-control-layers, +[data-bs-theme="dark"] .leaflet-bar, +[data-bs-theme="dark"] .leaflet-popup-content-wrapper, +[data-bs-theme="dark"] .leaflet-popup-tip { + background-color: #2b3035; + color: #dee2e6; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.65); +} + +[data-bs-theme="dark"] .leaflet-bar a { + background-color: #2b3035; + color: #dee2e6; + border-bottom-color: #495057; +} + +[data-bs-theme="dark"] .leaflet-bar a:hover { + background-color: #343a40; +} + +[data-bs-theme="dark"] .leaflet-control-layers-toggle { + filter: invert(1) brightness(1.6); +} + +[data-bs-theme="dark"] .leaflet-control-attribution { + background-color: rgba(43, 48, 53, 0.85); + color: #adb5bd; +} + +[data-bs-theme="dark"] .leaflet-control-attribution a { + color: #9ec5fe; +} + /* Theme toggle button in the header */ .theme-toggle-btn { border: none; diff --git a/src/main/resources/static/js/map-init.js b/src/main/resources/static/js/map-init.js new file mode 100644 index 0000000..90d4a00 --- /dev/null +++ b/src/main/resources/static/js/map-init.js @@ -0,0 +1,109 @@ +(function () { + /* global L */ + + var dataEl = document.getElementById('map-data'); + if (!dataEl) return; + + var data = JSON.parse(dataEl.textContent); + var locations = data.locations || []; + var layerLabels = data.layerLabels || {}; + var labels = data.labels || {}; + var forecastUrl = data.forecastUrl || '/forecast'; + + var DEFAULT_CENTER = [20, 0]; + var DEFAULT_ZOOM = 2; + + // CARTO's free basemaps (built on OSM data) look considerably nicer + // than the stock OSM tiles and, unlike them, come in a matching + // light/dark pair so the map can follow the app's own theme toggle. + var BASEMAPS = { + light: { + url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + attribution: '© OpenStreetMap contributors © CARTO' + }, + dark: { + url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + attribution: '© OpenStreetMap contributors © CARTO' + } + }; + + function currentTheme() { + return document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light'; + } + + function escapeHtml(value) { + var entities = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return String(value).replace(/[&<>"']/g, function (ch) { + return entities[ch]; + }); + } + + function markerIcon() { + return L.divIcon({ + className: 'weather-map-marker', + html: '', + iconSize: [30, 30], + iconAnchor: [15, 30], + popupAnchor: [0, -28] + }); + } + + function popupHtml(location) { + var href = forecastUrl + '?lat=' + encodeURIComponent(location.latitude) + '&lon=' + encodeURIComponent(location.longitude); + return '

' + + '
' + escapeHtml(location.name) + '
' + + '' + + ' ' + escapeHtml(labels.forecast || 'View forecast') + + '
'; + } + + var MIN_ZOOM = 2; + + var map = L.map('weather-map', { zoomControl: false, minZoom: MIN_ZOOM }).setView(DEFAULT_CENTER, DEFAULT_ZOOM); + L.control.zoom({ position: 'topleft' }).addTo(map); + L.control.scale({ imperial: true, metric: true, position: 'bottomleft' }).addTo(map); + + var baseLayer = L.tileLayer(BASEMAPS[currentTheme()].url, { + maxZoom: 20, + subdomains: 'abcd', + attribution: BASEMAPS[currentTheme()].attribution + }).addTo(map); + + var themeObserver = new MutationObserver(function () { + var theme = currentTheme(); + var next = BASEMAPS[theme]; + map.removeLayer(baseLayer); + baseLayer = L.tileLayer(next.url, { + maxZoom: 20, + subdomains: 'abcd', + attribution: next.attribution + }).addTo(map); + baseLayer.bringToBack(); + }); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-bs-theme'] }); + + var overlayOptions = { opacity: 0.55, maxZoom: 18 }; + var overlays = {}; + overlays[layerLabels.precipitation] = L.tileLayer('/map/tiles/precipitation/{z}/{x}/{y}', overlayOptions); + overlays[layerLabels.clouds] = L.tileLayer('/map/tiles/clouds/{z}/{x}/{y}', overlayOptions); + overlays[layerLabels.temperature] = L.tileLayer('/map/tiles/temperature/{z}/{x}/{y}', overlayOptions); + overlays[layerLabels.wind] = L.tileLayer('/map/tiles/wind/{z}/{x}/{y}', overlayOptions); + + L.control.layers(null, overlays, { collapsed: false, position: 'topright' }).addTo(map); + + var markers = []; + locations.forEach(function (location) { + if (location.latitude == null || location.longitude == null) { + return; + } + var marker = L.marker([location.latitude, location.longitude], { icon: markerIcon() }) + .addTo(map) + .bindPopup(popupHtml(location)); + markers.push(marker); + }); + + if (markers.length > 0) { + var group = L.featureGroup(markers); + map.fitBounds(group.getBounds().pad(0.3)); + } +})(); \ No newline at end of file diff --git a/src/main/resources/templates/header-component.html b/src/main/resources/templates/header-component.html index 635e0d3..81fedcb 100644 --- a/src/main/resources/templates/header-component.html +++ b/src/main/resources/templates/header-component.html @@ -11,6 +11,10 @@

Weather Viewer

+ + + + + + +
+ + +
+ + + + + + + + + + + + \ No newline at end of file From 4db9506b1e637ad39ee49c96a4f3ce9ebf0c5fc9 Mon Sep 17 00:00:00 2001 From: podlLev Date: Fri, 31 Jul 2026 09:52:49 +0300 Subject: [PATCH 05/37] fix(map): inject CSRF token into map JSON data model --- .../controller/MapController.java | 39 +++++++++--------- .../controller/MapControllerTest.java | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/weatherviewer/controller/MapController.java b/src/main/java/com/weatherviewer/controller/MapController.java index 40baf20..e35307f 100644 --- a/src/main/java/com/weatherviewer/controller/MapController.java +++ b/src/main/java/com/weatherviewer/controller/MapController.java @@ -5,11 +5,13 @@ import com.weatherviewer.rest.MapTileController; import com.weatherviewer.security.SecUser; import com.weatherviewer.service.LocationService; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -20,10 +22,12 @@ /** * Renders the {@code /map} page: a Leaflet world map with a marker for - * each of the current user's saved locations, and a toggleable - * OpenWeatherMap overlay (precipitation/clouds/temperature/wind) proxied - * through {@link MapTileController} so the OpenWeatherMap API key never - * reaches the browser. + * each of the current user's saved locations, a toggleable OpenWeatherMap + * overlay (precipitation/clouds/temperature/wind) proxied through + * {@link MapTileController} so the OpenWeatherMap API key never reaches + * the browser, and a click-to-add flow that lets the user save a new + * location by clicking a spot on the map (submitted to the existing + * {@code /search/add} endpoint). */ @Controller @RequiredArgsConstructor @@ -35,30 +39,19 @@ public class MapController { private final ObjectMapper objectMapper; @GetMapping("/map") - public String map(Model model, @AuthenticationPrincipal SecUser user) { + public String map(Model model, @AuthenticationPrincipal SecUser user, HttpServletRequest request) { List locations = locationService.getByUserId(user.getId()); log.info("Map page requested by user '{}', {} saved location(s)", user.getUsername(), locations.size()); + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + model.addAttribute("login", user.getFullName()); model.addAttribute("locations", locations); - model.addAttribute("mapDataJson", buildMapDataJson(locations)); + model.addAttribute("mapDataJson", buildMapDataJson(locations, csrfToken)); return "map"; } - /** - * Serializes the data {@code map-init.js} needs (saved locations plus - * translated overlay labels) into a single JSON string, built and - * escaped entirely server-side rather than via Thymeleaf's - * JavaScript-inlining ({@code /*[[...]]*}{@code /}) syntax. That syntax - * relies on comment markers which are only ever stripped for elements - * Thymeleaf recognizes as JavaScript; since {@code map-data} is - * {@code type="application/json"} (deliberately, so it's inert data - * rather than a script the CSP {@code script-src} directive would need - * to allow), nothing guarantees those markers get removed, and raw - * {@code /* ... *}{@code /} comments aren't valid JSON regardless. This - * produces plain, comment-free JSON up front instead. - */ - private String buildMapDataJson(List locations) { + private String buildMapDataJson(List locations, CsrfToken csrfToken) { Map layerLabels = new LinkedHashMap<>(); layerLabels.put("precipitation", messageSource.getMessage("map.layer.precipitation", null, LocaleContextHolder.getLocale())); layerLabels.put("clouds", messageSource.getMessage("map.layer.clouds", null, LocaleContextHolder.getLocale())); @@ -67,12 +60,18 @@ private String buildMapDataJson(List locations) { Map labels = new LinkedHashMap<>(); labels.put("forecast", messageSource.getMessage("map.popup.forecast", null, LocaleContextHolder.getLocale())); + labels.put("addLocation", messageSource.getMessage("map.popup.add-location", null, LocaleContextHolder.getLocale())); + labels.put("locationNamePlaceholder", messageSource.getMessage("map.popup.location-name-placeholder", null, LocaleContextHolder.getLocale())); Map data = new LinkedHashMap<>(); data.put("locations", locations); data.put("layerLabels", layerLabels); data.put("labels", labels); data.put("forecastUrl", "/forecast"); + data.put("addLocationUrl", "/search/add"); + + data.put("csrfParam", csrfToken.getParameterName()); + data.put("csrfToken", csrfToken.getToken()); try { return objectMapper.writeValueAsString(data).replace(" Date: Fri, 31 Jul 2026 09:53:05 +0300 Subject: [PATCH 06/37] fix(map): support CSRF submission in interactive map popup form --- src/main/resources/static/css/theme.css | 84 ++++++++------ src/main/resources/static/js/map-init.js | 136 +++++++++++------------ src/main/resources/templates/map.html | 46 +------- 3 files changed, 116 insertions(+), 150 deletions(-) diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css index a91bd4c..7f3bdec 100644 --- a/src/main/resources/static/css/theme.css +++ b/src/main/resources/static/css/theme.css @@ -46,53 +46,67 @@ body { min-height: 650px; } -/* Leaflet map controls (map.html) — Leaflet's chrome is plain CSS/PNG - with no awareness of data-bs-theme, so it's re-skinned here to match - the app's dark mode instead of sitting as a white panel over the dark - basemap that map-init.js switches to. */ -[data-bs-theme="dark"] .leaflet-control-layers, -[data-bs-theme="dark"] .leaflet-bar, -[data-bs-theme="dark"] .leaflet-popup-content-wrapper, -[data-bs-theme="dark"] .leaflet-popup-tip { - background-color: #2b3035; - color: #dee2e6; - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.65); +/* Theme toggle button in the header */ +.theme-toggle-btn { + border: none; + background: transparent; + font-size: 1.25rem; + line-height: 1; + color: var(--bs-body-color); + padding: 0.375rem 0.5rem; + border-radius: 0.5rem; } -[data-bs-theme="dark"] .leaflet-bar a { - background-color: #2b3035; - color: #dee2e6; - border-bottom-color: #495057; +.theme-toggle-btn:hover { + background-color: var(--bs-secondary-bg); } -[data-bs-theme="dark"] .leaflet-bar a:hover { - background-color: #343a40; +/* Leaflet map (map.html) — leaflet.css hardcodes light backgrounds for its + own chrome (popups, zoom buttons, layer control, attribution) and loads + after this file, so these need !important to win the cascade. Using + Bootstrap's theme-aware CSS variables (rather than scoping under + [data-bs-theme="dark"]) means one rule set covers both themes, the same + way .theme-toggle-btn does above. */ +.leaflet-popup-content-wrapper, +.leaflet-popup-tip, +.leaflet-bar a, +.leaflet-control-layers, +.leaflet-control-layers-expanded { + background-color: var(--bs-body-bg) !important; + color: var(--bs-body-color) !important; } -[data-bs-theme="dark"] .leaflet-control-layers-toggle { - filter: invert(1) brightness(1.6); +.leaflet-bar a { + border-color: var(--bs-border-color) !important; } -[data-bs-theme="dark"] .leaflet-control-attribution { - background-color: rgba(43, 48, 53, 0.85); - color: #adb5bd; +.leaflet-bar a:hover { + background-color: var(--bs-secondary-bg) !important; } -[data-bs-theme="dark"] .leaflet-control-attribution a { - color: #9ec5fe; +.leaflet-popup-close-button { + color: var(--bs-body-color) !important; } -/* Theme toggle button in the header */ -.theme-toggle-btn { - border: none; - background: transparent; - font-size: 1.25rem; - line-height: 1; - color: var(--bs-body-color); - padding: 0.375rem 0.5rem; - border-radius: 0.5rem; +.leaflet-control-attribution { + background-color: var(--bs-body-bg) !important; + color: var(--bs-secondary-color) !important; } -.theme-toggle-btn:hover { - background-color: var(--bs-secondary-bg); +.leaflet-control-attribution a { + color: var(--bs-link-color) !important; +} + +/* The base OSM tile layer is a plain raster image with no dark variant of + its own, so it stays light regardless of data-bs-theme by default. This + inverts it into a legible dark basemap in dark mode using CSS alone + (reacts live when the theme toggles, no JS re-fetch needed). Scoped to + .base-map-tiles (set via the tileLayer's className option in + map-init.js) so it only touches the base layer -- NOT the weather + overlay tiles (precipitation/clouds/temperature/wind), which would + otherwise have their data-carrying colors inverted into nonsense, and + NOT the location markers, which live in a separate Leaflet pane and stay + their normal color in both themes for legibility. */ +[data-bs-theme="dark"] .base-map-tiles { + filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%); } \ No newline at end of file diff --git a/src/main/resources/static/js/map-init.js b/src/main/resources/static/js/map-init.js index 90d4a00..38d79be 100644 --- a/src/main/resources/static/js/map-init.js +++ b/src/main/resources/static/js/map-init.js @@ -1,87 +1,45 @@ +/* global L */ (function () { - /* global L */ + 'use strict'; - var dataEl = document.getElementById('map-data'); - if (!dataEl) return; + function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + var dataElement = document.getElementById('map-data'); + var data = dataElement + ? JSON.parse(dataElement.textContent) + : { locations: [], layerLabels: {}, labels: {}, forecastUrl: '/forecast', addLocationUrl: '/search/add' }; - var data = JSON.parse(dataEl.textContent); var locations = data.locations || []; var layerLabels = data.layerLabels || {}; var labels = data.labels || {}; var forecastUrl = data.forecastUrl || '/forecast'; + var addLocationUrl = data.addLocationUrl || '/search/add'; + var csrfParam = data.csrfParam || '_csrf'; + var csrfToken = data.csrfToken || getCsrfToken(); - var DEFAULT_CENTER = [20, 0]; - var DEFAULT_ZOOM = 2; - - // CARTO's free basemaps (built on OSM data) look considerably nicer - // than the stock OSM tiles and, unlike them, come in a matching - // light/dark pair so the map can follow the app's own theme toggle. - var BASEMAPS = { - light: { - url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', - attribution: '© OpenStreetMap contributors © CARTO' - }, - dark: { - url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', - attribution: '© OpenStreetMap contributors © CARTO' - } - }; - - function currentTheme() { - return document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light'; - } - - function escapeHtml(value) { - var entities = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; - return String(value).replace(/[&<>"']/g, function (ch) { - return entities[ch]; - }); + function getCsrfToken() { + var match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/); + return match ? decodeURIComponent(match[1]) : ''; } - function markerIcon() { - return L.divIcon({ - className: 'weather-map-marker', - html: '', - iconSize: [30, 30], - iconAnchor: [15, 30], - popupAnchor: [0, -28] - }); - } - - function popupHtml(location) { - var href = forecastUrl + '?lat=' + encodeURIComponent(location.latitude) + '&lon=' + encodeURIComponent(location.longitude); - return '
' + - '
' + escapeHtml(location.name) + '
' + - '' + - ' ' + escapeHtml(labels.forecast || 'View forecast') + - '
'; - } - - var MIN_ZOOM = 2; + var DEFAULT_CENTER = [20, 0]; + var DEFAULT_ZOOM = 2; - var map = L.map('weather-map', { zoomControl: false, minZoom: MIN_ZOOM }).setView(DEFAULT_CENTER, DEFAULT_ZOOM); - L.control.zoom({ position: 'topleft' }).addTo(map); - L.control.scale({ imperial: true, metric: true, position: 'bottomleft' }).addTo(map); + var map = L.map('weather-map').setView(DEFAULT_CENTER, DEFAULT_ZOOM); - var baseLayer = L.tileLayer(BASEMAPS[currentTheme()].url, { - maxZoom: 20, - subdomains: 'abcd', - attribution: BASEMAPS[currentTheme()].attribution + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + attribution: '© OpenStreetMap contributors', + className: 'base-map-tiles' }).addTo(map); - var themeObserver = new MutationObserver(function () { - var theme = currentTheme(); - var next = BASEMAPS[theme]; - map.removeLayer(baseLayer); - baseLayer = L.tileLayer(next.url, { - maxZoom: 20, - subdomains: 'abcd', - attribution: next.attribution - }).addTo(map); - baseLayer.bringToBack(); - }); - themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-bs-theme'] }); - var overlayOptions = { opacity: 0.55, maxZoom: 18 }; var overlays = {}; overlays[layerLabels.precipitation] = L.tileLayer('/map/tiles/precipitation/{z}/{x}/{y}', overlayOptions); @@ -89,16 +47,23 @@ overlays[layerLabels.temperature] = L.tileLayer('/map/tiles/temperature/{z}/{x}/{y}', overlayOptions); overlays[layerLabels.wind] = L.tileLayer('/map/tiles/wind/{z}/{x}/{y}', overlayOptions); - L.control.layers(null, overlays, { collapsed: false, position: 'topright' }).addTo(map); + L.control.layers(null, overlays, { collapsed: false }).addTo(map); var markers = []; locations.forEach(function (location) { if (location.latitude == null || location.longitude == null) { return; } - var marker = L.marker([location.latitude, location.longitude], { icon: markerIcon() }) + + var forecastLink = '' + + escapeHtml(labels.forecast || 'Forecast') + ''; + var popupContent = '' + escapeHtml(location.name) + '
' + forecastLink; + + var marker = L.marker([location.latitude, location.longitude], { bubblingMouseEvents: false }) .addTo(map) - .bindPopup(popupHtml(location)); + .bindPopup(popupContent); markers.push(marker); }); @@ -106,4 +71,29 @@ var group = L.featureGroup(markers); map.fitBounds(group.getBounds().pad(0.3)); } + + map.on('click', function (e) { + var lat = e.latlng.lat; + var lng = e.latlng.lng; + + var formHtml = + '
' + + '
' + + '' + + '
' + + '' + + '' + + '' + + '' + + '
'; + + L.popup() + .setLatLng(e.latlng) + .setContent(formHtml) + .openOn(map); + }); })(); \ No newline at end of file diff --git a/src/main/resources/templates/map.html b/src/main/resources/templates/map.html index a20c7ef..ef86f3f 100644 --- a/src/main/resources/templates/map.html +++ b/src/main/resources/templates/map.html @@ -4,9 +4,8 @@ - -