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