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