1 /*
2 * *************************************************************************************************************************************************************
3 *
4 * MapView: a JavaFX map renderer for tile-based servers
5 * http://tidalwave.it/projects/mapview
6 *
7 * Copyright (C) 2024 - 2025 by Tidalwave s.a.s. (http://tidalwave.it)
8 *
9 * *************************************************************************************************************************************************************
10 *
11 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
12 * You may obtain a copy of the License at
13 *
14 * http://www.apache.org/licenses/LICENSE-2.0
15 *
16 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
17 * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
18 *
19 * *************************************************************************************************************************************************************
20 *
21 * git clone https://bitbucket.org/tidalwave/mapview-src
22 * git clone https://github.com/tidalwave-it/mapview-src
23 *
24 * *************************************************************************************************************************************************************
25 */
26 package it.tidalwave.mapviewer.javafx.impl;
27
28 import java.lang.ref.SoftReference;
29 import jakarta.annotation.Nonnull;
30 import java.util.Map;
31 import java.util.Optional;
32 import java.util.concurrent.BlockingQueue;
33 import java.util.concurrent.ConcurrentHashMap;
34 import java.util.concurrent.ExecutorService;
35 import java.util.concurrent.Executors;
36 import java.util.concurrent.LinkedBlockingQueue;
37 import java.util.concurrent.TimeUnit;
38 import java.util.stream.IntStream;
39 import java.nio.charset.StandardCharsets;
40 import java.nio.file.Files;
41 import java.nio.file.Path;
42 import java.net.URI;
43 import java.net.http.HttpClient;
44 import java.net.http.HttpRequest;
45 import java.net.http.HttpResponse;
46 import javafx.scene.image.Image;
47 import javafx.application.Platform;
48 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
49 import it.tidalwave.mapviewer.javafx.MapView;
50 import lombok.extern.slf4j.Slf4j;
51 import static it.tidalwave.mapviewer.impl.NameMangler.mangle;
52 import static java.net.http.HttpClient.Redirect.ALWAYS;
53
54 /***************************************************************************************************************************************************************
55 *
56 * A cache for tiles.
57 *
58 * @author Fabrizio Giudici
59 *
60 **************************************************************************************************************************************************************/
61 @Slf4j
62 public class TileCache
63 {
64 /** The queue of tiles to be downloaded. */
65 @Nonnull
66 private final BlockingQueue<Tile> tileQueue;
67
68 /** Options of the map view. */
69 @Nonnull
70 private final MapView.Options options;
71
72 /** The thread pool for downloading tiles. */
73 @Nonnull
74 private final ExecutorService executorService;
75
76 /** Whether the downloader thread should be stopped. */
77 private volatile boolean stopped = false;
78
79 /** This is important to avoid flickering then the TileGrid recreates tiles. */
80 private final Map<URI, SoftReference<Image>> memoryImageCache = new ConcurrentHashMap<>();
81
82 /** The placeholder used while the tile image has not been loaded yet. */
83 private final Image waitingImage = new Image(TileCache.class.getResource("/hold-on.gif").toExternalForm());
84
85 /***********************************************************************************************************************************************************
86 *
87 **********************************************************************************************************************************************************/
88 @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_BAD_PRACTICE")
89 public TileCache (@Nonnull final MapView.Options options)
90 {
91 this.options = options;
92 tileQueue = new LinkedBlockingQueue<>(options.tileQueueCapacity());
93 final var poolSize = options.poolSize();
94 executorService = Executors.newFixedThreadPool(poolSize);
95 IntStream.range(0, poolSize).forEach(i -> executorService.submit(this::tileLoader));
96 }
97
98 /***********************************************************************************************************************************************************
99 * {@return the number of tiles in the download queue}.
100 **********************************************************************************************************************************************************/
101 public int getPendingTileCount()
102 {
103 return tileQueue.size();
104 }
105
106 /***********************************************************************************************************************************************************
107 * Loads a tile in background.
108 * @param tile the tile to download
109 **********************************************************************************************************************************************************/
110 public final void loadTileInBackground (@Nonnull final Tile tile)
111 {
112 final var imageRef = memoryImageCache.get(tile.getUri());
113 final var image = (imageRef == null) ? null : imageRef.get();
114
115 if (image != null)
116 {
117 tile.setImage(image);
118 }
119 else
120 {
121 final var localPath = resolveCachedTilePath(tile);
122
123 if (Files.exists(localPath))
124 {
125 loadImageFromCache(tile, localPath);
126 }
127 else
128 {
129 tile.setImage(waitingImage);
130
131 if (tileQueue.offer(tile))
132 {
133 log.debug("Tiles in download queue: {}", tileQueue.size());
134 }
135 else
136 {
137 log.warn("Download queue full, discarding: {}", tile);
138 }
139 }
140 }
141 }
142
143 /***********************************************************************************************************************************************************
144 * Clears the queue of pending tiles, retaining only those for the given zoom level.
145 * @param zoom the zoom level to retain
146 **********************************************************************************************************************************************************/
147 public void retainPendingTiles (final int zoom)
148 {
149 log.debug("retainPendingTiles({})", zoom);
150 tileQueue.removeIf(tile -> tile.getZoom() != zoom);
151 }
152
153 /***********************************************************************************************************************************************************
154 *
155 **********************************************************************************************************************************************************/
156 public void dispose()
157 throws InterruptedException
158 {
159 log.debug("dispose()");
160 stopped = true;
161 executorService.shutdown();
162 executorService.awaitTermination(10, TimeUnit.SECONDS);
163 }
164
165 /***********************************************************************************************************************************************************
166 * The main loop that downloads the tiles.
167 **********************************************************************************************************************************************************/
168 private void tileLoader()
169 {
170 while (!stopped)
171 {
172 try
173 {
174 log.debug("waiting for next tile to load... queue size = {}", tileQueue.size());
175 final var tile = tileQueue.take();
176 final var uri = tile.getUri();
177 final var localPath = resolveCachedTilePath(tile);
178
179 if (!Files.exists(localPath) && options.downloadAllowed())
180 {
181 downloadTile(localPath, uri);
182 }
183
184 if (!Files.exists(localPath))
185 {
186 Platform.runLater(() -> tile.setImage(null));
187 }
188 else
189 {
190 Platform.runLater(() -> loadImageFromCache(tile, localPath));
191 }
192 }
193 catch (InterruptedException ignored)
194 {
195 log.info("tileLoader interrupted");
196 }
197 catch (Exception e) // defensive
198 {
199 log.error("", e);
200 }
201 }
202
203 log.info("tileLoader terminated");
204 }
205
206 /***********************************************************************************************************************************************************
207 * Loads an image from the cache.
208 * @param tile the tile
209 * @param path the path of the cache file
210 **********************************************************************************************************************************************************/
211 private void loadImageFromCache (@Nonnull final Tile tile, @Nonnull final Path path)
212 {
213 log.debug("Loading tile from cache: {}", path);
214 final var image = new Image(path.toUri().toString());
215 memoryImageCache.put(tile.getUri(), new SoftReference<>(image));
216 tile.setImage(image);
217 }
218
219 /***********************************************************************************************************************************************************
220 * {@return the path of the cached tile}.
221 * @param tile the tile
222 **********************************************************************************************************************************************************/
223 @Nonnull
224 private Path resolveCachedTilePath (@Nonnull final Tile tile)
225 {
226 return options.cacheFolder().resolve(tile.getSource().getCachePrefix()).resolve(mangle(tile.getUri().toString()));
227 }
228
229 /***********************************************************************************************************************************************************
230 * Downloads a tile and stores it.
231 * @param localPath the file to store the tile into
232 * @param uri the uri of the tile
233 **********************************************************************************************************************************************************/
234 @SuppressFBWarnings("REC_CATCH_EXCEPTION")
235 private static void downloadTile (@Nonnull final Path localPath, @Nonnull final URI uri)
236 {
237 try (final var client = HttpClient.newBuilder().followRedirects(ALWAYS).build())
238 {
239 Files.createDirectories(localPath.getParent());
240 final var request = HttpRequest.newBuilder()
241 .GET()
242 .header("User-Agent", "curl/8.7.1")
243 .header("Accept", "*/*")
244 .uri(uri)
245 .build();
246 final var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
247
248 switch (response.statusCode())
249 {
250 case 200:
251 final var bytes = response.body();
252 Files.write(localPath, bytes);
253 log.debug("written {} bytes to {}", bytes.length, localPath);
254 break;
255 case 503:
256 log.warn("status code 503 for {}, should re-schedule; {}", uri, response.headers().map());
257 getErrorBody(response).ifPresent(log::warn);
258 // TODO: should reschedule, but not immediately, and also count for a max number of attempts
259 // TOOD: could use a different placeholder image?
260 break;
261 default:
262 log.error("status code {} for {}; {}", response.statusCode(), uri, response.headers().map());
263 getErrorBody(response).ifPresent(log::error);
264 }
265 }
266 catch (Exception e) // defensive
267 {
268 log.error("", e);
269 }
270 }
271
272 /***********************************************************************************************************************************************************
273 *
274 **********************************************************************************************************************************************************/
275 @Nonnull
276 private static Optional<String> getErrorBody (@Nonnull final HttpResponse<byte[]> response)
277 {
278 return response.headers()
279 .firstValue("Content-type")
280 .filter(ct -> ct.startsWith("text/"))
281 .map(r -> new String(response.body(), StandardCharsets.UTF_8)); // TODO: charset should be get from response
282 }
283 }