001package systems.dmx.geomaps; 002 003import systems.dmx.geomaps.model.GeoCoordinate; 004import systems.dmx.geomaps.model.Geomap; 005import systems.dmx.topicmaps.TopicmapsService; 006import systems.dmx.facets.FacetsService; 007 008import systems.dmx.core.Association; 009import systems.dmx.core.AssociationDefinition; 010import systems.dmx.core.ChildTopics; 011import systems.dmx.core.RelatedTopic; 012import systems.dmx.core.Topic; 013import systems.dmx.core.TopicType; 014import systems.dmx.core.model.AssociationModel; 015import systems.dmx.core.model.ChildTopicsModel; 016import systems.dmx.core.model.TopicModel; 017import systems.dmx.core.osgi.PluginActivator; 018import systems.dmx.core.service.Cookies; 019import systems.dmx.core.service.Inject; 020import systems.dmx.core.service.Transactional; 021import systems.dmx.core.service.event.PostCreateTopicListener; 022import systems.dmx.core.service.event.PostUpdateTopicListener; 023import systems.dmx.core.service.event.PreSendTopicListener; 024import systems.dmx.core.util.ContextTracker; 025import systems.dmx.core.util.JavaUtils; 026 027import org.codehaus.jettison.json.JSONObject; 028 029import javax.ws.rs.GET; 030import javax.ws.rs.PUT; 031import javax.ws.rs.HeaderParam; 032import javax.ws.rs.Path; 033import javax.ws.rs.PathParam; 034import javax.ws.rs.QueryParam; 035import javax.ws.rs.Produces; 036import javax.ws.rs.Consumes; 037 038import java.net.URL; 039import java.util.List; 040import java.util.concurrent.Callable; 041import java.util.logging.Level; 042import java.util.logging.Logger; 043 044 045 046@Path("/geomap") 047@Consumes("application/json") 048@Produces("application/json") 049public class GeomapsPlugin extends PluginActivator implements GeomapsService, PostCreateTopicListener, 050 PostUpdateTopicListener, 051 PreSendTopicListener { 052 053 private static final String GEOCODER_URL = "http://maps.googleapis.com/maps/api/geocode/json?" + 054 "address=%s&sensor=false"; 055 056 private static final String COOKIE_NO_GEOCODING = "dmx_no_geocoding"; 057 058 private static final double EARTH_RADIUS_KM = 6371.009; 059 060 // ---------------------------------------------------------------------------------------------- Instance Variables 061 062 @Inject private TopicmapsService topicmapsService; 063 @Inject private FacetsService facetsService; 064 065 // used for geocoding suppression 066 private ContextTracker contextTracker = new ContextTracker(); 067 068 private Logger logger = Logger.getLogger(getClass().getName()); 069 070 // -------------------------------------------------------------------------------------------------- Public Methods 071 072 073 074 // ************************************* 075 // *** GeomapsService Implementation *** 076 // ************************************* 077 078 079 080 @GET 081 @Path("/{id}") 082 @Override 083 public Geomap getGeomap(@PathParam("id") long geomapId) { 084 return new Geomap(geomapId, dmx); 085 } 086 087 // Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter 088 @GET 089 @Path("/topic/{id}") 090 @Override 091 public Topic getDomainTopic(@PathParam("id") long geoCoordId) { 092 try { 093 Topic topic = dmx.getTopic(geoCoordId); 094 RelatedTopic parentTopic; 095 while ((parentTopic = topic.getRelatedTopic(null, "dmx.core.child", "dmx.core.parent", null)) != null) { 096 topic = parentTopic; 097 } 098 return topic; 099 } catch (Exception e) { 100 throw new RuntimeException("Finding the geo coordinate's domain topic failed (geoCoordId=" + 101 geoCoordId + ")", e); 102 } 103 } 104 105 @Override 106 public GeoCoordinate getGeoCoordinate(Topic geoTopic) { 107 try { 108 Topic geoCoordTopic = getGeoCoordinateTopic(geoTopic); 109 if (geoCoordTopic != null) { 110 return geoCoordinate(geoCoordTopic); 111 } else { 112 return null; 113 } 114 } catch (Exception e) { 115 throw new RuntimeException("Getting the geo coordinate failed (geoTopic=" + geoTopic + ")", e); 116 } 117 } 118 119 @Override 120 public GeoCoordinate geoCoordinate(Topic geoCoordTopic) { 121 ChildTopics childTopics = geoCoordTopic.getChildTopics(); 122 return new GeoCoordinate( 123 childTopics.getDouble("dmx.geomaps.longitude"), 124 childTopics.getDouble("dmx.geomaps.latitude") 125 ); 126 } 127 128 @PUT 129 @Path("/{id}/topic/{geo_coord_id}") 130 @Transactional 131 @Override 132 public void addCoordinateToGeomap(@PathParam("id") long geomapId, @PathParam("geo_coord_id") long geoCoordId) { 133 logger.info("### Adding geo coordinate topic " + geoCoordId + " to geomap " + geomapId); 134 AssociationModel model = mf.newAssociationModel("dmx.geomaps.geotopic_mapcontext", 135 mf.newTopicRoleModel(geomapId, "dmx.core.default"), 136 mf.newTopicRoleModel(geoCoordId, "dmx.topicmaps.topicmap_topic") 137 ); 138 dmx.createAssociation(model); 139 } 140 141 @PUT 142 @Path("/{id}/center/{lon}/{lat}/zoom/{zoom}") 143 @Transactional 144 @Override 145 public void setGeomapState(@PathParam("id") long geomapId, @PathParam("lon") double lon, 146 @PathParam("lat") double lat, @PathParam("zoom") int zoom) { 147 ChildTopicsModel geomapState = mf.newChildTopicsModel().put( 148 "dmx.topicmaps.state", mf.newChildTopicsModel().put( 149 "dmx.topicmaps.translation", mf.newChildTopicsModel().put( 150 "dmx.topicmaps.translation_x", lon).put( 151 "dmx.topicmaps.translation_y", lat)).put( 152 "dmx.topicmaps.zoom_level", zoom) 153 ); 154 dmx.updateTopic(mf.newTopicModel(geomapId, geomapState)); 155 } 156 157 @GET 158 @Path("/distance") 159 @Override 160 public double getDistance(@QueryParam("coord1") GeoCoordinate coord1, 161 @QueryParam("coord2") GeoCoordinate coord2) { 162 // calculate distance by the flat-surface formula for a "Spherical Earth projected to a plane" 163 // http://en.wikipedia.org/wiki/Geographical_distance#Flat-surface_formulae 164 double lonDiff = Math.toRadians(coord2.lon - coord1.lon); 165 double latDiff = Math.toRadians(coord2.lat - coord1.lat); 166 double latMean = Math.toRadians((coord1.lat + coord2.lat) / 2); 167 return EARTH_RADIUS_KM * Math.sqrt(Math.pow(latDiff, 2) + Math.pow(Math.cos(latMean) * lonDiff, 2)); 168 } 169 170 // --- 171 172 @Override 173 public <V> V runWithoutGeocoding(Callable<V> callable) throws Exception { 174 return contextTracker.run(callable); 175 } 176 177 178 179 // **************************** 180 // *** Hook Implementations *** 181 // **************************** 182 183 184 185 @Override 186 public void init() { 187 topicmapsService.registerTopicmapRenderer(new GeomapRenderer()); 188 } 189 190 191 192 // ******************************** 193 // *** Listener Implementations *** 194 // ******************************** 195 196 197 198 @Override 199 public void postCreateTopic(Topic topic) { 200 if (topic.getTypeUri().equals("dmx.contacts.address")) { 201 if (!abortGeocoding(topic)) { 202 // 203 facetsService.addFacetTypeToTopic(topic.getId(), "dmx.geomaps.geo_coordinate_facet"); 204 // 205 Address address = new Address(topic.getChildTopics().getModel()); 206 if (!address.isEmpty()) { 207 logger.info("### New " + address); 208 geocodeAndStoreFacet(address, topic); 209 } else { 210 logger.info("New empty address"); 211 } 212 } 213 } 214 } 215 216 @Override 217 public void postUpdateTopic(Topic topic, TopicModel updateModel, TopicModel oldTopic) { 218 if (topic.getTypeUri().equals("dmx.contacts.address")) { 219 if (!abortGeocoding(topic)) { 220 Address address = new Address(topic.getChildTopics().getModel()); 221 Address oldAddress = new Address(oldTopic.getChildTopicsModel()); 222 if (!address.equals(oldAddress)) { 223 logger.info("### Address changed:" + address.changeReport(oldAddress)); 224 geocodeAndStoreFacet(address, topic); 225 } else { 226 logger.info("Address not changed"); 227 } 228 } 229 } 230 } 231 232 // --- 233 234 /** 235 * Enriches an Address topic with its geo coordinate. 236 */ 237 @Override 238 public void preSendTopic(Topic topic) { 239 Topic address = topic.findChildTopic("dmx.contacts.address"); 240 if (address != null) { 241 String operation = "Enriching address " + address.getId() + " with its geo coordinate"; 242 Topic geoCoordTopic = getGeoCoordinateTopic(address); 243 if (geoCoordTopic != null) { 244 logger.info(operation); 245 address.getChildTopics().getModel().put("dmx.geomaps.geo_coordinate", geoCoordTopic.getModel()); 246 } else { 247 logger.info(operation + " SKIPPED -- no geo coordinate in DB"); 248 } 249 } 250 } 251 252 253 254 // ------------------------------------------------------------------------------------------------- Private Methods 255 256 /** 257 * Returns the Geo Coordinate topic (including its child topics) of a geo-facetted topic (e.g. an Address), 258 * or <code>null</code> if no geo coordinate is stored. 259 */ 260 private Topic getGeoCoordinateTopic(Topic geoTopic) { 261 Topic geoCoordTopic = facetsService.getFacet(geoTopic, "dmx.geomaps.geo_coordinate_facet"); 262 return geoCoordTopic != null ? geoCoordTopic.loadChildTopics() : null; 263 } 264 265 // --- 266 267 /** 268 * Geocodes the given address and stores the resulting coordinate as a facet value of the given Address topic. 269 * If geocoding (or storing the coordinate) fails a warning is logged; no exception is thrown. 270 * 271 * @param topic the Address topic to be facetted. 272 */ 273 private void geocodeAndStoreFacet(Address address, Topic topic) { 274 try { 275 GeoCoordinate geoCoord = address.geocode(); 276 storeGeoCoordinate(topic, geoCoord); 277 } catch (Exception e) { 278 // ### TODO: show to the user? 279 logger.log(Level.WARNING, "Adding geo coordinate to " + address + " failed", e); 280 } 281 } 282 283 /** 284 * Stores a geo coordinate for an address topic in the DB. 285 */ 286 private void storeGeoCoordinate(Topic address, GeoCoordinate geoCoord) { 287 try { 288 logger.info("Storing geo coordinate (" + geoCoord + ") of address topic " + address.getId()); 289 facetsService.updateFacet(address, "dmx.geomaps.geo_coordinate_facet", 290 mf.newFacetValueModel("dmx.geomaps.geo_coordinate") 291 .put(mf.newChildTopicsModel() 292 .put("dmx.geomaps.longitude", geoCoord.lon) 293 .put("dmx.geomaps.latitude", geoCoord.lat) 294 ) 295 ); 296 } catch (Exception e) { 297 throw new RuntimeException("Storing geo coordinate of address " + address.getId() + " failed", e); 298 } 299 } 300 301 // --- 302 303 private boolean abortGeocoding(Topic address) { 304 return abortGeocodingByCookie(address) || abortGeocodingByExcecutionContext(address); 305 } 306 307 private boolean abortGeocodingByCookie(Topic address) { 308 boolean abort = false; 309 Cookies cookies = Cookies.get(); 310 if (cookies.has(COOKIE_NO_GEOCODING)) { 311 String value = cookies.get(COOKIE_NO_GEOCODING); 312 if (!value.equals("false") && !value.equals("true")) { 313 throw new RuntimeException("\"" + value + "\" is an unexpected value for the \"" + COOKIE_NO_GEOCODING + 314 "\" cookie (expected are \"false\" or \"true\")"); 315 } 316 abort = value.equals("true"); 317 if (abort) { 318 logger.info("Geocoding for Address topic " + address.getId() + " SUPPRESSED -- \"" + 319 COOKIE_NO_GEOCODING + "\" cookie detected"); 320 } 321 } 322 return abort; 323 } 324 325 private boolean abortGeocodingByExcecutionContext(Topic address) { 326 boolean abort = contextTracker.runsInTrackedContext(); 327 if (abort) { 328 logger.info("Geocoding for Address topic " + address.getId() + " SUPPRESSED -- runWithoutGeocoding() " + 329 "context detected"); 330 } 331 return abort; 332 } 333 334 // ------------------------------------------------------------------------------------------------- Private Classes 335 336 private class Address { 337 338 String street, postalCode, city, country; 339 340 // --- 341 342 Address(ChildTopicsModel address) { 343 // Note: some Address child topics might be deleted (resp. do not exist), so we use "" 344 // as defaults here. Otherwise "Invalid access to ChildTopicsModel" would be thrown. 345 street = address.getString("dmx.contacts.street", ""); 346 postalCode = address.getString("dmx.contacts.postal_code", ""); 347 city = address.getString("dmx.contacts.city", ""); 348 country = address.getString("dmx.contacts.country", ""); 349 } 350 351 // --- 352 353 GeoCoordinate geocode() { 354 URL url = null; 355 try { 356 // perform request 357 String address = street + ", " + postalCode + " " + city + ", " + country; 358 url = new URL(String.format(GEOCODER_URL, JavaUtils.encodeURIComponent(address))); 359 logger.info("Geocoding \"" + address + "\"\n url=\"" + url + "\""); 360 JSONObject response = new JSONObject(JavaUtils.readTextURL(url)); 361 // check response status 362 String status = response.getString("status"); 363 if (!status.equals("OK")) { 364 throw new RuntimeException(status); 365 } 366 // parse response 367 JSONObject location = response.getJSONArray("results").getJSONObject(0).getJSONObject("geometry") 368 .getJSONObject("location"); 369 double lng = location.getDouble("lng"); 370 double lat = location.getDouble("lat"); 371 // create result 372 GeoCoordinate geoCoord = new GeoCoordinate(lng, lat); 373 logger.info("=> " + geoCoord); 374 return geoCoord; 375 } catch (Exception e) { 376 throw new RuntimeException("Geocoding failed (url=\"" + url + "\")", e); 377 } 378 } 379 380 boolean isEmpty() { 381 return street.equals("") && postalCode.equals("") && city.equals("") && country.equals(""); 382 } 383 384 String changeReport(Address oldAddr) { 385 StringBuilder report = new StringBuilder(); 386 if (!street.equals(oldAddr.street)) { 387 report.append("\n Street: \"" + oldAddr.street + "\" -> \"" + street + "\""); 388 } 389 if (!postalCode.equals(oldAddr.postalCode)) { 390 report.append("\n Postal Code: \"" + oldAddr.postalCode + "\" -> \"" + postalCode + "\""); 391 } 392 if (!city.equals(oldAddr.city)) { 393 report.append("\n City: \"" + oldAddr.city + "\" -> \"" + city + "\""); 394 } 395 if (!country.equals(oldAddr.country)) { 396 report.append("\n Country: \"" + oldAddr.country + "\" -> \"" + country + "\""); 397 } 398 return report.toString(); 399 } 400 401 // === Java API === 402 403 @Override 404 public boolean equals(Object o) { 405 if (o instanceof Address) { 406 Address addr = (Address) o; 407 return street.equals(addr.street) && postalCode.equals(addr.postalCode) && 408 city.equals(addr.city) && country.equals(addr.country); 409 } 410 return false; 411 } 412 413 @Override 414 public int hashCode() { 415 return (street + postalCode + city + country).hashCode(); 416 } 417 418 @Override 419 public String toString() { 420 return "address (street=\"" + street + "\", postalCode=\"" + postalCode + 421 "\", city=\"" + city + "\", country=\"" + country + "\")"; 422 } 423 } 424}