package de.deepamehta.geomaps; import de.deepamehta.geomaps.model.GeoCoordinate; import de.deepamehta.geomaps.model.Geomap; import de.deepamehta.topicmaps.TopicmapsService; import de.deepamehta.facets.FacetsService; import de.deepamehta.core.Association; import de.deepamehta.core.AssociationDefinition; import de.deepamehta.core.ChildTopics; import de.deepamehta.core.RelatedTopic; import de.deepamehta.core.Topic; import de.deepamehta.core.TopicType; import de.deepamehta.core.model.AssociationModel; import de.deepamehta.core.model.ChildTopicsModel; import de.deepamehta.core.model.TopicModel; import de.deepamehta.core.osgi.PluginActivator; import de.deepamehta.core.service.Cookies; import de.deepamehta.core.service.Inject; import de.deepamehta.core.service.Transactional; import de.deepamehta.core.service.event.PostCreateTopicListener; import de.deepamehta.core.service.event.PostUpdateTopicListener; import de.deepamehta.core.service.event.PreSendTopicListener; import de.deepamehta.core.util.ContextTracker; import de.deepamehta.core.util.JavaUtils; import org.codehaus.jettison.json.JSONObject; import javax.ws.rs.GET; import javax.ws.rs.PUT; import javax.ws.rs.HeaderParam; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.Produces; import javax.ws.rs.Consumes; import java.net.URL; import java.util.List; import java.util.concurrent.Callable; import java.util.logging.Level; import java.util.logging.Logger; @Path("/geomap") @Consumes("application/json") @Produces("application/json") public class GeomapsPlugin extends PluginActivator implements GeomapsService, PostCreateTopicListener, PostUpdateTopicListener, PreSendTopicListener { private static final String GEOCODER_URL = "http://maps.googleapis.com/maps/api/geocode/json?" + "address=%s&sensor=false"; private static final String COOKIE_NO_GEOCODING = "dm4_no_geocoding"; private static final double EARTH_RADIUS_KM = 6371.009; // ---------------------------------------------------------------------------------------------- Instance Variables @Inject private TopicmapsService topicmapsService; @Inject private FacetsService facetsService; // used for geocoding suppression private ContextTracker contextTracker = new ContextTracker(); private Logger logger = Logger.getLogger(getClass().getName()); // -------------------------------------------------------------------------------------------------- Public Methods // ************************************* // *** GeomapsService Implementation *** // ************************************* @GET @Path("/{id}") @Override public Geomap getGeomap(@PathParam("id") long geomapId) { return new Geomap(geomapId, dm4); } // Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter @GET @Path("/topic/{id}") @Override public Topic getDomainTopic(@PathParam("id") long geoCoordId) { try { Topic topic = dm4.getTopic(geoCoordId); RelatedTopic parentTopic; while ((parentTopic = topic.getRelatedTopic(null, "dm4.core.child", "dm4.core.parent", null)) != null) { topic = parentTopic; } return topic; } catch (Exception e) { throw new RuntimeException("Finding the geo coordinate's domain topic failed (geoCoordId=" + geoCoordId + ")", e); } } @Override public GeoCoordinate getGeoCoordinate(Topic geoTopic) { try { Topic geoCoordTopic = getGeoCoordinateTopic(geoTopic); if (geoCoordTopic != null) { return geoCoordinate(geoCoordTopic); } else { return null; } } catch (Exception e) { throw new RuntimeException("Getting the geo coordinate failed (geoTopic=" + geoTopic + ")", e); } } @Override public GeoCoordinate geoCoordinate(Topic geoCoordTopic) { ChildTopics childTopics = geoCoordTopic.getChildTopics(); return new GeoCoordinate( childTopics.getDouble("dm4.geomaps.longitude"), childTopics.getDouble("dm4.geomaps.latitude") ); } @PUT @Path("/{id}/topic/{geo_coord_id}") @Transactional @Override public void addCoordinateToGeomap(@PathParam("id") long geomapId, @PathParam("geo_coord_id") long geoCoordId) { logger.info("### Adding geo coordinate topic " + geoCoordId + " to geomap " + geomapId); AssociationModel model = mf.newAssociationModel("dm4.geomaps.geotopic_mapcontext", mf.newTopicRoleModel(geomapId, "dm4.core.default"), mf.newTopicRoleModel(geoCoordId, "dm4.topicmaps.topicmap_topic") ); dm4.createAssociation(model); } @PUT @Path("/{id}/center/{lon}/{lat}/zoom/{zoom}") @Transactional @Override public void setGeomapState(@PathParam("id") long geomapId, @PathParam("lon") double lon, @PathParam("lat") double lat, @PathParam("zoom") int zoom) { ChildTopicsModel geomapState = mf.newChildTopicsModel().put( "dm4.topicmaps.state", mf.newChildTopicsModel().put( "dm4.topicmaps.translation", mf.newChildTopicsModel().put( "dm4.topicmaps.translation_x", lon).put( "dm4.topicmaps.translation_y", lat)).put( "dm4.topicmaps.zoom_level", zoom) ); dm4.updateTopic(mf.newTopicModel(geomapId, geomapState)); } @GET @Path("/distance") @Override public double getDistance(@QueryParam("coord1") GeoCoordinate coord1, @QueryParam("coord2") GeoCoordinate coord2) { // calculate distance by the flat-surface formula for a "Spherical Earth projected to a plane" // http://en.wikipedia.org/wiki/Geographical_distance#Flat-surface_formulae double lonDiff = Math.toRadians(coord2.lon - coord1.lon); double latDiff = Math.toRadians(coord2.lat - coord1.lat); double latMean = Math.toRadians((coord1.lat + coord2.lat) / 2); return EARTH_RADIUS_KM * Math.sqrt(Math.pow(latDiff, 2) + Math.pow(Math.cos(latMean) * lonDiff, 2)); } // --- @Override public <V> V runWithoutGeocoding(Callable<V> callable) throws Exception { return contextTracker.run(callable); } // **************************** // *** Hook Implementations *** // **************************** @Override public void init() { topicmapsService.registerTopicmapRenderer(new GeomapRenderer()); } // ******************************** // *** Listener Implementations *** // ******************************** @Override public void postCreateTopic(Topic topic) { if (topic.getTypeUri().equals("dm4.contacts.address")) { if (!abortGeocoding(topic)) { // facetsService.addFacetTypeToTopic(topic.getId(), "dm4.geomaps.geo_coordinate_facet"); // Address address = new Address(topic.getChildTopics().getModel()); if (!address.isEmpty()) { logger.info("### New " + address); geocodeAndStoreFacet(address, topic); } else { logger.info("### New empty address"); } } } } @Override public void postUpdateTopic(Topic topic, TopicModel updateModel, TopicModel oldTopic) { if (topic.getTypeUri().equals("dm4.contacts.address")) { if (!abortGeocoding(topic)) { Address address = new Address(topic.getChildTopics().getModel()); Address oldAddress = new Address(oldTopic.getChildTopicsModel()); if (!address.equals(oldAddress)) { logger.info("### Address changed:" + address.changeReport(oldAddress)); geocodeAndStoreFacet(address, topic); } else { logger.info("### Address not changed"); } } } } // --- /** * Enriches an Address topic with its geo coordinate. */ @Override public void preSendTopic(Topic topic) { Topic address = topic.findChildTopic("dm4.contacts.address"); if (address != null) { String operation = "### Enriching address " + address.getId() + " with its geo coordinate"; Topic geoCoordTopic = getGeoCoordinateTopic(address); if (geoCoordTopic != null) { logger.info(operation); address.getChildTopics().getModel().put("dm4.geomaps.geo_coordinate", geoCoordTopic.getModel()); } else { logger.info(operation + " SKIPPED -- no geo coordinate in DB"); } } } // ------------------------------------------------------------------------------------------------- Private Methods /** * Returns the Geo Coordinate topic (including its child topics) of a geo-facetted topic (e.g. an Address), * or <code>null</code> if no geo coordinate is stored. */ private Topic getGeoCoordinateTopic(Topic geoTopic) { Topic geoCoordTopic = facetsService.getFacet(geoTopic, "dm4.geomaps.geo_coordinate_facet"); return geoCoordTopic != null ? geoCoordTopic.loadChildTopics() : null; } // --- /** * Geocodes the given address and stores the resulting coordinate as a facet value of the given Address topic. * If geocoding (or storing the coordinate) fails a warning is logged; no exception is thrown. * * @param topic the Address topic to be facetted. */ private void geocodeAndStoreFacet(Address address, Topic topic) { try { GeoCoordinate geoCoord = address.geocode(); storeGeoCoordinate(topic, geoCoord); } catch (Exception e) { // ### TODO: show to the user? logger.log(Level.WARNING, "Adding geo coordinate to " + address + " failed", e); } } /** * Stores a geo coordinate for an address topic in the DB. */ private void storeGeoCoordinate(Topic address, GeoCoordinate geoCoord) { try { logger.info("Storing geo coordinate (" + geoCoord + ") of address topic " + address.getId()); facetsService.updateFacet(address, "dm4.geomaps.geo_coordinate_facet", mf.newFacetValueModel("dm4.geomaps.geo_coordinate") .put(mf.newChildTopicsModel() .put("dm4.geomaps.longitude", geoCoord.lon) .put("dm4.geomaps.latitude", geoCoord.lat) ) ); } catch (Exception e) { throw new RuntimeException("Storing geo coordinate of address " + address.getId() + " failed", e); } } // --- private boolean abortGeocoding(Topic address) { return abortGeocodingByCookie(address) || abortGeocodingByExcecutionContext(address); } private boolean abortGeocodingByCookie(Topic address) { boolean abort = false; Cookies cookies = Cookies.get(); if (cookies.has(COOKIE_NO_GEOCODING)) { String value = cookies.get(COOKIE_NO_GEOCODING); if (!value.equals("false") && !value.equals("true")) { throw new RuntimeException("\"" + value + "\" is an unexpected value for the \"" + COOKIE_NO_GEOCODING + "\" cookie (expected are \"false\" or \"true\")"); } abort = value.equals("true"); if (abort) { logger.info("Geocoding for Address topic " + address.getId() + " SUPPRESSED -- \"" + COOKIE_NO_GEOCODING + "\" cookie detected"); } } return abort; } private boolean abortGeocodingByExcecutionContext(Topic address) { boolean abort = contextTracker.runsInTrackedContext(); if (abort) { logger.info("Geocoding for Address topic " + address.getId() + " SUPPRESSED -- runWithoutGeocoding() " + "context detected"); } return abort; } // ------------------------------------------------------------------------------------------------- Private Classes private class Address { String street, postalCode, city, country; // --- Address(ChildTopicsModel address) { // Note: some Address child topics might be deleted (resp. do not exist), so we use "" // as defaults here. Otherwise "Invalid access to ChildTopicsModel" would be thrown. street = address.getString("dm4.contacts.street", ""); postalCode = address.getString("dm4.contacts.postal_code", ""); city = address.getString("dm4.contacts.city", ""); country = address.getString("dm4.contacts.country", ""); } // --- GeoCoordinate geocode() { URL url = null; try { // perform request String address = street + ", " + postalCode + " " + city + ", " + country; url = new URL(String.format(GEOCODER_URL, JavaUtils.encodeURIComponent(address))); logger.info("### Geocoding \"" + address + "\"\n url=\"" + url + "\""); JSONObject response = new JSONObject(JavaUtils.readTextURL(url)); // check response status String status = response.getString("status"); if (!status.equals("OK")) { throw new RuntimeException(status); } // parse response JSONObject location = response.getJSONArray("results").getJSONObject(0).getJSONObject("geometry") .getJSONObject("location"); double lng = location.getDouble("lng"); double lat = location.getDouble("lat"); // create result GeoCoordinate geoCoord = new GeoCoordinate(lng, lat); logger.info("=> " + geoCoord); return geoCoord; } catch (Exception e) { throw new RuntimeException("Geocoding failed (url=\"" + url + "\")", e); } } boolean isEmpty() { return street.equals("") && postalCode.equals("") && city.equals("") && country.equals(""); } String changeReport(Address oldAddr) { StringBuilder report = new StringBuilder(); if (!street.equals(oldAddr.street)) { report.append("\n Street: \"" + oldAddr.street + "\" -> \"" + street + "\""); } if (!postalCode.equals(oldAddr.postalCode)) { report.append("\n Postal Code: \"" + oldAddr.postalCode + "\" -> \"" + postalCode + "\""); } if (!city.equals(oldAddr.city)) { report.append("\n City: \"" + oldAddr.city + "\" -> \"" + city + "\""); } if (!country.equals(oldAddr.country)) { report.append("\n Country: \"" + oldAddr.country + "\" -> \"" + country + "\""); } return report.toString(); } // === Java API === @Override public boolean equals(Object o) { if (o instanceof Address) { Address addr = (Address) o; return street.equals(addr.street) && postalCode.equals(addr.postalCode) && city.equals(addr.city) && country.equals(addr.country); } return false; } @Override public int hashCode() { return (street + postalCode + city + country).hashCode(); } @Override public String toString() { return "address (street=\"" + street + "\", postalCode=\"" + postalCode + "\", city=\"" + city + "\", country=\"" + country + "\")"; } } }