001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Component;
007import java.awt.Graphics;
008import java.awt.Graphics2D;
009import java.awt.Image;
010import java.awt.Point;
011import java.awt.event.ActionEvent;
012import java.awt.event.MouseAdapter;
013import java.awt.event.MouseEvent;
014import java.awt.image.BufferedImage;
015import java.awt.image.ImageObserver;
016import java.io.Externalizable;
017import java.io.File;
018import java.io.IOException;
019import java.io.InvalidClassException;
020import java.io.ObjectInput;
021import java.io.ObjectOutput;
022import java.util.ArrayList;
023import java.util.Collections;
024import java.util.HashSet;
025import java.util.Iterator;
026import java.util.List;
027import java.util.Set;
028import java.util.concurrent.locks.Condition;
029import java.util.concurrent.locks.Lock;
030import java.util.concurrent.locks.ReentrantLock;
031
032import javax.swing.AbstractAction;
033import javax.swing.Action;
034import javax.swing.JCheckBoxMenuItem;
035import javax.swing.JMenuItem;
036import javax.swing.JOptionPane;
037
038import org.openstreetmap.gui.jmapviewer.AttributionSupport;
039import org.openstreetmap.josm.Main;
040import org.openstreetmap.josm.actions.SaveActionBase;
041import org.openstreetmap.josm.data.Bounds;
042import org.openstreetmap.josm.data.Preferences.PreferenceChangeEvent;
043import org.openstreetmap.josm.data.Preferences.PreferenceChangedListener;
044import org.openstreetmap.josm.data.ProjectionBounds;
045import org.openstreetmap.josm.data.coor.EastNorth;
046import org.openstreetmap.josm.data.coor.LatLon;
047import org.openstreetmap.josm.data.imagery.GeorefImage;
048import org.openstreetmap.josm.data.imagery.GeorefImage.State;
049import org.openstreetmap.josm.data.imagery.ImageryInfo;
050import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
051import org.openstreetmap.josm.data.imagery.ImageryLayerInfo;
052import org.openstreetmap.josm.data.imagery.WmsCache;
053import org.openstreetmap.josm.data.imagery.types.ObjectFactory;
054import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
055import org.openstreetmap.josm.data.preferences.BooleanProperty;
056import org.openstreetmap.josm.data.preferences.IntegerProperty;
057import org.openstreetmap.josm.data.projection.Projection;
058import org.openstreetmap.josm.gui.MapView;
059import org.openstreetmap.josm.gui.MapView.LayerChangeListener;
060import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
061import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
062import org.openstreetmap.josm.gui.progress.ProgressMonitor;
063import org.openstreetmap.josm.io.WMSLayerImporter;
064import org.openstreetmap.josm.io.imagery.Grabber;
065import org.openstreetmap.josm.io.imagery.HTMLGrabber;
066import org.openstreetmap.josm.io.imagery.WMSGrabber;
067import org.openstreetmap.josm.io.imagery.WMSRequest;
068import org.openstreetmap.josm.tools.ImageProvider;
069
070/**
071 * This is a layer that grabs the current screen from an WMS server. The data
072 * fetched this way is tiled and managed to the disc to reduce server load.
073 */
074public class WMSLayer extends ImageryLayer implements ImageObserver, PreferenceChangedListener, Externalizable {
075
076    public static class PrecacheTask {
077        private final ProgressMonitor progressMonitor;
078        private volatile int totalCount;
079        private volatile int processedCount;
080        private volatile boolean isCancelled;
081
082        public PrecacheTask(ProgressMonitor progressMonitor) {
083            this.progressMonitor = progressMonitor;
084        }
085
086        public boolean isFinished() {
087            return totalCount == processedCount;
088        }
089
090        public int getTotalCount() {
091            return totalCount;
092        }
093
094        public void cancel() {
095            isCancelled = true;
096        }
097    }
098
099    // Fake reference to keep build scripts from removing ObjectFactory class. This class is not used directly but it's necessary for jaxb to work
100    private static final ObjectFactory OBJECT_FACTORY = null;
101
102    // these values correspond to the zoom levels used throughout OSM and are in meters/pixel from zoom level 0 to 18.
103    // taken from http://wiki.openstreetmap.org/wiki/Zoom_levels
104    private static final Double[] snapLevels = { 156412.0, 78206.0, 39103.0, 19551.0, 9776.0, 4888.0,
105        2444.0, 1222.0, 610.984, 305.492, 152.746, 76.373, 38.187, 19.093, 9.547, 4.773, 2.387, 1.193, 0.596 };
106
107    public static final BooleanProperty PROP_ALPHA_CHANNEL = new BooleanProperty("imagery.wms.alpha_channel", true);
108    public static final IntegerProperty PROP_SIMULTANEOUS_CONNECTIONS = new IntegerProperty("imagery.wms.simultaneousConnections", 3);
109    public static final BooleanProperty PROP_OVERLAP = new BooleanProperty("imagery.wms.overlap", false);
110    public static final IntegerProperty PROP_OVERLAP_EAST = new IntegerProperty("imagery.wms.overlapEast", 14);
111    public static final IntegerProperty PROP_OVERLAP_NORTH = new IntegerProperty("imagery.wms.overlapNorth", 4);
112    public static final IntegerProperty PROP_IMAGE_SIZE = new IntegerProperty("imagery.wms.imageSize", 500);
113    public static final BooleanProperty PROP_DEFAULT_AUTOZOOM = new BooleanProperty("imagery.wms.default_autozoom", true);
114
115    public int messageNum = 5; //limit for messages per layer
116    protected double resolution;
117    protected String resolutionText;
118    protected int imageSize;
119    protected int dax = 10;
120    protected int day = 10;
121    protected int daStep = 5;
122    protected int minZoom = 3;
123
124    protected GeorefImage[][] images;
125    protected static final int serializeFormatVersion = 5;
126    protected boolean autoDownloadEnabled = true;
127    protected boolean autoResolutionEnabled = PROP_DEFAULT_AUTOZOOM.get();
128    protected boolean settingsChanged;
129    public WmsCache cache;
130    private AttributionSupport attribution = new AttributionSupport();
131
132    // Image index boundary for current view
133    private volatile int bminx;
134    private volatile int bminy;
135    private volatile int bmaxx;
136    private volatile int bmaxy;
137    private volatile int leftEdge;
138    private volatile int bottomEdge;
139
140    // Request queue
141    private final List<WMSRequest> requestQueue = new ArrayList<>();
142    private final List<WMSRequest> finishedRequests = new ArrayList<>();
143    /**
144     * List of request currently being processed by download threads
145     */
146    private final List<WMSRequest> processingRequests = new ArrayList<>();
147    private final Lock requestQueueLock = new ReentrantLock();
148    private final Condition queueEmpty = requestQueueLock.newCondition();
149    private final List<Grabber> grabbers = new ArrayList<>();
150    private final List<Thread> grabberThreads = new ArrayList<>();
151    private boolean canceled;
152
153    /** set to true if this layer uses an invalid base url */
154    private boolean usesInvalidUrl = false;
155    /** set to true if the user confirmed to use an potentially invalid WMS base url */
156    private boolean isInvalidUrlConfirmed = false;
157
158    /**
159     * Constructs a new {@code WMSLayer}.
160     */
161    public WMSLayer() {
162        this(new ImageryInfo(tr("Blank Layer")));
163    }
164
165    public WMSLayer(ImageryInfo info) {
166        super(info);
167        imageSize = PROP_IMAGE_SIZE.get();
168        setBackgroundLayer(true); /* set global background variable */
169        initializeImages();
170
171        attribution.initialize(this.info);
172
173        Main.pref.addPreferenceChangeListener(this);
174    }
175
176    @Override
177    public void hookUpMapView() {
178        if (info.getUrl() != null) {
179            startGrabberThreads();
180
181            for (WMSLayer layer: Main.map.mapView.getLayersOfType(WMSLayer.class)) {
182                if (layer.getInfo().getUrl().equals(info.getUrl())) {
183                    cache = layer.cache;
184                    break;
185                }
186            }
187            if (cache == null) {
188                cache = new WmsCache(info.getUrl(), imageSize);
189                cache.loadIndex();
190            }
191        }
192
193        // if automatic resolution is enabled, ensure that the first zoom level
194        // is already snapped. Otherwise it may load tiles that will never get
195        // used again when zooming.
196        updateResolutionSetting(this, autoResolutionEnabled);
197
198        final MouseAdapter adapter = new MouseAdapter() {
199            @Override
200            public void mouseClicked(MouseEvent e) {
201                if (!isVisible()) return;
202                if (e.getButton() == MouseEvent.BUTTON1) {
203                    attribution.handleAttribution(e.getPoint(), true);
204                }
205            }
206        };
207        Main.map.mapView.addMouseListener(adapter);
208
209        MapView.addLayerChangeListener(new LayerChangeListener() {
210            @Override
211            public void activeLayerChange(Layer oldLayer, Layer newLayer) {
212                //
213            }
214
215            @Override
216            public void layerAdded(Layer newLayer) {
217                //
218            }
219
220            @Override
221            public void layerRemoved(Layer oldLayer) {
222                if (oldLayer == WMSLayer.this) {
223                    Main.map.mapView.removeMouseListener(adapter);
224                    MapView.removeLayerChangeListener(this);
225                }
226            }
227        });
228    }
229
230    public void doSetName(String name) {
231        setName(name);
232        info.setName(name);
233    }
234
235    public boolean hasAutoDownload(){
236        return autoDownloadEnabled;
237    }
238
239    public void downloadAreaToCache(PrecacheTask precacheTask, List<LatLon> points, double bufferX, double bufferY) {
240        Set<Point> requestedTiles = new HashSet<>();
241        for (LatLon point: points) {
242            EastNorth minEn = Main.getProjection().latlon2eastNorth(new LatLon(point.lat() - bufferY, point.lon() - bufferX));
243            EastNorth maxEn = Main.getProjection().latlon2eastNorth(new LatLon(point.lat() + bufferY, point.lon() + bufferX));
244            int minX = getImageXIndex(minEn.east());
245            int maxX = getImageXIndex(maxEn.east());
246            int minY = getImageYIndex(minEn.north());
247            int maxY = getImageYIndex(maxEn.north());
248
249            for (int x=minX; x<=maxX; x++) {
250                for (int y=minY; y<=maxY; y++) {
251                    requestedTiles.add(new Point(x, y));
252                }
253            }
254        }
255
256        for (Point p: requestedTiles) {
257            addRequest(new WMSRequest(p.x, p.y, info.getPixelPerDegree(), true, false, precacheTask));
258        }
259
260        precacheTask.progressMonitor.setTicksCount(precacheTask.getTotalCount());
261        precacheTask.progressMonitor.setCustomText(tr("Downloaded {0}/{1} tiles", 0, precacheTask.totalCount));
262    }
263
264    @Override
265    public void destroy() {
266        super.destroy();
267        cancelGrabberThreads(false);
268        Main.pref.removePreferenceChangeListener(this);
269        if (cache != null) {
270            cache.saveIndex();
271        }
272    }
273
274    public final void initializeImages() {
275        GeorefImage[][] old = images;
276        images = new GeorefImage[dax][day];
277        if (old != null) {
278            for (GeorefImage[] row : old) {
279                for (GeorefImage image : row) {
280                    images[modulo(image.getXIndex(), dax)][modulo(image.getYIndex(), day)] = image;
281                }
282            }
283        }
284        for(int x = 0; x<dax; ++x) {
285            for(int y = 0; y<day; ++y) {
286                if (images[x][y] == null) {
287                    images[x][y]= new GeorefImage(this);
288                }
289            }
290        }
291    }
292
293    @Override public ImageryInfo getInfo() {
294        return info;
295    }
296
297    @Override public String getToolTipText() {
298        if(autoDownloadEnabled)
299            return tr("WMS layer ({0}), automatically downloading in zoom {1}", getName(), resolutionText);
300        else
301            return tr("WMS layer ({0}), downloading in zoom {1}", getName(), resolutionText);
302    }
303
304    private int modulo (int a, int b) {
305        return a % b >= 0 ? a%b : a%b+b;
306    }
307
308    private boolean zoomIsTooBig() {
309        //don't download when it's too outzoomed
310        return info.getPixelPerDegree() / getPPD() > minZoom;
311    }
312
313    @Override public void paint(Graphics2D g, final MapView mv, Bounds b) {
314        if(info.getUrl() == null || (usesInvalidUrl && !isInvalidUrlConfirmed)) return;
315
316        if (autoResolutionEnabled && getBestZoom() != mv.getDist100Pixel()) {
317            changeResolution(this, true);
318        }
319
320        settingsChanged = false;
321
322        ProjectionBounds bounds = mv.getProjectionBounds();
323        bminx= getImageXIndex(bounds.minEast);
324        bminy= getImageYIndex(bounds.minNorth);
325        bmaxx= getImageXIndex(bounds.maxEast);
326        bmaxy= getImageYIndex(bounds.maxNorth);
327
328        leftEdge = (int)(bounds.minEast * getPPD());
329        bottomEdge = (int)(bounds.minNorth * getPPD());
330
331        if (zoomIsTooBig()) {
332            for(int x = 0; x<images.length; ++x) {
333                for(int y = 0; y<images[0].length; ++y) {
334                    GeorefImage image = images[x][y];
335                    image.paint(g, mv, image.getXIndex(), image.getYIndex(), leftEdge, bottomEdge);
336                }
337            }
338        } else {
339            downloadAndPaintVisible(g, mv, false);
340        }
341
342        attribution.paintAttribution(g, mv.getWidth(), mv.getHeight(), null, null, 0, this);
343    }
344
345    @Override
346    public void setOffset(double dx, double dy) {
347        super.setOffset(dx, dy);
348        settingsChanged = true;
349    }
350
351    public int getImageXIndex(double coord) {
352        return (int)Math.floor( ((coord - dx) * info.getPixelPerDegree()) / imageSize);
353    }
354
355    public int getImageYIndex(double coord) {
356        return (int)Math.floor( ((coord - dy) * info.getPixelPerDegree()) / imageSize);
357    }
358
359    public int getImageX(int imageIndex) {
360        return (int)(imageIndex * imageSize * (getPPD() / info.getPixelPerDegree()) + dx * getPPD());
361    }
362
363    public int getImageY(int imageIndex) {
364        return (int)(imageIndex * imageSize * (getPPD() / info.getPixelPerDegree()) + dy * getPPD());
365    }
366
367    public int getImageWidth(int xIndex) {
368        return getImageX(xIndex + 1) - getImageX(xIndex);
369    }
370
371    public int getImageHeight(int yIndex) {
372        return getImageY(yIndex + 1) - getImageY(yIndex);
373    }
374
375    /**
376     *
377     * @return Size of image in original zoom
378     */
379    public int getBaseImageWidth() {
380        int overlap = PROP_OVERLAP.get() ? (PROP_OVERLAP_EAST.get() * imageSize / 100) : 0;
381        return imageSize + overlap;
382    }
383
384    /**
385     *
386     * @return Size of image in original zoom
387     */
388    public int getBaseImageHeight() {
389        int overlap = PROP_OVERLAP.get() ? (PROP_OVERLAP_NORTH.get() * imageSize / 100) : 0;
390        return imageSize + overlap;
391    }
392
393    public int getImageSize() {
394        return imageSize;
395    }
396
397    public boolean isOverlapEnabled() {
398        return WMSLayer.PROP_OVERLAP.get() && (WMSLayer.PROP_OVERLAP_EAST.get() > 0 || WMSLayer.PROP_OVERLAP_NORTH.get() > 0);
399    }
400
401    /**
402     *
403     * @return When overlapping is enabled, return visible part of tile. Otherwise return original image
404     */
405    public BufferedImage normalizeImage(BufferedImage img) {
406        if (isOverlapEnabled()) {
407            BufferedImage copy = img;
408            img = new BufferedImage(imageSize, imageSize, copy.getType());
409            img.createGraphics().drawImage(copy, 0, 0, imageSize, imageSize,
410                    0, copy.getHeight() - imageSize, imageSize, copy.getHeight(), null);
411        }
412        return img;
413    }
414
415    /**
416     *
417     * @param xIndex
418     * @param yIndex
419     * @return Real EastNorth of given tile. dx/dy is not counted in
420     */
421    public EastNorth getEastNorth(int xIndex, int yIndex) {
422        return new EastNorth((xIndex * imageSize) / info.getPixelPerDegree(), (yIndex * imageSize) / info.getPixelPerDegree());
423    }
424
425    protected void downloadAndPaintVisible(Graphics g, final MapView mv, boolean real){
426
427        int newDax = dax;
428        int newDay = day;
429
430        if (bmaxx - bminx >= dax || bmaxx - bminx < dax - 2 * daStep) {
431            newDax = ((bmaxx - bminx) / daStep + 1) * daStep;
432        }
433
434        if (bmaxy - bminy >= day || bmaxy - bminx < day - 2 * daStep) {
435            newDay = ((bmaxy - bminy) / daStep + 1) * daStep;
436        }
437
438        if (newDax != dax || newDay != day) {
439            dax = newDax;
440            day = newDay;
441            initializeImages();
442        }
443
444        for(int x = bminx; x<=bmaxx; ++x) {
445            for(int y = bminy; y<=bmaxy; ++y){
446                images[modulo(x,dax)][modulo(y,day)].changePosition(x, y);
447            }
448        }
449
450        gatherFinishedRequests();
451        Set<ProjectionBounds> areaToCache = new HashSet<>();
452
453        for(int x = bminx; x<=bmaxx; ++x) {
454            for(int y = bminy; y<=bmaxy; ++y){
455                GeorefImage img = images[modulo(x,dax)][modulo(y,day)];
456                if (!img.paint(g, mv, x, y, leftEdge, bottomEdge)) {
457                    addRequest(new WMSRequest(x, y, info.getPixelPerDegree(), real, true));
458                    areaToCache.add(new ProjectionBounds(getEastNorth(x, y), getEastNorth(x + 1, y + 1)));
459                } else if (img.getState() == State.PARTLY_IN_CACHE && autoDownloadEnabled) {
460                    addRequest(new WMSRequest(x, y, info.getPixelPerDegree(), real, false));
461                    areaToCache.add(new ProjectionBounds(getEastNorth(x, y), getEastNorth(x + 1, y + 1)));
462                }
463            }
464        }
465        if (cache != null) {
466            cache.setAreaToCache(areaToCache);
467        }
468    }
469
470    @Override public void visitBoundingBox(BoundingXYVisitor v) {
471        for(int x = 0; x<dax; ++x) {
472            for(int y = 0; y<day; ++y)
473                if(images[x][y].getImage() != null){
474                    v.visit(images[x][y].getMin());
475                    v.visit(images[x][y].getMax());
476                }
477        }
478    }
479
480    @Override public Action[] getMenuEntries() {
481        return new Action[]{
482                LayerListDialog.getInstance().createActivateLayerAction(this),
483                LayerListDialog.getInstance().createShowHideLayerAction(),
484                LayerListDialog.getInstance().createDeleteLayerAction(),
485                SeparatorLayerAction.INSTANCE,
486                new OffsetAction(),
487                new LayerSaveAction(this),
488                new LayerSaveAsAction(this),
489                new BookmarkWmsAction(),
490                SeparatorLayerAction.INSTANCE,
491                new StartStopAction(),
492                new ToggleAlphaAction(),
493                new ToggleAutoResolutionAction(),
494                new ChangeResolutionAction(),
495                new ZoomToNativeResolution(),
496                new ReloadErrorTilesAction(),
497                new DownloadAction(),
498                SeparatorLayerAction.INSTANCE,
499                new LayerListPopup.InfoAction(this)
500        };
501    }
502
503    public GeorefImage findImage(EastNorth eastNorth) {
504        int xIndex = getImageXIndex(eastNorth.east());
505        int yIndex = getImageYIndex(eastNorth.north());
506        GeorefImage result = images[modulo(xIndex, dax)][modulo(yIndex, day)];
507        if (result.getXIndex() == xIndex && result.getYIndex() == yIndex)
508            return result;
509        else
510            return null;
511    }
512
513    /**
514     *
515     * @param request
516     * @return -1 if request is no longer needed, otherwise priority of request (lower number &lt;=&gt; more important request)
517     */
518    private int getRequestPriority(WMSRequest request) {
519        if (request.getPixelPerDegree() != info.getPixelPerDegree())
520            return -1;
521        if (bminx > request.getXIndex()
522                || bmaxx < request.getXIndex()
523                || bminy > request.getYIndex()
524                || bmaxy < request.getYIndex())
525            return -1;
526
527        MouseEvent lastMEvent = Main.map.mapView.lastMEvent;
528        EastNorth cursorEastNorth = Main.map.mapView.getEastNorth(lastMEvent.getX(), lastMEvent.getY());
529        int mouseX = getImageXIndex(cursorEastNorth.east());
530        int mouseY = getImageYIndex(cursorEastNorth.north());
531        int dx = request.getXIndex() - mouseX;
532        int dy = request.getYIndex() - mouseY;
533
534        return 1 + dx * dx + dy * dy;
535    }
536
537    private void sortRequests(boolean localOnly) {
538        Iterator<WMSRequest> it = requestQueue.iterator();
539        while (it.hasNext()) {
540            WMSRequest item = it.next();
541
542            if (item.getPrecacheTask() != null && item.getPrecacheTask().isCancelled) {
543                it.remove();
544                continue;
545            }
546
547            int priority = getRequestPriority(item);
548            if (priority == -1 && item.isPrecacheOnly()) {
549                priority = Integer.MAX_VALUE; // Still download, but prefer requests in current view
550            }
551
552            if (localOnly && !item.hasExactMatch()) {
553                priority = Integer.MAX_VALUE; // Only interested in tiles that can be loaded from file immediately
554            }
555
556            if (       priority == -1
557                    || finishedRequests.contains(item)
558                    || processingRequests.contains(item)) {
559                it.remove();
560            } else {
561                item.setPriority(priority);
562            }
563        }
564        Collections.sort(requestQueue);
565    }
566
567    public WMSRequest getRequest(boolean localOnly) {
568        requestQueueLock.lock();
569        try {
570            sortRequests(localOnly);
571            while (!canceled && (requestQueue.isEmpty() || (localOnly && !requestQueue.get(0).hasExactMatch()))) {
572                try {
573                    queueEmpty.await();
574                    sortRequests(localOnly);
575                } catch (InterruptedException e) {
576                    Main.warn("InterruptedException in "+getClass().getSimpleName()+" during WMS request");
577                }
578            }
579
580            if (canceled)
581                return null;
582            else {
583                WMSRequest request = requestQueue.remove(0);
584                processingRequests.add(request);
585                return request;
586            }
587
588        } finally {
589            requestQueueLock.unlock();
590        }
591    }
592
593    public void finishRequest(WMSRequest request) {
594        requestQueueLock.lock();
595        try {
596            PrecacheTask task = request.getPrecacheTask();
597            if (task != null) {
598                task.processedCount++;
599                if (!task.progressMonitor.isCanceled()) {
600                    task.progressMonitor.worked(1);
601                    task.progressMonitor.setCustomText(tr("Downloaded {0}/{1} tiles", task.processedCount, task.totalCount));
602                }
603            }
604            processingRequests.remove(request);
605            if (request.getState() != null && !request.isPrecacheOnly()) {
606                finishedRequests.add(request);
607                if (Main.isDisplayingMapView()) {
608                    Main.map.mapView.repaint();
609                }
610            }
611        } finally {
612            requestQueueLock.unlock();
613        }
614    }
615
616    public void addRequest(WMSRequest request) {
617        requestQueueLock.lock();
618        try {
619
620            if (cache != null) {
621                ProjectionBounds b = getBounds(request);
622                // Checking for exact match is fast enough, no need to do it in separated thread
623                request.setHasExactMatch(cache.hasExactMatch(Main.getProjection(), request.getPixelPerDegree(), b.minEast, b.minNorth));
624                if (request.isPrecacheOnly() && request.hasExactMatch())
625                    return; // We already have this tile cached
626            }
627
628            if (!requestQueue.contains(request) && !finishedRequests.contains(request) && !processingRequests.contains(request)) {
629                requestQueue.add(request);
630                if (request.getPrecacheTask() != null) {
631                    request.getPrecacheTask().totalCount++;
632                }
633                queueEmpty.signalAll();
634            }
635        } finally {
636            requestQueueLock.unlock();
637        }
638    }
639
640    public boolean requestIsVisible(WMSRequest request) {
641        return bminx <= request.getXIndex() && bmaxx >= request.getXIndex() && bminy <= request.getYIndex() && bmaxy >= request.getYIndex();
642    }
643
644    private void gatherFinishedRequests() {
645        requestQueueLock.lock();
646        try {
647            for (WMSRequest request: finishedRequests) {
648                GeorefImage img = images[modulo(request.getXIndex(),dax)][modulo(request.getYIndex(),day)];
649                if (img.equalPosition(request.getXIndex(), request.getYIndex())) {
650                    img.changeImage(request.getState(), request.getImage());
651                }
652            }
653        } finally {
654            requestQueueLock.unlock();
655            finishedRequests.clear();
656        }
657    }
658
659    public class DownloadAction extends AbstractAction {
660        /**
661         * Constructs a new {@code DownloadAction}.
662         */
663        public DownloadAction() {
664            super(tr("Download visible tiles"));
665        }
666        @Override
667        public void actionPerformed(ActionEvent ev) {
668            if (zoomIsTooBig()) {
669                JOptionPane.showMessageDialog(
670                        Main.parent,
671                        tr("The requested area is too big. Please zoom in a little, or change resolution"),
672                        tr("Error"),
673                        JOptionPane.ERROR_MESSAGE
674                        );
675            } else {
676                downloadAndPaintVisible(Main.map.mapView.getGraphics(), Main.map.mapView, true);
677            }
678        }
679    }
680
681    /**
682     * Finds the most suitable resolution for the current zoom level, but prefers
683     * higher resolutions. Snaps to values defined in snapLevels.
684     * @return best zoom level
685     */
686    private static double getBestZoom() {
687        // not sure why getDist100Pixel returns values corresponding to
688        // the snapLevels, which are in meters per pixel. It works, though.
689        double dist = Main.map.mapView.getDist100Pixel();
690        for(int i = snapLevels.length-2; i >= 0; i--) {
691            if(snapLevels[i+1]/3 + snapLevels[i]*2/3 > dist)
692                return snapLevels[i+1];
693        }
694        return snapLevels[0];
695    }
696
697    /**
698     * Updates the given layer’s resolution settings to the current zoom level. Does
699     * not update existing tiles, only new ones will be subject to the new settings.
700     *
701     * @param layer
702     * @param snap  Set to true if the resolution should snap to certain values instead of
703     *              matching the current zoom level perfectly
704     */
705    private static void updateResolutionSetting(WMSLayer layer, boolean snap) {
706        if(snap) {
707            layer.resolution = getBestZoom();
708            layer.resolutionText = MapView.getDistText(layer.resolution);
709        } else {
710            layer.resolution = Main.map.mapView.getDist100Pixel();
711            layer.resolutionText = Main.map.mapView.getDist100PixelText();
712        }
713        layer.info.setPixelPerDegree(layer.getPPD());
714    }
715
716    /**
717     * Updates the given layer’s resolution settings to the current zoom level and
718     * updates existing tiles. If round is true, tiles will be updated gradually, if
719     * false they will be removed instantly (and redrawn only after the new resolution
720     * image has been loaded).
721     * @param layer
722     * @param snap  Set to true if the resolution should snap to certain values instead of
723     *              matching the current zoom level perfectly
724     */
725    private static void changeResolution(WMSLayer layer, boolean snap) {
726        updateResolutionSetting(layer, snap);
727
728        layer.settingsChanged = true;
729
730        // Don’t move tiles off screen when the resolution is rounded. This
731        // prevents some flickering when zooming with auto-resolution enabled
732        // and instead gradually updates each tile.
733        if(!snap) {
734            for(int x = 0; x<layer.dax; ++x) {
735                for(int y = 0; y<layer.day; ++y) {
736                    layer.images[x][y].changePosition(-1, -1);
737                }
738            }
739        }
740    }
741
742    public static class ChangeResolutionAction extends AbstractAction implements LayerAction {
743
744        /**
745         * Constructs a new {@code ChangeResolutionAction}
746         */
747        public ChangeResolutionAction() {
748            super(tr("Change resolution"));
749        }
750
751        @Override
752        public void actionPerformed(ActionEvent ev) {
753            List<Layer> layers = LayerListDialog.getInstance().getModel().getSelectedLayers();
754            for (Layer l: layers) {
755                changeResolution((WMSLayer) l, false);
756            }
757            Main.map.mapView.repaint();
758        }
759
760        @Override
761        public boolean supportLayers(List<Layer> layers) {
762            for (Layer l: layers) {
763                if (!(l instanceof WMSLayer))
764                    return false;
765            }
766            return true;
767        }
768
769        @Override
770        public Component createMenuComponent() {
771            return new JMenuItem(this);
772        }
773    }
774
775    public class ReloadErrorTilesAction extends AbstractAction {
776        /**
777         * Constructs a new {@code ReloadErrorTilesAction}.
778         */
779        public ReloadErrorTilesAction() {
780            super(tr("Reload erroneous tiles"));
781        }
782        @Override
783        public void actionPerformed(ActionEvent ev) {
784            // Delete small files, because they're probably blank tiles.
785            // See #2307
786            cache.cleanSmallFiles(4096);
787
788            for (int x = 0; x < dax; ++x) {
789                for (int y = 0; y < day; ++y) {
790                    GeorefImage img = images[modulo(x,dax)][modulo(y,day)];
791                    if(img.getState() == State.FAILED){
792                        addRequest(new WMSRequest(img.getXIndex(), img.getYIndex(), info.getPixelPerDegree(), true, false));
793                    }
794                }
795            }
796        }
797    }
798
799    public class ToggleAlphaAction extends AbstractAction implements LayerAction {
800        /**
801         * Constructs a new {@code ToggleAlphaAction}.
802         */
803        public ToggleAlphaAction() {
804            super(tr("Alpha channel"));
805        }
806        @Override
807        public void actionPerformed(ActionEvent ev) {
808            JCheckBoxMenuItem checkbox = (JCheckBoxMenuItem) ev.getSource();
809            boolean alphaChannel = checkbox.isSelected();
810            PROP_ALPHA_CHANNEL.put(alphaChannel);
811            Main.info("WMS Alpha channel changed to "+alphaChannel);
812
813            // clear all resized cached instances and repaint the layer
814            for (int x = 0; x < dax; ++x) {
815                for (int y = 0; y < day; ++y) {
816                    GeorefImage img = images[modulo(x, dax)][modulo(y, day)];
817                    img.flushResizedCachedInstance();
818                    BufferedImage bi = img.getImage();
819                    // Completely erases images for which transparency has been forced,
820                    // or images that should be forced now, as they need to be recreated
821                    if (ImageProvider.isTransparencyForced(bi) || ImageProvider.hasTransparentColor(bi)) {
822                        img.resetImage();
823                    }
824                }
825            }
826            Main.map.mapView.repaint();
827        }
828
829        @Override
830        public Component createMenuComponent() {
831            JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
832            item.setSelected(PROP_ALPHA_CHANNEL.get());
833            return item;
834        }
835
836        @Override
837        public boolean supportLayers(List<Layer> layers) {
838            return layers.size() == 1 && layers.get(0) instanceof WMSLayer;
839        }
840    }
841
842    public class ToggleAutoResolutionAction extends AbstractAction implements LayerAction {
843
844        /**
845         * Constructs a new {@code ToggleAutoResolutionAction}.
846         */
847        public ToggleAutoResolutionAction() {
848            super(tr("Automatically change resolution"));
849        }
850
851        @Override
852        public void actionPerformed(ActionEvent ev) {
853            JCheckBoxMenuItem checkbox = (JCheckBoxMenuItem) ev.getSource();
854            autoResolutionEnabled = checkbox.isSelected();
855        }
856
857        @Override
858        public Component createMenuComponent() {
859            JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
860            item.setSelected(autoResolutionEnabled);
861            return item;
862        }
863
864        @Override
865        public boolean supportLayers(List<Layer> layers) {
866            return layers.size() == 1 && layers.get(0) instanceof WMSLayer;
867        }
868    }
869
870    /**
871     * This action will add a WMS layer menu entry with the current WMS layer
872     * URL and name extended by the current resolution.
873     * When using the menu entry again, the WMS cache will be used properly.
874     */
875    public class BookmarkWmsAction extends AbstractAction {
876        /**
877         * Constructs a new {@code BookmarkWmsAction}.
878         */
879        public BookmarkWmsAction() {
880            super(tr("Set WMS Bookmark"));
881        }
882        @Override
883        public void actionPerformed(ActionEvent ev) {
884            ImageryLayerInfo.addLayer(new ImageryInfo(info));
885        }
886    }
887
888    private class StartStopAction extends AbstractAction implements LayerAction {
889
890        public StartStopAction() {
891            super(tr("Automatic downloading"));
892        }
893
894        @Override
895        public Component createMenuComponent() {
896            JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
897            item.setSelected(autoDownloadEnabled);
898            return item;
899        }
900
901        @Override
902        public boolean supportLayers(List<Layer> layers) {
903            return layers.size() == 1 && layers.get(0) instanceof WMSLayer;
904        }
905
906        @Override
907        public void actionPerformed(ActionEvent e) {
908            autoDownloadEnabled = !autoDownloadEnabled;
909            if (autoDownloadEnabled) {
910                for (int x = 0; x < dax; ++x) {
911                    for (int y = 0; y < day; ++y) {
912                        GeorefImage img = images[modulo(x,dax)][modulo(y,day)];
913                        if(img.getState() == State.NOT_IN_CACHE){
914                            addRequest(new WMSRequest(img.getXIndex(), img.getYIndex(), info.getPixelPerDegree(), false, true));
915                        }
916                    }
917                }
918                Main.map.mapView.repaint();
919            }
920        }
921    }
922
923    private class ZoomToNativeResolution extends AbstractAction {
924
925        public ZoomToNativeResolution() {
926            super(tr("Zoom to native resolution"));
927        }
928
929        @Override
930        public void actionPerformed(ActionEvent e) {
931            Main.map.mapView.zoomTo(Main.map.mapView.getCenter(), 1 / info.getPixelPerDegree());
932        }
933
934    }
935
936    private void cancelGrabberThreads(boolean wait) {
937        requestQueueLock.lock();
938        try {
939            canceled = true;
940            for (Grabber grabber: grabbers) {
941                grabber.cancel();
942            }
943            queueEmpty.signalAll();
944        } finally {
945            requestQueueLock.unlock();
946        }
947        if (wait) {
948            for (Thread t: grabberThreads) {
949                try {
950                    t.join();
951                } catch (InterruptedException e) {
952                    Main.warn("InterruptedException in "+getClass().getSimpleName()+" while cancelling grabber threads");
953                }
954            }
955        }
956    }
957
958    private void startGrabberThreads() {
959        int threadCount = PROP_SIMULTANEOUS_CONNECTIONS.get();
960        requestQueueLock.lock();
961        try {
962            canceled = false;
963            grabbers.clear();
964            grabberThreads.clear();
965            for (int i=0; i<threadCount; i++) {
966                Grabber grabber = getGrabber(i == 0 && threadCount > 1);
967                grabbers.add(grabber);
968                Thread t = new Thread(grabber, "WMS " + getName() + " " + i);
969                t.setDaemon(true);
970                t.start();
971                grabberThreads.add(t);
972            }
973        } finally {
974            requestQueueLock.unlock();
975        }
976    }
977
978    @Override
979    public boolean isChanged() {
980        requestQueueLock.lock();
981        try {
982            return !finishedRequests.isEmpty() || settingsChanged;
983        } finally {
984            requestQueueLock.unlock();
985        }
986    }
987
988    @Override
989    public void preferenceChanged(PreferenceChangeEvent event) {
990        if (event.getKey().equals(PROP_SIMULTANEOUS_CONNECTIONS.getKey()) && info.getUrl() != null) {
991            cancelGrabberThreads(true);
992            startGrabberThreads();
993        } else if (
994                event.getKey().equals(PROP_OVERLAP.getKey())
995                || event.getKey().equals(PROP_OVERLAP_EAST.getKey())
996                || event.getKey().equals(PROP_OVERLAP_NORTH.getKey())) {
997            for (int i=0; i<images.length; i++) {
998                for (int k=0; k<images[i].length; k++) {
999                    images[i][k] = new GeorefImage(this);
1000                }
1001            }
1002
1003            settingsChanged = true;
1004        }
1005    }
1006
1007    protected Grabber getGrabber(boolean localOnly) {
1008        if (getInfo().getImageryType() == ImageryType.HTML)
1009            return new HTMLGrabber(Main.map.mapView, this, localOnly);
1010        else if (getInfo().getImageryType() == ImageryType.WMS)
1011            return new WMSGrabber(Main.map.mapView, this, localOnly);
1012        else throw new IllegalStateException("getGrabber() called for non-WMS layer type");
1013    }
1014
1015    public ProjectionBounds getBounds(WMSRequest request) {
1016        ProjectionBounds result = new ProjectionBounds(
1017                getEastNorth(request.getXIndex(), request.getYIndex()),
1018                getEastNorth(request.getXIndex() + 1, request.getYIndex() + 1));
1019
1020        if (WMSLayer.PROP_OVERLAP.get()) {
1021            double eastSize =  result.maxEast - result.minEast;
1022            double northSize =  result.maxNorth - result.minNorth;
1023
1024            double eastCoef = WMSLayer.PROP_OVERLAP_EAST.get() / 100.0;
1025            double northCoef = WMSLayer.PROP_OVERLAP_NORTH.get() / 100.0;
1026
1027            result = new ProjectionBounds(result.getMin(),
1028                    new EastNorth(result.maxEast + eastCoef * eastSize,
1029                            result.maxNorth + northCoef * northSize));
1030        }
1031        return result;
1032    }
1033
1034    @Override
1035    public boolean isProjectionSupported(Projection proj) {
1036        List<String> serverProjections = info.getServerProjections();
1037        return serverProjections.contains(proj.toCode().toUpperCase())
1038                || ("EPSG:3857".equals(proj.toCode()) && (serverProjections.contains("EPSG:4326") || serverProjections.contains("CRS:84")))
1039                || ("EPSG:4326".equals(proj.toCode()) && serverProjections.contains("CRS:84"));
1040    }
1041
1042    @Override
1043    public String nameSupportedProjections() {
1044        StringBuilder res = new StringBuilder();
1045        for (String p : info.getServerProjections()) {
1046            if (res.length() > 0) {
1047                res.append(", ");
1048            }
1049            res.append(p);
1050        }
1051        return tr("Supported projections are: {0}", res);
1052    }
1053
1054    @Override
1055    public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) {
1056        boolean done = ((infoflags & (ERROR | FRAMEBITS | ALLBITS)) != 0);
1057        Main.map.repaint(done ? 0 : 100);
1058        return !done;
1059    }
1060
1061    @Override
1062    public void writeExternal(ObjectOutput out) throws IOException {
1063        out.writeInt(serializeFormatVersion);
1064        out.writeInt(dax);
1065        out.writeInt(day);
1066        out.writeInt(imageSize);
1067        out.writeDouble(info.getPixelPerDegree());
1068        out.writeObject(info.getName());
1069        out.writeObject(info.getExtendedUrl());
1070        out.writeObject(images);
1071    }
1072
1073    @Override
1074    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
1075        int sfv = in.readInt();
1076        if (sfv != serializeFormatVersion)
1077            throw new InvalidClassException(tr("Unsupported WMS file version; found {0}, expected {1}", sfv, serializeFormatVersion));
1078        autoDownloadEnabled = false;
1079        dax = in.readInt();
1080        day = in.readInt();
1081        imageSize = in.readInt();
1082        info.setPixelPerDegree(in.readDouble());
1083        doSetName((String)in.readObject());
1084        info.setExtendedUrl((String)in.readObject());
1085        images = (GeorefImage[][])in.readObject();
1086
1087        for (GeorefImage[] imgs : images) {
1088            for (GeorefImage img : imgs) {
1089                if (img != null) {
1090                    img.setLayer(WMSLayer.this);
1091                }
1092            }
1093        }
1094
1095        settingsChanged = true;
1096        if (Main.isDisplayingMapView()) {
1097            Main.map.mapView.repaint();
1098        }
1099        if (cache != null) {
1100            cache.saveIndex();
1101            cache = null;
1102        }
1103    }
1104
1105    @Override
1106    public void onPostLoadFromFile() {
1107        if (info.getUrl() != null) {
1108            cache = new WmsCache(info.getUrl(), imageSize);
1109            startGrabberThreads();
1110        }
1111    }
1112
1113    @Override
1114    public boolean isSavable() {
1115        return true; // With WMSLayerExporter
1116    }
1117
1118    @Override
1119    public File createAndOpenSaveFileChooser() {
1120        return SaveActionBase.createAndOpenSaveFileChooser(tr("Save WMS file"), WMSLayerImporter.FILE_FILTER);
1121    }
1122}