View Javadoc
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   }