/*
* Copyright (c) 2016 Metron, Inc.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* * Neither the name of Metron, Inc. nor the
* names of its contributors may be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL METRON, INC. BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.metsci.glimpse.charts.slippy;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.imageio.ImageIO;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.metsci.glimpse.support.projection.LatLonProjection;
import com.metsci.glimpse.support.projection.Projection;
import com.metsci.glimpse.support.texture.RGBTextureProjected2D;
import com.metsci.glimpse.util.geo.LatLonGeo;
import com.metsci.glimpse.util.geo.projection.GeoProjection;
/**
* An in-memory cache that uses soft references.
* If a path is given the images are checked for locally before fetching them from the web. Multiple URLs may be given,
* but only one thread will pull from each URL at a given time.
* @author oren
*
*/
public class SlippyCache
{
private static final Logger logger = Logger.getLogger( SlippyCache.class.getName( ) );
/*
* suffix pattern for slippy tiles zoom/x/y.png
*/
private static final String KEY_PATTERN = "%d/%d/%d.png";
/*
* Queue of URL prefixes for the tile server. You should only fetch from a single server at a time.
*/
private final BlockingDeque<String> prefixQueue;
/*
* Where to store tiles on disk. Set to null to disable disk caching.
*/
private final Path cacheDir;
/*
* The in memory cache of textures.
*/
private final LoadingCache<String, RGBTextureProjected2D> cache;
/*
* The base geo projection of the map display.
*/
private final GeoProjection geoProj;
/*
* Slippy projections for various zoom levels
*/
private final SlippyProjection[] slippyProj = new SlippyProjection[20];
public SlippyCache( GeoProjection geoProj, String urlPrefix )
{
this( geoProj, Collections.singletonList( urlPrefix ) );
}
public SlippyCache( GeoProjection geoProj, String urlPrefix, Path cacheDir )
{
this( geoProj, Collections.singletonList( urlPrefix ), cacheDir );
}
public SlippyCache( GeoProjection geoProj, List<String> urlPrefixes )
{
this( geoProj, urlPrefixes, null );
}
public SlippyCache( GeoProjection geoProj, List<String> urlPrefixes, Path cacheDir )
{
this.geoProj = geoProj;
this.prefixQueue = new LinkedBlockingDeque<>( urlPrefixes.size( ) );
if ( urlPrefixes.isEmpty( ) )
{
throw new IllegalArgumentException( "must supply at least one slippy server" );
}
else
{
for ( String prefix : urlPrefixes )
{
try
{
new URL( prefix );
}
catch ( Exception e )
{
throw new IllegalArgumentException( prefix + " is not a valid url" );
}
prefixQueue.add( prefix + ( prefix.endsWith( "/" ) ? "" : "/" ) );
}
}
if ( cacheDir != null )
{
if ( Files.isDirectory( cacheDir ) )
{
this.cacheDir = cacheDir;
}
else if ( !Files.exists( cacheDir ) )
{
try
{
Files.createDirectories( cacheDir );
}
catch ( Exception e )
{
String msg = "Failed to created directory for disk cache: " + cacheDir.toAbsolutePath( ).toString( );
logger.log( Level.WARNING, msg, e );
}
if ( Files.isDirectory( cacheDir ) )
{
this.cacheDir = cacheDir;
}
else
{
this.cacheDir = null;
}
}
else
{
throw new IllegalArgumentException( "specified cache directory (" + cacheDir.toString( ) + ")is a file" );
}
}
else
{
this.cacheDir = null;
}
for ( int zoom = 0; zoom < slippyProj.length; zoom++ )
{
this.slippyProj[zoom] = new SlippyProjection( zoom );
}
this.cache = CacheBuilder.newBuilder( )
.concurrencyLevel( urlPrefixes.size( ) )
.softValues( )
.build( new SlippyLoader( ) );
}
public RGBTextureProjected2D getTexture( int zoom, int x, int y )
{
String key = String.format( KEY_PATTERN, zoom, x, y );
RGBTextureProjected2D tex = null;
try
{
tex = cache.get( key );
}
catch ( Exception e )
{
logger.log( Level.WARNING, "Failed to get texture from cache", e );
}
return tex;
}
public RGBTextureProjected2D getTextureIfPresent( int zoom, int x, int y )
{
String key = String.format( KEY_PATTERN, zoom, x, y );
RGBTextureProjected2D tex = null;
try
{
tex = cache.getIfPresent( key );
}
catch ( Exception e )
{
logger.log( Level.WARNING, "Failed to get texture from cache (if present)", e );
}
return tex;
}
private class SlippyLoader extends CacheLoader<String, RGBTextureProjected2D>
{
@Override
public RGBTextureProjected2D load( String key ) throws Exception
{
BufferedImage img = fetchFromDisk( key );
//if we were able to get the image from disk, return it
if ( img == null )
{
img = fetchFromWeb( key );
}
return makeTex( key, img );
}
private BufferedImage fetchFromWeb( String key )
{
BufferedImage img = null;
//Now try to pull the image from the web
String prefix = null;
try
{
prefix = prefixQueue.take( );
String urlStr = prefix + key;
try
{
img = ImageIO.read( new URL( urlStr ) );
}
catch ( IOException e )
{
logger.log( Level.WARNING, "Exception fetching tile from the web", e );
}
//If we got an image, try to cache it to disk
try
{
saveToDisk( key, img );
}
catch ( IOException e )
{
logger.log( Level.WARNING, "Exception saving tile to disk", e );
}
}
catch ( InterruptedException e )
{
logger.log( Level.WARNING, "Interrupted while getting a URL", e );
}
finally
{
if ( prefix != null )
{
try
{
prefixQueue.put( prefix );
}
catch ( InterruptedException e )
{
logger.log( Level.WARNING, "Interrupted while putting a URL back on the queue", e );
}
}
}
return img;
}
private void saveToDisk( String key, BufferedImage img ) throws IOException
{
if ( img != null && cacheDir != null )
{
Path imgPath = cacheDir.resolve( key );
if ( !Files.exists( imgPath.getParent( ) ) )
{
try
{
Files.createDirectories( imgPath.getParent( ) );
}
catch ( Exception e )
{
//the img write should fail anyway
}
}
ImageIO.write( img, "PNG", imgPath.toFile( ) );
}
}
private BufferedImage fetchFromDisk( String key )
{
BufferedImage img = null;
if ( cacheDir != null )
{
Path imgPath = cacheDir.resolve( key );
if ( Files.exists( imgPath ) )
{
try
{
img = ImageIO.read( imgPath.toFile( ) );
}
catch ( Exception e )
{
logger.log( Level.WARNING, "Exception while attempting to read the tile from disk", e );
}
}
}
return img;
}
}
private RGBTextureProjected2D makeTex( String key, BufferedImage img )
{
if ( img == null )
{
return null;
}
RGBTextureProjected2D tex = new RGBTextureProjected2D( img );
String[] parts = key.substring( 0, key.length( ) - 4 ).split( "/" );
int zoom = Integer.parseInt( parts[0] );
int x = Integer.parseInt( parts[1] );
int y = Integer.parseInt( parts[2] );
tex.setProjection( getProjection( zoom, x, y ) );
return tex;
}
private Projection getProjection( int zoom, int x, int y )
{
LatLonGeo nw = slippyProj[zoom].unproject( x, y );
LatLonGeo se = slippyProj[zoom].unproject( x + 1, y + 1 );
double minLat = se.getLatDeg( );
double maxLat = nw.getLatDeg( );
double minLon = nw.getLonDeg( );
double maxLon = se.getLonDeg( );
return new LatLonProjection( geoProj, minLat, maxLat, minLon, maxLon, false );
}
}