001// License: GPL. For details, see Readme.txt file.
002package org.openstreetmap.gui.jmapviewer.tilesources;
003
004import java.awt.Image;
005import java.io.IOException;
006import java.io.InputStream;
007import java.net.MalformedURLException;
008import java.net.URL;
009import java.util.ArrayList;
010import java.util.List;
011import java.util.Locale;
012import java.util.concurrent.Callable;
013import java.util.concurrent.ExecutionException;
014import java.util.concurrent.Executors;
015import java.util.concurrent.Future;
016import java.util.concurrent.TimeUnit;
017import java.util.concurrent.TimeoutException;
018import java.util.regex.Pattern;
019
020import javax.imageio.ImageIO;
021import javax.xml.parsers.DocumentBuilder;
022import javax.xml.parsers.DocumentBuilderFactory;
023import javax.xml.parsers.ParserConfigurationException;
024import javax.xml.xpath.XPath;
025import javax.xml.xpath.XPathConstants;
026import javax.xml.xpath.XPathExpression;
027import javax.xml.xpath.XPathExpressionException;
028import javax.xml.xpath.XPathFactory;
029
030import org.openstreetmap.gui.jmapviewer.Coordinate;
031import org.openstreetmap.gui.jmapviewer.JMapViewer;
032import org.w3c.dom.Document;
033import org.w3c.dom.Node;
034import org.w3c.dom.NodeList;
035import org.xml.sax.InputSource;
036import org.xml.sax.SAXException;
037
038public class BingAerialTileSource extends AbstractTMSTileSource {
039
040    private static String API_KEY = "Arzdiw4nlOJzRwOz__qailc8NiR31Tt51dN2D7cm57NrnceZnCpgOkmJhNpGoppU";
041    private static volatile Future<List<Attribution>> attributions; // volatile is required for getAttribution(), see below.
042    private static String imageUrlTemplate;
043    private static Integer imageryZoomMax;
044    private static String[] subdomains;
045
046    private static final Pattern subdomainPattern = Pattern.compile("\\{subdomain\\}");
047    private static final Pattern quadkeyPattern = Pattern.compile("\\{quadkey\\}");
048    private static final Pattern culturePattern = Pattern.compile("\\{culture\\}");
049    private String brandLogoUri = null;
050
051    /**
052     * Constructs a new {@code BingAerialTileSource}.
053     */
054    public BingAerialTileSource() {
055        super("Bing Aerial Maps", "http://example.com/");
056    }
057
058    protected class Attribution {
059        String attribution;
060        int minZoom;
061        int maxZoom;
062        Coordinate min;
063        Coordinate max;
064    }
065
066    @Override
067    public String getTileUrl(int zoom, int tilex, int tiley) throws IOException {
068        // make sure that attribution is loaded. otherwise subdomains is null.
069        if (getAttribution() == null)
070            throw new IOException("Attribution is not loaded yet");
071
072        int t = (zoom + tilex + tiley) % subdomains.length;
073        String subdomain = subdomains[t];
074
075        String url = imageUrlTemplate;
076        url = subdomainPattern.matcher(url).replaceAll(subdomain);
077        url = quadkeyPattern.matcher(url).replaceAll(computeQuadTree(zoom, tilex, tiley));
078
079        return url;
080    }
081
082    protected URL getAttributionUrl() throws MalformedURLException {
083        return new URL("http://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial?include=ImageryProviders&output=xml&key="
084                + API_KEY);
085    }
086
087    protected List<Attribution> parseAttributionText(InputSource xml) throws IOException {
088        try {
089            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
090            DocumentBuilder builder = factory.newDocumentBuilder();
091            Document document = builder.parse(xml);
092
093            XPathFactory xPathFactory = XPathFactory.newInstance();
094            XPath xpath = xPathFactory.newXPath();
095            imageUrlTemplate = xpath.compile("//ImageryMetadata/ImageUrl/text()").evaluate(document);
096            imageUrlTemplate = culturePattern.matcher(imageUrlTemplate).replaceAll(Locale.getDefault().toString());
097            imageryZoomMax = Integer.parseInt(xpath.compile("//ImageryMetadata/ZoomMax/text()").evaluate(document));
098
099            NodeList subdomainTxt = (NodeList) xpath.compile("//ImageryMetadata/ImageUrlSubdomains/string/text()").evaluate(document, XPathConstants.NODESET);
100            subdomains = new String[subdomainTxt.getLength()];
101            for(int i = 0; i < subdomainTxt.getLength(); i++) {
102                subdomains[i] = subdomainTxt.item(i).getNodeValue();
103            }
104
105            brandLogoUri = xpath.compile("/Response/BrandLogoUri/text()").evaluate(document);
106
107            XPathExpression attributionXpath = xpath.compile("Attribution/text()");
108            XPathExpression coverageAreaXpath = xpath.compile("CoverageArea");
109            XPathExpression zoomMinXpath = xpath.compile("ZoomMin/text()");
110            XPathExpression zoomMaxXpath = xpath.compile("ZoomMax/text()");
111            XPathExpression southLatXpath = xpath.compile("BoundingBox/SouthLatitude/text()");
112            XPathExpression westLonXpath = xpath.compile("BoundingBox/WestLongitude/text()");
113            XPathExpression northLatXpath = xpath.compile("BoundingBox/NorthLatitude/text()");
114            XPathExpression eastLonXpath = xpath.compile("BoundingBox/EastLongitude/text()");
115
116            NodeList imageryProviderNodes = (NodeList) xpath.compile("//ImageryMetadata/ImageryProvider").evaluate(document, XPathConstants.NODESET);
117            List<Attribution> attributions = new ArrayList<>(imageryProviderNodes.getLength());
118            for (int i = 0; i < imageryProviderNodes.getLength(); i++) {
119                Node providerNode = imageryProviderNodes.item(i);
120
121                String attribution = attributionXpath.evaluate(providerNode);
122
123                NodeList coverageAreaNodes = (NodeList) coverageAreaXpath.evaluate(providerNode, XPathConstants.NODESET);
124                for(int j = 0; j < coverageAreaNodes.getLength(); j++) {
125                    Node areaNode = coverageAreaNodes.item(j);
126                    Attribution attr = new Attribution();
127                    attr.attribution = attribution;
128
129                    attr.maxZoom = Integer.parseInt(zoomMaxXpath.evaluate(areaNode));
130                    attr.minZoom = Integer.parseInt(zoomMinXpath.evaluate(areaNode));
131
132                    Double southLat = Double.parseDouble(southLatXpath.evaluate(areaNode));
133                    Double northLat = Double.parseDouble(northLatXpath.evaluate(areaNode));
134                    Double westLon = Double.parseDouble(westLonXpath.evaluate(areaNode));
135                    Double eastLon = Double.parseDouble(eastLonXpath.evaluate(areaNode));
136                    attr.min = new Coordinate(southLat, westLon);
137                    attr.max = new Coordinate(northLat, eastLon);
138
139                    attributions.add(attr);
140                }
141            }
142
143            return attributions;
144        } catch (SAXException e) {
145            System.err.println("Could not parse Bing aerials attribution metadata.");
146            e.printStackTrace();
147        } catch (ParserConfigurationException e) {
148            e.printStackTrace();
149        } catch (XPathExpressionException e) {
150            e.printStackTrace();
151        }
152        return null;
153    }
154
155    @Override
156    public int getMaxZoom() {
157        if(imageryZoomMax != null)
158            return imageryZoomMax;
159        else
160            return 22;
161    }
162
163    @Override
164    public TileUpdate getTileUpdate() {
165        return TileUpdate.IfNoneMatch;
166    }
167
168    @Override
169    public boolean requiresAttribution() {
170        return true;
171    }
172
173    @Override
174    public String getAttributionLinkURL() {
175        //return "http://bing.com/maps"
176        // FIXME: I've set attributionLinkURL temporarily to ToU URL to comply with bing ToU
177        // (the requirement is that we have such a link at the bottom of the window)
178        return "http://go.microsoft.com/?linkid=9710837";
179    }
180
181    @Override
182    public Image getAttributionImage() {
183        try {
184            final InputStream imageResource = JMapViewer.class.getResourceAsStream("images/bing_maps.png");
185            if (imageResource != null) {
186                return ImageIO.read(imageResource);
187            } else {
188                // Some Linux distributions (like Debian) will remove Bing logo from sources, so get it at runtime
189                for (int i = 0; i < 5 && getAttribution() == null; i++) {
190                    // Makes sure attribution is loaded
191                }
192                if (brandLogoUri != null && !brandLogoUri.isEmpty()) {
193                    System.out.println("Reading Bing logo from "+brandLogoUri);
194                    return ImageIO.read(new URL(brandLogoUri));
195                }
196            }
197        } catch (IOException e) {
198            System.err.println("Error while retrieving Bing logo: "+e.getMessage());
199        }
200        return null;
201    }
202
203    @Override
204    public String getAttributionImageURL() {
205        return "http://opengeodata.org/microsoft-imagery-details";
206    }
207
208    @Override
209    public String getTermsOfUseText() {
210        return null;
211    }
212
213    @Override
214    public String getTermsOfUseURL() {
215        return "http://opengeodata.org/microsoft-imagery-details";
216    }
217
218    protected Callable<List<Attribution>> getAttributionLoaderCallable() {
219        return new Callable<List<Attribution>>() {
220
221            @Override
222            public List<Attribution> call() throws Exception {
223                int waitTimeSec = 1;
224                while (true) {
225                    try {
226                        InputSource xml = new InputSource(getAttributionUrl().openStream());
227                        List<Attribution> r = parseAttributionText(xml);
228                        System.out.println("Successfully loaded Bing attribution data.");
229                        return r;
230                    } catch (IOException ex) {
231                        System.err.println("Could not connect to Bing API. Will retry in " + waitTimeSec + " seconds.");
232                        Thread.sleep(waitTimeSec * 1000L);
233                        waitTimeSec *= 2;
234                    }
235                }
236            }
237        };
238    }
239
240    protected List<Attribution> getAttribution() {
241        if (attributions == null) {
242            // see http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
243            synchronized (BingAerialTileSource.class) {
244                if (attributions == null) {
245                    attributions = Executors.newSingleThreadExecutor().submit(getAttributionLoaderCallable());
246                }
247            }
248        }
249        try {
250            return attributions.get(1000, TimeUnit.MILLISECONDS);
251        } catch (TimeoutException ex) {
252            System.err.println("Bing: attribution data is not yet loaded.");
253        } catch (ExecutionException ex) {
254            throw new RuntimeException(ex.getCause());
255        } catch (InterruptedException ign) {
256        }
257        return null;
258    }
259
260    @Override
261    public String getAttributionText(int zoom, Coordinate topLeft, Coordinate botRight) {
262        try {
263            final List<Attribution> data = getAttribution();
264            if (data == null)
265                return "Error loading Bing attribution data";
266            StringBuilder a = new StringBuilder();
267            for (Attribution attr : data) {
268                if (zoom <= attr.maxZoom && zoom >= attr.minZoom) {
269                    if (topLeft.getLon() < attr.max.getLon() && botRight.getLon() > attr.min.getLon()
270                            && topLeft.getLat() > attr.min.getLat() && botRight.getLat() < attr.max.getLat()) {
271                        a.append(attr.attribution);
272                        a.append(" ");
273                    }
274                }
275            }
276            return a.toString();
277        } catch (Exception e) {
278            e.printStackTrace();
279        }
280        return "Error loading Bing attribution data";
281    }
282
283    static String computeQuadTree(int zoom, int tilex, int tiley) {
284        StringBuilder k = new StringBuilder();
285        for (int i = zoom; i > 0; i--) {
286            char digit = 48;
287            int mask = 1 << (i - 1);
288            if ((tilex & mask) != 0) {
289                digit += 1;
290            }
291            if ((tiley & mask) != 0) {
292                digit += 2;
293            }
294            k.append(digit);
295        }
296        return k.toString();
297    }
298}