TileCache.java

  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. import java.lang.ref.SoftReference;
  28. import jakarta.annotation.Nonnull;
  29. import java.util.Map;
  30. import java.util.Optional;
  31. import java.util.concurrent.BlockingQueue;
  32. import java.util.concurrent.ConcurrentHashMap;
  33. import java.util.concurrent.ExecutorService;
  34. import java.util.concurrent.Executors;
  35. import java.util.concurrent.LinkedBlockingQueue;
  36. import java.util.concurrent.TimeUnit;
  37. import java.util.stream.IntStream;
  38. import java.nio.charset.StandardCharsets;
  39. import java.nio.file.Files;
  40. import java.nio.file.Path;
  41. import java.net.URI;
  42. import java.net.http.HttpClient;
  43. import java.net.http.HttpRequest;
  44. import java.net.http.HttpResponse;
  45. import javafx.scene.image.Image;
  46. import javafx.application.Platform;
  47. import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
  48. import it.tidalwave.mapviewer.javafx.MapView;
  49. import lombok.extern.slf4j.Slf4j;
  50. import static it.tidalwave.mapviewer.impl.NameMangler.mangle;
  51. import static java.net.http.HttpClient.Redirect.ALWAYS;

  52. /***************************************************************************************************************************************************************
  53.  *
  54.  * A cache for tiles.
  55.  *
  56.  * @author  Fabrizio Giudici
  57.  *
  58.  **************************************************************************************************************************************************************/
  59. @Slf4j
  60. public class TileCache
  61.   {
  62.     /** The queue of tiles to be downloaded. */
  63.     @Nonnull
  64.     private final BlockingQueue<Tile> tileQueue;

  65.     /** Options of the map view. */
  66.     @Nonnull
  67.     private final MapView.Options options;

  68.     /** The thread pool for downloading tiles. */
  69.     @Nonnull
  70.     private final ExecutorService executorService;

  71.     /** Whether the downloader thread should be stopped. */
  72.     private volatile boolean stopped = false;

  73.     /** This is important to avoid flickering then the TileGrid recreates tiles. */
  74.     private final Map<URI, SoftReference<Image>> memoryImageCache = new ConcurrentHashMap<>();

  75.     /** The placeholder used while the tile image has not been loaded yet. */
  76.     private final Image waitingImage = new Image(TileCache.class.getResource("/hold-on.gif").toExternalForm());

  77.     /***********************************************************************************************************************************************************
  78.      *
  79.      **********************************************************************************************************************************************************/
  80.     @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_BAD_PRACTICE")
  81.     public TileCache (@Nonnull final MapView.Options options)
  82.       {
  83.         this.options = options;
  84.         tileQueue = new LinkedBlockingQueue<>(options.tileQueueCapacity());
  85.         final var poolSize = options.poolSize();
  86.         executorService = Executors.newFixedThreadPool(poolSize);
  87.         IntStream.range(0, poolSize).forEach(i -> executorService.submit(this::tileLoader));
  88.       }

  89.     /***********************************************************************************************************************************************************
  90.      * {@return the number of tiles in the download queue}.
  91.      **********************************************************************************************************************************************************/
  92.     public int getPendingTileCount()
  93.       {
  94.         return tileQueue.size();
  95.       }

  96.     /***********************************************************************************************************************************************************
  97.      * Loads a tile in background.
  98.      * @param   tile      the tile to download
  99.      **********************************************************************************************************************************************************/
  100.     public final void loadTileInBackground (@Nonnull final Tile tile)
  101.       {
  102.         final var imageRef = memoryImageCache.get(tile.getUri());
  103.         final var image = (imageRef == null) ? null : imageRef.get();

  104.         if (image != null)
  105.           {
  106.             tile.setImage(image);
  107.           }
  108.         else
  109.           {
  110.             final var localPath = resolveCachedTilePath(tile);

  111.             if (Files.exists(localPath))
  112.               {
  113.                 loadImageFromCache(tile, localPath);
  114.               }
  115.             else
  116.               {
  117.                 tile.setImage(waitingImage);

  118.                 if (tileQueue.offer(tile))
  119.                   {
  120.                     log.debug("Tiles in download queue: {}", tileQueue.size());
  121.                   }
  122.                 else
  123.                   {
  124.                     log.warn("Download queue full, discarding: {}", tile);
  125.                   }
  126.               }
  127.           }
  128.       }

  129.     /***********************************************************************************************************************************************************
  130.      * Clears the queue of pending tiles, retaining only those for the given zoom level.
  131.      * @param   zoom    the zoom level to retain
  132.      **********************************************************************************************************************************************************/
  133.     public void retainPendingTiles (final int zoom)
  134.       {
  135.         log.debug("retainPendingTiles({})", zoom);
  136.         tileQueue.removeIf(tile -> tile.getZoom() != zoom);
  137.       }

  138.     /***********************************************************************************************************************************************************
  139.      *
  140.      **********************************************************************************************************************************************************/
  141.     public void dispose()
  142.             throws InterruptedException
  143.       {
  144.         log.debug("dispose()");
  145.         stopped = true;
  146.         executorService.shutdown();
  147.         executorService.awaitTermination(10, TimeUnit.SECONDS);
  148.       }

  149.     /***********************************************************************************************************************************************************
  150.      * The main loop that downloads the tiles.
  151.      **********************************************************************************************************************************************************/
  152.     private void tileLoader()
  153.       {
  154.         while (!stopped)
  155.           {
  156.             try
  157.               {
  158.                 log.debug("waiting for next tile to load... queue size = {}", tileQueue.size());
  159.                 final var tile = tileQueue.take();
  160.                 final var uri = tile.getUri();
  161.                 final var localPath = resolveCachedTilePath(tile);

  162.                 if (!Files.exists(localPath) && options.downloadAllowed())
  163.                   {
  164.                     downloadTile(localPath, uri);
  165.                   }

  166.                 if (!Files.exists(localPath))
  167.                   {
  168.                     Platform.runLater(() -> tile.setImage(null));
  169.                   }
  170.                 else
  171.                   {
  172.                     Platform.runLater(() -> loadImageFromCache(tile, localPath));
  173.                   }
  174.               }
  175.             catch (InterruptedException ignored)
  176.               {
  177.                 log.info("tileLoader interrupted");
  178.               }
  179.             catch (Exception e) // defensive
  180.               {
  181.                 log.error("", e);
  182.               }
  183.           }

  184.         log.info("tileLoader terminated");
  185.       }

  186.     /***********************************************************************************************************************************************************
  187.      * Loads an image from the cache.
  188.      * @param     tile          the tile
  189.      * @param     path          the path of the cache file
  190.      **********************************************************************************************************************************************************/
  191.     private void loadImageFromCache (@Nonnull final Tile tile, @Nonnull final Path path)
  192.       {
  193.         log.debug("Loading tile from cache: {}", path);
  194.         final var image = new Image(path.toUri().toString());
  195.         memoryImageCache.put(tile.getUri(), new SoftReference<>(image));
  196.         tile.setImage(image);
  197.       }

  198.     /***********************************************************************************************************************************************************
  199.      * {@return the path of the cached tile}.
  200.      * @param     tile          the tile
  201.      **********************************************************************************************************************************************************/
  202.     @Nonnull
  203.     private Path resolveCachedTilePath (@Nonnull final Tile tile)
  204.       {
  205.         return options.cacheFolder().resolve(tile.getSource().getCachePrefix()).resolve(mangle(tile.getUri().toString()));
  206.       }

  207.     /***********************************************************************************************************************************************************
  208.      * Downloads a tile and stores it.
  209.      * @param     localPath     the file to store the tile into
  210.      * @param     uri           the uri of the tile
  211.      **********************************************************************************************************************************************************/
  212.     @SuppressFBWarnings("REC_CATCH_EXCEPTION")
  213.     private static void downloadTile (@Nonnull final Path localPath, @Nonnull final URI uri)
  214.       {
  215.         try (final var client = HttpClient.newBuilder().followRedirects(ALWAYS).build())
  216.           {
  217.             Files.createDirectories(localPath.getParent());
  218.             final var request = HttpRequest.newBuilder()
  219.                                            .GET()
  220.                                            .header("User-Agent", "curl/8.7.1")
  221.                                            .header("Accept", "*/*")
  222.                                            .uri(uri)
  223.                                            .build();
  224.             final var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());

  225.             switch (response.statusCode())
  226.               {
  227.                 case 200:
  228.                   final var bytes = response.body();
  229.                   Files.write(localPath, bytes);
  230.                   log.debug("written {} bytes to {}", bytes.length, localPath);
  231.                   break;
  232.                 case 503:
  233.                   log.warn("status code 503 for {}, should re-schedule; {}", uri, response.headers().map());
  234.                   getErrorBody(response).ifPresent(log::warn);
  235.                   // TODO: should reschedule, but not immediately, and also count for a max number of attempts
  236.                   // TOOD: could use a different placeholder image?
  237.                   break;
  238.                 default:
  239.                   log.error("status code {} for {}; {}", response.statusCode(), uri, response.headers().map());
  240.                   getErrorBody(response).ifPresent(log::error);
  241.               }
  242.           }
  243.         catch (Exception e) // defensive
  244.           {
  245.             log.error("", e);
  246.           }
  247.       }

  248.     /***********************************************************************************************************************************************************
  249.      *
  250.      **********************************************************************************************************************************************************/
  251.     @Nonnull
  252.     private static Optional<String> getErrorBody (@Nonnull final HttpResponse<byte[]> response)
  253.       {
  254.         return response.headers()
  255.                        .firstValue("Content-type")
  256.                        .filter(ct -> ct.startsWith("text/"))
  257.                        .map(r -> new String(response.body(), StandardCharsets.UTF_8)); // TODO: charset should be get from response
  258.       }
  259.   }