/*
* Copyright 2013 Hannes Janetzek
*
* This file is part of the OpenScienceMap project (http://www.opensciencemap.org).
*
* This program is free software: you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.oscim.layers.tile;
import static org.oscim.layers.tile.MapTile.State.CANCEL;
import static org.oscim.layers.tile.MapTile.State.DEADBEEF;
import static org.oscim.layers.tile.MapTile.State.LOADING;
import static org.oscim.layers.tile.MapTile.State.NEW_DATA;
import static org.oscim.layers.tile.MapTile.State.NONE;
import static org.oscim.layers.tile.MapTile.State.READY;
import static org.oscim.utils.FastMath.clamp;
import java.util.ArrayList;
import java.util.Arrays;
import org.oscim.core.MapPosition;
import org.oscim.core.Tile;
import org.oscim.event.Event;
import org.oscim.event.EventDispatcher;
import org.oscim.event.EventListener;
import org.oscim.layers.tile.MapTile.TileNode;
import org.oscim.map.Map;
import org.oscim.map.Viewport;
import org.oscim.renderer.BufferObject;
import org.oscim.utils.ScanBox;
import org.oscim.utils.quadtree.TileIndex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TileManager {
static final Logger log = LoggerFactory.getLogger(TileManager.class);
static final boolean dbg = false;
public final static Event TILE_LOADED = new Event();
public final static Event TILE_REMOVED = new Event();
private final int mCacheLimit;
private int mCacheReduce;
private int mMinZoom;
private int mMaxZoom;
private int[] mZoomTable;
/**
* limit number tiles with new data not uploaded to GL
* TODO this should depend on the number of tiles displayed
*/
private static final int MAX_TILES_IN_QUEUE = 20;
/** cache limit threshold */
private static final int CACHE_THRESHOLD = 25;
private static final int CACHE_CLEAR_THRESHOLD = 10;
private final Map mMap;
private final Viewport mViewport;
/** cache for all tiles */
private MapTile[] mTiles;
/** actual number of tiles in mTiles */
private int mTilesCount;
/** current end position in mTiles */
private int mTilesEnd;
/** counter for tiles with new data not yet loaded to GL */
private int mTilesToUpload;
/** new tile jobs for MapWorkers */
private final ArrayList<MapTile> mJobs;
/** counter to check whether current TileSet has changed */
private int mUpdateSerial;
/** lock for TileSets while updating MapTile locks - still needed? */
private final Object mTilelock = new Object();
private TileSet mCurrentTiles;
/* package */TileSet mNewTiles;
/** job queue filled in TileManager and polled by TileLoaders */
private final JobQueue jobQueue;
private final float[] mMapPlane = new float[8];
private boolean mLoadParent;
private int mPrevZoomlevel;
private double mLevelUpThreshold = 1;
private double mLevelDownThreshold = 2;
private final TileIndex<TileNode, MapTile> mIndex =
new TileIndex<TileNode, MapTile>() {
@Override
public void removeItem(MapTile t) {
if (t.node == null)
throw new IllegalStateException("Already removed: " + t);
super.remove(t.node);
t.node.item = null;
}
@Override
public TileNode create() {
return new TileNode();
}
};
public final EventDispatcher<Listener, MapTile> events =
new EventDispatcher<Listener, MapTile>() {
@Override
public void tell(Listener l, Event event, MapTile tile) {
l.onTileManagerEvent(event, tile);
}
};
public interface Listener extends EventListener {
void onTileManagerEvent(Event event, MapTile tile);
};
public TileManager(Map map, int cacheLimit) {
mMap = map;
mMaxZoom = 20;
mMinZoom = 0;
mCacheLimit = cacheLimit;
mCacheReduce = 0;
mViewport = map.viewport();
jobQueue = new JobQueue();
mJobs = new ArrayList<MapTile>();
mTiles = new MapTile[mCacheLimit];
mTilesEnd = 0;
mTilesToUpload = 0;
mUpdateSerial = 0;
}
public void setZoomTable(int[] zoomTable) {
mZoomTable = zoomTable;
}
/**
* TESTING: avoid flickering when switching zoom-levels:
* 1.85, 1.15 seems to work well
*/
public void setZoomThresholds(float down, float up) {
mLevelDownThreshold = clamp(down, 1, 2);
mLevelUpThreshold = clamp(up, 1, 2);
}
public MapTile getTile(int x, int y, int z) {
synchronized (mTilelock) {
return mIndex.getTile(x, y, z);
}
}
public void init() {
if (mCurrentTiles != null)
mCurrentTiles.releaseTiles();
mIndex.drop();
/* Pass VBOs and VertexItems back to pools */
for (int i = 0; i < mTilesEnd; i++) {
MapTile t = mTiles[i];
if (t == null)
continue;
/* Check if tile is used by another thread */
if (!t.isLocked())
t.clear();
/* In case the tile is still loading or used by
* another thread: clear when returned from loader
* or becomes unlocked */
t.setState(DEADBEEF);
}
/* clear references to cached MapTiles */
Arrays.fill(mTiles, null);
mTilesEnd = 0;
mTilesCount = 0;
/* set up TileSet large enough to hold current tiles */
int num = Math.max(mMap.getWidth(), mMap.getHeight());
int size = Tile.SIZE >> 1;
int numTiles = (num * num) / (size * size) * 4;
mNewTiles = new TileSet(numTiles);
mCurrentTiles = new TileSet(numTiles);
}
/**
* 1. Update mCurrentTiles TileSet of currently visible tiles.
* 2. Add not yet loaded (or loading) tiles to JobQueue.
* 3. Manage cache
*
* @param pos
* current MapPosition
*/
public boolean update(MapPosition pos) {
// FIXME cant expect init to be called otherwise
// Should use some onLayerAttached callback instead.
if (mNewTiles == null || mNewTiles.tiles.length == 0) {
mPrevZoomlevel = pos.zoomLevel;
init();
}
/* clear JobQueue and set tiles to state == NONE.
* one could also append new tiles and sort in JobQueue
* but this has the nice side-effect that MapWorkers dont
* start with old jobs while new jobs are calculated, which
* should increase the chance that they are free when new
* jobs come in. */
jobQueue.clear();
if (pos.zoomLevel < mMinZoom) {
if (mCurrentTiles.cnt > 0 && pos.zoomLevel < mMinZoom - 4) {
synchronized (mTilelock) {
mCurrentTiles.releaseTiles();
}
}
return false;
}
int tileZoom = clamp(pos.zoomLevel, mMinZoom, mMaxZoom);
if (mZoomTable == null) {
/* greater 1 when zoomed in further than
* tile zoomlevel, so [1..2] while whithin
* min/maxZoom */
double scaleDiv = pos.scale / (1 << tileZoom);
mLoadParent = scaleDiv < 1.5;
int zoomDiff = tileZoom - mPrevZoomlevel;
if (zoomDiff == 1) {
/* dont switch zoomlevel up yet */
if (scaleDiv < mLevelUpThreshold) {
tileZoom = mPrevZoomlevel;
mLoadParent = false;
}
} else if (zoomDiff == -1) {
/* dont switch zoomlevel down yet */
if (scaleDiv > mLevelDownThreshold) {
tileZoom = mPrevZoomlevel;
mLoadParent = true;
}
}
// log.debug("p:{} {}:{}=>{} | {} <> {}", mLoadParent,
// mPrevZoomlevel, pos.zoomLevel, tileZoom,
// scaleDiv, (pos.scale / (1 << tileZoom)));
} else {
mLoadParent = false;
int match = 0;
for (int z : mZoomTable) {
if (z <= tileZoom && z > match)
match = z;
}
if (match == 0)
return false;
tileZoom = match;
}
mPrevZoomlevel = tileZoom;
mViewport.getMapExtents(mMapPlane, Tile.SIZE / 2);
/* scan visible tiles. callback function calls 'addTile'
* which updates mNewTiles */
mNewTiles.cnt = 0;
mScanBox.scan(pos.x, pos.y, pos.scale, tileZoom, mMapPlane);
MapTile[] newTiles = mNewTiles.tiles;
int newCnt = mNewTiles.cnt;
MapTile[] curTiles = mCurrentTiles.tiles;
int curCnt = mCurrentTiles.cnt;
boolean changed = (newCnt != curCnt);
Arrays.sort(newTiles, 0, newCnt, TileSet.coordComparator);
if (!changed) {
/* compare if any tile has changed */
for (int i = 0; i < newCnt; i++) {
if (newTiles[i] != curTiles[i]) {
changed = true;
break;
}
}
}
if (changed) {
synchronized (mTilelock) {
/* lock new tiles */
mNewTiles.lockTiles();
/* unlock previous tiles */
mCurrentTiles.releaseTiles();
/* swap newTiles with currentTiles */
TileSet tmp = mCurrentTiles;
mCurrentTiles = mNewTiles;
mNewTiles = tmp;
mUpdateSerial++;
}
/* request rendering as tiles changed */
mMap.render();
}
/* Add tile jobs to queue */
if (mJobs.isEmpty())
return false;
MapTile[] jobs = new MapTile[mJobs.size()];
jobs = mJobs.toArray(jobs);
updateDistances(jobs, jobs.length, pos);
/* sets tiles to state == LOADING */
jobQueue.setJobs(jobs);
mJobs.clear();
if (mCacheReduce < mCacheLimit / 2) {
if (BufferObject.isMaxFill()) {
mCacheReduce += 10;
if (dbg)
log.debug("reduce cache {}", (mCacheLimit - mCacheReduce));
} else {
mCacheReduce = 0;
}
}
/* limit cache items */
int remove = mTilesCount - (mCacheLimit - mCacheReduce);
if (remove > CACHE_THRESHOLD || mTilesToUpload > MAX_TILES_IN_QUEUE) {
synchronized (mTilelock) {
limitCache(pos, remove);
}
}
return true;
}
public void clearJobs() {
jobQueue.clear();
}
public boolean hasTileJobs() {
return !jobQueue.isEmpty();
}
public MapTile getTileJob() {
return jobQueue.poll();
}
/**
* Retrive a TileSet of current tiles. Tiles remain locked in cache until
* the set is unlocked by either passing it again to this function or to
* releaseTiles.
*
* @threadsafe
* @param tileSet
* to be updated
* @return true if TileSet has changed
*/
public boolean getActiveTiles(TileSet tileSet) {
if (mCurrentTiles == null)
return false;
if (tileSet == null)
return false;
if (tileSet.serial == mUpdateSerial)
return false;
/* do not flip mNew/mCurrentTiles while copying */
synchronized (mTilelock) {
tileSet.setTiles(mCurrentTiles);
tileSet.serial = mUpdateSerial;
}
return true;
}
MapTile addTile(int x, int y, int zoomLevel) {
MapTile tile = mIndex.getTile(x, y, zoomLevel);
if (tile == null) {
TileNode n = mIndex.add(x, y, zoomLevel);
tile = n.item = new MapTile(n, x, y, zoomLevel);
tile.setState(LOADING);
mJobs.add(tile);
addToCache(tile);
} else if (!tile.isActive()) {
tile.setState(LOADING);
mJobs.add(tile);
}
if (mLoadParent && (zoomLevel > mMinZoom) && (mZoomTable == null)) {
/* prefetch parent */
MapTile p = tile.node.parent();
if (p == null) {
TileNode n = mIndex.add(x >> 1, y >> 1, zoomLevel - 1);
p = n.item = new MapTile(n, x >> 1, y >> 1, zoomLevel - 1);
addToCache(p);
/* this prevents to add tile twice to queue */
p.setState(LOADING);
mJobs.add(p);
} else if (!p.isActive()) {
p.setState(LOADING);
mJobs.add(p);
}
}
return tile;
}
private void addToCache(MapTile tile) {
if (mTilesEnd == mTiles.length) {
if (mTilesEnd > mTilesCount) {
TileDistanceSort.sort(mTiles, 0, mTilesEnd);
/* sorting also repacks the 'sparse' filled array
* so end of mTiles is at mTilesCount now */
mTilesEnd = mTilesCount;
}
if (mTilesEnd == mTiles.length) {
log.debug("realloc tiles {}", mTilesEnd);
MapTile[] tmp = new MapTile[mTiles.length + 20];
System.arraycopy(mTiles, 0, tmp, 0, mTilesCount);
mTiles = tmp;
}
}
mTiles[mTilesEnd++] = tile;
mTilesCount++;
}
private boolean removeFromCache(MapTile t) {
/* TODO check valid states here:When in CANCEL state tile belongs to
* TileLoader thread, defer clearing to jobCompleted() */
if (dbg)
log.debug("remove from cache {} {} {}",
t, t.state(), t.isLocked());
if (t.isLocked())
return false;
if (t.state(NEW_DATA | READY))
events.fire(TILE_REMOVED, t);
t.clear();
mIndex.removeItem(t);
mTilesCount--;
return true;
}
private void limitCache(MapPosition pos, int remove) {
MapTile[] tiles = mTiles;
/* count tiles that have new data */
int newTileCnt = 0;
/* remove tiles that were never loaded */
for (int i = 0; i < mTilesEnd; i++) {
MapTile t = tiles[i];
if (t == null)
continue;
if (t.state(NEW_DATA))
newTileCnt++;
if (t.state(DEADBEEF)) {
log.debug("found DEADBEEF {}", t);
t.clear();
tiles[i] = null;
continue;
}
/* make sure tile cannot be used by GL or MapWorker Thread */
if (t.state(NONE) && removeFromCache(t)) {
tiles[i] = null;
remove--;
}
}
if ((remove < CACHE_CLEAR_THRESHOLD) && (newTileCnt < MAX_TILES_IN_QUEUE))
return;
updateDistances(tiles, mTilesEnd, pos);
TileDistanceSort.sort(tiles, 0, mTilesEnd);
/* sorting also repacks the 'sparse' filled array
* so end of mTiles is at mTilesCount now */
mTilesEnd = mTilesCount;
/* start with farest away tile */
for (int i = mTilesCount - 1; i >= 0 && remove > 0; i--) {
MapTile t = tiles[i];
/* dont remove tile used by TileRenderer, or somewhere else
* try again in next run. */
if (t.isLocked()) {
if (dbg)
log.debug("{} locked (state={}, d={})",
t, t.state(), t.distance);
continue;
}
if (t.state(CANCEL)) {
continue;
}
/* cancel loading of tiles that should not even be cached */
if (t.state(LOADING)) {
t.setState(CANCEL);
if (dbg)
log.debug("{} canceled (d={})", t, t.distance);
continue;
}
/* clear new and unused tile */
if (t.state(NEW_DATA)) {
newTileCnt--;
if (dbg)
log.debug("{} unused (d=({})", t, t.distance);
}
if (!t.state(READY | NEW_DATA)) {
log.error("stuff that should be here! {} {}", t, t.state());
}
if (removeFromCache(t)) {
tiles[i] = null;
remove--;
}
}
for (int i = mTilesCount - 1; i >= 0 && newTileCnt > MAX_TILES_IN_QUEUE; i--) {
MapTile t = tiles[i];
if ((t != null) && (t.state(NEW_DATA))) {
if (removeFromCache(t)) {
tiles[i] = null;
newTileCnt--;
}
}
}
mTilesToUpload = newTileCnt;
}
/**
* Called by TileLoader thread when tile is loaded.
*
* @threadsafe
* @param tile
* Tile ready for upload in TileRenderLayer
*/
public void jobCompleted(MapTile tile, boolean success) {
/* send TILE_LOADED event on main-loop */
mMap.post(new JobCompletedEvent(tile, success));
/* locked means the tile is visible or referenced by
* a tile that might be visible. */
if (tile.isLocked())
mMap.render();
}
class JobCompletedEvent implements Runnable {
final MapTile tile;
final boolean success;
public JobCompletedEvent(MapTile tile, boolean success) {
this.tile = tile;
this.success = success;
}
@Override
public void run() {
if (success && tile.state(LOADING)) {
tile.setState(NEW_DATA);
events.fire(TILE_LOADED, tile);
mTilesToUpload++;
return;
}
// TODO use mMap.update(true) to retry tile loading?
log.debug("Load: {} {} state:{}",
tile, success ? "success" : "failed",
tile.state());
/* got orphaned tile */
if (tile.state(DEADBEEF)) {
tile.clear();
return;
}
tile.clear();
}
}
private static void updateDistances(MapTile[] tiles, int size, MapPosition pos) {
/* TODO there is probably a better quad-tree distance function */
int zoom = 20;
long x = (long) (pos.x * (1 << zoom));
long y = (long) (pos.y * (1 << zoom));
for (int i = 0; i < size; i++) {
MapTile t = tiles[i];
if (t == null)
continue;
int diff = (zoom - t.zoomLevel);
long dx, dy;
if (diff == 0) {
dx = t.tileX - x;
dy = t.tileY - y;
} else { // diff > 0
long mx = x >> diff;
long my = y >> diff;
dx = t.tileX - mx;
dy = t.tileY - my;
}
int dz = (pos.zoomLevel - t.zoomLevel);
if (dz == 0)
dz = 1;
else if (dz < -1)
dz *= 0.75;
t.distance = (dx * dx + dy * dy) * (dz * dz);
}
}
private final ScanBox mScanBox = new ScanBox() {
@Override
protected void setVisible(int y, int x1, int x2) {
MapTile[] tiles = mNewTiles.tiles;
int cnt = mNewTiles.cnt;
int maxTiles = tiles.length;
int xmax = 1 << mZoom;
for (int x = x1; x < x2; x++) {
MapTile tile = null;
if (cnt == maxTiles) {
log.debug("too many tiles {}", maxTiles);
break;
}
int xx = x;
if (x < 0 || x >= xmax) {
/* flip-around date line */
if (x < 0)
xx = xmax + x;
else
xx = x - xmax;
if (xx < 0 || xx >= xmax)
continue;
}
/* check if tile is already added */
for (int i = 0; i < cnt; i++)
if (tiles[i].tileX == xx && tiles[i].tileY == y) {
tile = tiles[i];
break;
}
if (tile == null) {
tile = addTile(xx, y, mZoom);
tiles[cnt++] = tile;
}
}
mNewTiles.cnt = cnt;
}
};
public MapTile getTile(int tileX, int tileY, byte zoomLevel) {
return mIndex.getTile(tileX, tileY, zoomLevel);
}
public void setZoomLevel(int zoomLevelMin, int zoomLevelMax) {
mMinZoom = zoomLevelMin;
mMaxZoom = zoomLevelMax;
}
}