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