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