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