├── .gitignore ├── .travis.yml ├── README.markdown ├── app ├── Global.java ├── actors │ └── ProcessCPOCsvEntry.java ├── controllers │ ├── Application.java │ └── Server.java ├── models │ ├── CartesianLocation.java │ ├── Location.java │ ├── Model.java │ ├── PostcodeUnit.java │ └── csv │ │ └── CodePointOpenCsvEntry.java ├── modules │ ├── CamelcodeModule.java │ └── MorphiaModule.java ├── services │ ├── CPOCsvCamelWatchService.java │ └── MongoService.java ├── utils │ └── MoreMatchers.java └── views │ ├── index.scala.html │ ├── main.scala.html │ ├── map.scala.html │ └── twitterBootstrapFieldConstructor.scala.html ├── codepointopen └── ab.csv ├── conf ├── application.conf └── routes ├── lib └── com.springsource.javax.media.jai.core-1.1.3.jar ├── project ├── Build.scala ├── build.properties └── plugins.sbt ├── public └── images │ ├── favicon.png │ ├── layers.png │ ├── maki │ ├── marker-solid-12.png │ ├── marker-solid-18.png │ └── marker-solid-24.png │ ├── popup-close.png │ ├── zoom-in.png │ └── zoom-out.png ├── screenshot.png └── screenshot2.png /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | project/project 3 | project/target 4 | codepointopen/done 5 | target 6 | tmp 7 | .history 8 | .settings 9 | .idea 10 | .idea_modules 11 | dist -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | env: 3 | - PLAY_VERSION="2.2.1" # Latest 2.x 4 | before_script: 5 | - wget http://downloads.typesafe.com/play/${PLAY_VERSION}/play-${PLAY_VERSION}.zip 6 | - unzip -q play-${PLAY_VERSION}.zip 7 | script: play-${PLAY_VERSION}/play dist -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | CamelCode [![Build Status](https://travis-ci.org/analytically/camelcode.png)](https://travis-ci.org/analytically/camelcode) 2 | ========= 3 | 4 | A tech demo built using [Play Framework 2.2](http://www.playframework.org) (Java) that imports the 5 | [CodePoint Open](https://www.ordnancesurvey.co.uk/opendatadownload/products.html) UK postcode dataset 6 | and offers a Geocoding RESTful API and a map. It also demonstrates how 7 | [Google Guice](http://code.google.com/p/google-guice/) can be integrated in a Play Framework Java application. 8 | 9 | Follow [@analytically](http://twitter.com/analytically) for updates. 10 | 11 | ### Requirements 12 | 13 | - Java 6 or later 14 | - [Play Framework 2.2.1](http://www.playframework.org) 15 | - [MongoDB](http://www.mongodb.org) 16 | 17 | ### Setup 18 | 19 | Edit `conf/application.conf` and point it to a MongoDB installation (defaults to `localhost:27017`), and execute 20 | 21 | ``` sh 22 | play run 23 | ``` 24 | 25 | Then drop the [CodePoint Open CSV](https://www.ordnancesurvey.co.uk/opendatadownload/products.html) (scroll halfway down, 20mb) 26 | files in the `codepointopen` directory. 27 | 28 | After each file is imported, it will be moved to the `codepointopen/done` directory. 29 | 30 | Then visit [http://localhost:9000](http://localhost:9000) and you should see the welcome screen. 31 | Check out the [server metrics](http://localhost:9000/servermetrics) or the [map](http://localhost:9000/map). 32 | 33 | ### REST API and JSON 34 | 35 | GET [http://localhost:9000/latlng/POSTCODE](http://localhost:9000/latlng/BS106TF) to geocode a UK postcode. Response will be JSON: 36 | 37 | ``` json 38 | {"latitude":51.505615,"longitude":-2.6120315} 39 | ``` 40 | 41 | ### Screenshots 42 | 43 | ![Welcome Page](screenshot.png) 44 | 45 | ![Map](screenshot2.png) 46 | 47 | ### Technology 48 | 49 | * [Play Framework 2.2.1](http://www.playframework.org), as web framework 50 | * [Apache Camel](http://camel.apache.org) to [process and monitor](https://github.com/analytically/camelcode/blob/master/app/Global.java#L103) the `codepointopen` directory and to tell the actors about the postcodes (split(body())) 51 | * [Akka](http://akka.io) provides a nice concurrency model [to process the 1.7 million postcodes](https://github.com/analytically/camelcode/blob/master/app/actors/ProcessCPOCsvEntry.java) in under one minute on modern hardware 52 | * [GeoTools](http://www.geotools.org) [converts](https://github.com/analytically/camelcode/blob/master/app/actors/ProcessCPOCsvEntry.java) the eastings/northings to latitude/longitude 53 | * [Guice](http://code.google.com/p/google-guice/) for [Dependency Injection](https://github.com/analytically/camelcode/blob/master/app/Global.java#L53) (not too much to inject yet though) 54 | * [Metrics](http://metrics.codahale.com/) for metrics 55 | * [MongoDB](http://www.mongodb.org) as database with two-dimensional geospatial indexes (see [Geospatial Indexing](http://www.mongodb.org/display/DOCS/Geospatial+Indexing)) 56 | * [Morphia](https://github.com/mongodb/morphia) for 'Object-Document Mapping' 57 | * [Leaflet](http://leafletjs.com/) for the map 58 | * [Bootstrap](http://getbootstrap.com/) and [Font Awesome](http://fortawesome.github.com/Font-Awesome/) for the UI 59 | 60 | ### License 61 | 62 | Licensed under the [WTFPL](http://en.wikipedia.org/wiki/WTFPL). 63 | 64 | This data contains Ordnance Survey data © Crown copyright and database right 2013. Code-Point Open contains 65 | Royal Mail data © Royal Mail copyright and database right 2012. Code-Point Open and ONSPD contains National Statistics 66 | data © Crown copyright and database right 2013. 67 | 68 | OS data may be used under the terms of the [OS OpenData licence](http://www.ordnancesurvey.co.uk/oswebsite/docs/licences/os-opendata-licence.pdf). 69 | -------------------------------------------------------------------------------- /app/Global.java: -------------------------------------------------------------------------------- 1 | import actors.ProcessCPOCsvEntry; 2 | import akka.actor.ActorRef; 3 | import akka.actor.Props; 4 | import akka.actor.UntypedActor; 5 | import akka.actor.UntypedActorFactory; 6 | import org.mongodb.morphia.logging.MorphiaLoggerFactory; 7 | import com.google.common.base.Function; 8 | import com.google.common.base.Throwables; 9 | import com.google.common.collect.ImmutableMap; 10 | import com.google.common.collect.Lists; 11 | import com.google.common.util.concurrent.AbstractExecutionThreadService; 12 | import com.google.common.util.concurrent.AbstractIdleService; 13 | import com.google.common.util.concurrent.AbstractService; 14 | import com.google.common.util.concurrent.Service; 15 | import com.google.inject.*; 16 | import com.google.inject.multibindings.Multibinder; 17 | import com.google.inject.name.Names; 18 | import com.google.inject.spi.InjectionListener; 19 | import com.google.inject.spi.TypeEncounter; 20 | import com.google.inject.spi.TypeListener; 21 | import org.mongodb.morphia.logging.slf4j.SLF4JLoggerImplFactory; 22 | import org.reflections.Reflections; 23 | import org.reflections.scanners.SubTypesScanner; 24 | import org.reflections.util.ClasspathHelper; 25 | import org.reflections.util.ConfigurationBuilder; 26 | import org.reflections.util.FilterBuilder; 27 | import play.Application; 28 | import play.GlobalSettings; 29 | import play.Logger; 30 | import play.libs.Akka; 31 | import play.mvc.Controller; 32 | import utils.MoreMatchers; 33 | 34 | import java.util.List; 35 | import java.util.Set; 36 | import java.util.concurrent.CopyOnWriteArrayList; 37 | 38 | /** 39 | * @author Mathias Bogaert 40 | */ 41 | public class Global extends GlobalSettings { 42 | static { 43 | MorphiaLoggerFactory.reset(); 44 | MorphiaLoggerFactory.registerLogger(SLF4JLoggerImplFactory.class); 45 | } 46 | 47 | static final class ActorProvider implements Provider { 48 | private final TypeLiteral uta; 49 | private final Injector injector; 50 | 51 | @Inject 52 | public ActorProvider(TypeLiteral uta, Injector injector) { 53 | this.uta = uta; 54 | this.injector = injector; 55 | } 56 | 57 | @Override 58 | public ActorRef get() { 59 | return Akka.system().actorOf(new Props(new UntypedActorFactory() { 60 | public T create() { 61 | return injector.getInstance(Key.get(uta)); 62 | } 63 | })); 64 | } 65 | } 66 | 67 | private Injector injector; 68 | private final List modules = Lists.newArrayList(); 69 | 70 | private final List onStartListeners = new CopyOnWriteArrayList(); 71 | private final List onStopListeners = new CopyOnWriteArrayList(); 72 | 73 | @Override 74 | public void beforeStart(final Application application) { 75 | final Reflections reflections = new Reflections(new ConfigurationBuilder() 76 | .filterInputsBy(new FilterBuilder.Exclude(FilterBuilder.prefix("com.google"))) 77 | .addUrls(ClasspathHelper.forClassLoader(application.classloader())) 78 | .addScanners( 79 | new SubTypesScanner() 80 | )); 81 | 82 | // automatic Guice module detection 83 | Set> guiceModules = reflections.getSubTypesOf(AbstractModule.class); 84 | for (Class moduleClass : guiceModules) { 85 | try { 86 | if (!moduleClass.isAnonymousClass()) { 87 | modules.add(moduleClass.newInstance()); 88 | } 89 | } catch (InstantiationException e) { 90 | throw Throwables.propagate(e); 91 | } catch (IllegalAccessException e) { 92 | throw Throwables.propagate(e); 93 | } 94 | } 95 | 96 | modules.add(new AbstractModule() { 97 | @Override 98 | protected void configure() { 99 | bind(Application.class).toInstance(application); 100 | bind(Reflections.class).toInstance(reflections); 101 | 102 | Names.bindProperties(this.binder(), fromKeys(application.configuration().keys(), new Function() { 103 | @Override 104 | public String apply(String key) { 105 | if (key.contains("akka")) return null; 106 | 107 | return application.configuration().getString(key); 108 | } 109 | })); 110 | 111 | for (Class controllerClass : reflections.getSubTypesOf(Controller.class)) { 112 | Logger.info("Static injection for " + controllerClass); 113 | 114 | requestStaticInjection(controllerClass); 115 | } 116 | 117 | // bind all services 118 | Multibinder serviceBinder = Multibinder.newSetBinder(binder(), Service.class); 119 | for (Class serviceImplClass : reflections.getSubTypesOf(AbstractService.class)) { 120 | serviceBinder.addBinding().to(serviceImplClass).asEagerSingleton(); 121 | } 122 | for (Class serviceImplClass : reflections.getSubTypesOf(AbstractIdleService.class)) { 123 | serviceBinder.addBinding().to(serviceImplClass).asEagerSingleton(); 124 | } 125 | for (Class serviceImplClass : reflections.getSubTypesOf(AbstractExecutionThreadService.class)) { 126 | serviceBinder.addBinding().to(serviceImplClass).asEagerSingleton(); 127 | } 128 | 129 | // bind actor - todo use reflections for this 130 | bind(ActorRef.class).annotatedWith(Names.named("ProcessCPOCsvEntry")) 131 | .toProvider(new TypeLiteral>() { 132 | }); 133 | 134 | // start/stop services after injection and on shutdown of the Play app 135 | bindListener(MoreMatchers.subclassesOf(Service.class), new TypeListener() { 136 | @Override 137 | public void hear(TypeLiteral typeLiteral, TypeEncounter typeEncounter) { 138 | typeEncounter.register(new InjectionListener() { 139 | @Override 140 | public void afterInjection(final I i) { 141 | onStartListeners.add(new OnStartListener() { 142 | @Override 143 | public void onApplicationStart(Application application, Injector injector) { 144 | Logger.info(String.format("Starting %s", i.toString())); 145 | ((Service) i).start(); 146 | 147 | onStopListeners.add(new OnStopListener() { 148 | @Override 149 | public void onApplicationStop(Application application) { 150 | Logger.info(String.format("Stopping %s", i.toString())); 151 | ((Service) i).stop(); 152 | } 153 | }); 154 | } 155 | }); 156 | } 157 | }); 158 | } 159 | }); 160 | } 161 | }); 162 | } 163 | 164 | @Override 165 | public A getControllerInstance(Class controllerClass) throws Exception { 166 | return injector.getInstance(controllerClass); 167 | } 168 | 169 | @Override 170 | public void onStart(Application app) { 171 | Logger.info("Creating injector with " + modules.size() + " modules."); 172 | injector = Guice.createInjector(Stage.PRODUCTION, modules); 173 | 174 | for (OnStartListener listener : onStartListeners) { 175 | listener.onApplicationStart(app, injector); 176 | } 177 | } 178 | 179 | @Override 180 | public void onStop(Application app) { 181 | for (OnStopListener listener : onStopListeners) { 182 | listener.onApplicationStop(app); 183 | } 184 | } 185 | 186 | /** 187 | * Listener that will get invoked after the application is started. 188 | */ 189 | static interface OnStartListener { 190 | void onApplicationStart(Application application, Injector injector); 191 | } 192 | 193 | /** 194 | * Listener that will get invoked before the application is stopped. 195 | */ 196 | static interface OnStopListener { 197 | void onApplicationStop(Application application); 198 | } 199 | 200 | private static ImmutableMap fromKeys(Iterable keys, Function valueFunction) { 201 | ImmutableMap.Builder builder = ImmutableMap.builder(); 202 | for (K key : keys) { 203 | V value = valueFunction.apply(key); 204 | if (value != null) { 205 | builder.put(key, value); 206 | } 207 | } 208 | return builder.build(); 209 | } 210 | } -------------------------------------------------------------------------------- /app/actors/ProcessCPOCsvEntry.java: -------------------------------------------------------------------------------- 1 | package actors; 2 | 3 | import akka.actor.UntypedActor; 4 | import com.google.common.base.CharMatcher; 5 | import com.google.common.base.Throwables; 6 | import com.mongodb.MongoException; 7 | import com.yammer.metrics.Metrics; 8 | import com.yammer.metrics.core.Counter; 9 | import com.yammer.metrics.core.Timer; 10 | import com.yammer.metrics.core.TimerContext; 11 | import models.CartesianLocation; 12 | import models.Location; 13 | import models.PostcodeUnit; 14 | import models.csv.CodePointOpenCsvEntry; 15 | import org.geotools.geometry.GeneralDirectPosition; 16 | import org.geotools.referencing.CRS; 17 | import org.geotools.referencing.crs.DefaultGeographicCRS; 18 | import org.opengis.geometry.DirectPosition; 19 | import org.opengis.referencing.FactoryException; 20 | import org.opengis.referencing.crs.CoordinateReferenceSystem; 21 | import org.opengis.referencing.operation.MathTransform; 22 | import org.opengis.referencing.operation.TransformException; 23 | 24 | import java.util.concurrent.TimeUnit; 25 | 26 | /** 27 | * @author Mathias Bogaert 28 | */ 29 | public class ProcessCPOCsvEntry extends UntypedActor { 30 | private MathTransform osgbToWgs84Transform; 31 | 32 | private final Counter postcodesProcessed = Metrics.newCounter(ProcessCPOCsvEntry.class, "postcodes-processed"); 33 | private final Timer latLongTransform = Metrics.newTimer(ProcessCPOCsvEntry.class, "latitude-longitude-transform", TimeUnit.MILLISECONDS, TimeUnit.MILLISECONDS); 34 | private final Timer savePostcodeUnit = Metrics.newTimer(ProcessCPOCsvEntry.class, "save-postcode-unit-mongodb", TimeUnit.MILLISECONDS, TimeUnit.MILLISECONDS); 35 | 36 | @Override 37 | public void preStart() { 38 | try { 39 | CoordinateReferenceSystem osgbCrs = CRS.decode("EPSG:27700"); // OSGB 1936 / British National Grid 40 | CoordinateReferenceSystem wgs84crs = DefaultGeographicCRS.WGS84; // WGS 84, GPS 41 | 42 | osgbToWgs84Transform = CRS.findMathTransform(osgbCrs, wgs84crs); 43 | } catch (FactoryException e) { 44 | throw Throwables.propagate(e); 45 | } 46 | } 47 | 48 | @Override 49 | public void onReceive(Object message) { 50 | if (message instanceof CodePointOpenCsvEntry) { 51 | CodePointOpenCsvEntry entry = (CodePointOpenCsvEntry) message; 52 | 53 | PostcodeUnit unit = new PostcodeUnit(CharMatcher.WHITESPACE.removeFrom(entry.getPostcode())); 54 | unit.pqi = entry.getPositionalQualityIndicator(); 55 | 56 | try { 57 | int eastings = Integer.parseInt(entry.getEastings()); 58 | int northings = Integer.parseInt(entry.getNorthings()); 59 | 60 | unit.cartesianLocation = new CartesianLocation(eastings, northings); 61 | 62 | final TimerContext latLongCtx = latLongTransform.time(); 63 | try { 64 | DirectPosition eastNorth = new GeneralDirectPosition(eastings, northings); 65 | DirectPosition latLng = osgbToWgs84Transform.transform(eastNorth, eastNorth); 66 | 67 | unit.location = new Location(round(latLng.getOrdinate(1), 8), round(latLng.getOrdinate(0), 8)); 68 | } finally { 69 | latLongCtx.stop(); 70 | } 71 | } catch (NumberFormatException e) { 72 | throw new RuntimeException("NumberFormatException parsing easting/northings '" + entry.getEastings() + ", " + entry.getNorthings() + "'."); 73 | } catch (TransformException e) { 74 | throw Throwables.propagate(e); 75 | } 76 | 77 | final TimerContext saveCtx = savePostcodeUnit.time(); 78 | try { 79 | unit.save(); 80 | 81 | postcodesProcessed.inc(); 82 | } catch (MongoException.DuplicateKey e) { 83 | // ignore 84 | } finally { 85 | saveCtx.stop(); 86 | } 87 | } 88 | } 89 | 90 | public static double round(double valueToRound, int numberOfDecimalPlaces) { 91 | double multipicationFactor = Math.pow(10, numberOfDecimalPlaces); 92 | double interestedInZeroDPs = valueToRound * multipicationFactor; 93 | return Math.round(interestedInZeroDPs) / multipicationFactor; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/controllers/Application.java: -------------------------------------------------------------------------------- 1 | package controllers; 2 | 3 | import org.mongodb.morphia.Morphia; 4 | import com.google.common.base.CharMatcher; 5 | import com.google.common.base.Function; 6 | import com.google.common.base.Strings; 7 | import com.google.common.collect.Iterables; 8 | import com.google.common.collect.Lists; 9 | import com.google.inject.Inject; 10 | import com.mongodb.BasicDBList; 11 | import com.mongodb.BasicDBObject; 12 | import com.mongodb.CommandResult; 13 | import models.Model; 14 | import models.PostcodeUnit; 15 | import play.data.Form; 16 | import play.data.validation.Constraints; 17 | import play.mvc.Controller; 18 | import play.mvc.Result; 19 | import views.html.index; 20 | import views.html.map; 21 | 22 | import javax.annotation.Nullable; 23 | import java.util.ArrayList; 24 | import java.util.List; 25 | 26 | import static play.data.Form.form; 27 | import static play.libs.Json.toJson; 28 | 29 | public class Application extends Controller { 30 | @Inject 31 | public static Morphia morphia; 32 | 33 | public static class DistanceCalc { 34 | @Constraints.Required 35 | public String postcode; 36 | 37 | @Constraints.Required 38 | public int distance = 10; 39 | 40 | public String validate() { 41 | if (PostcodeUnit.find.field("postcode").equal(postcode).get() == null) { 42 | return "Invalid postcode"; 43 | } 44 | 45 | return null; 46 | } 47 | } 48 | 49 | public static class Geocode { 50 | @Constraints.Required 51 | public String postcode; 52 | 53 | public String validate() { 54 | if (PostcodeUnit.find.field("postcode").equal(postcode).get() == null) { 55 | return "Invalid postcode"; 56 | } 57 | 58 | return null; 59 | } 60 | } 61 | 62 | public static Result index() { 63 | return ok(index.render(form(DistanceCalc.class), form(Geocode.class), new ArrayList())); 64 | } 65 | 66 | public static Result map() { 67 | return ok(map.render()); 68 | } 69 | 70 | public static Result ll() { 71 | Form geocodeForm = form(Geocode.class).bindFromRequest(); 72 | if (geocodeForm.hasErrors()) { 73 | return badRequest(index.render(form(DistanceCalc.class), geocodeForm, new ArrayList())); 74 | } else { 75 | Geocode geocode = geocodeForm.get(); 76 | 77 | PostcodeUnit unit = PostcodeUnit.find.field("postcode").equal(CharMatcher.WHITESPACE.removeFrom(geocode.postcode).toUpperCase()).get(); 78 | return ok(toJson(unit.location)); 79 | } 80 | } 81 | 82 | public static Result latLng(String postcode) { 83 | if (Strings.isNullOrEmpty(postcode)) return badRequest("empty postcode"); 84 | postcode = CharMatcher.WHITESPACE.removeFrom(postcode).toUpperCase(); 85 | if (postcode.length() < 5 || postcode.length() > 7) return badRequest("illegal postcode format"); 86 | 87 | PostcodeUnit unit = PostcodeUnit.find.field("postcode").equal(postcode).get(); 88 | if (unit == null) { 89 | return notFound(); 90 | } else { 91 | return ok(toJson(unit.location)); 92 | } 93 | } 94 | 95 | public static Result en() { 96 | Form geocodeForm = form(Geocode.class).bindFromRequest(); 97 | if (geocodeForm.hasErrors()) { 98 | return badRequest(index.render(form(DistanceCalc.class), geocodeForm, new ArrayList())); 99 | } else { 100 | Geocode geocode = geocodeForm.get(); 101 | 102 | PostcodeUnit unit = PostcodeUnit.find.field("postcode").equal(CharMatcher.WHITESPACE.removeFrom(geocode.postcode).toUpperCase()).get(); 103 | return ok(toJson(unit.cartesianLocation)); 104 | } 105 | } 106 | 107 | public static Result eastingsNorthings(String postcode) { 108 | if (Strings.isNullOrEmpty(postcode)) return badRequest("empty postcode"); 109 | postcode = CharMatcher.WHITESPACE.removeFrom(postcode).toUpperCase(); 110 | if (postcode.length() < 5 || postcode.length() > 7) return badRequest("illegal postcode format"); 111 | 112 | PostcodeUnit unit = PostcodeUnit.find.field("postcode").equal(postcode).get(); 113 | if (unit == null) { 114 | return notFound(); 115 | } else { 116 | return ok(toJson(unit.cartesianLocation)); 117 | } 118 | } 119 | 120 | public static Result near(String latitude, String longitude) { 121 | return ok(toJson(findNearMiles(Double.parseDouble(latitude), Double.parseDouble(longitude), 15, 100))); 122 | } 123 | 124 | public static Result calc() { 125 | Form distanceCalcForm = form(DistanceCalc.class).bindFromRequest(); 126 | if (distanceCalcForm.hasErrors()) { 127 | return badRequest(index.render(distanceCalcForm, form(Geocode.class), new ArrayList())); 128 | } else { 129 | DistanceCalc calc = distanceCalcForm.get(); 130 | 131 | PostcodeUnit postcode = PostcodeUnit.find.field("postcode").equal(CharMatcher.WHITESPACE.removeFrom(calc.postcode).toUpperCase()).get(); 132 | List near = findNearMiles(postcode.location.latitude, postcode.location.longitude, calc.distance, 100); 133 | 134 | StringBuilder message = new StringBuilder(); 135 | message.append("Found ") 136 | .append(near.size()) 137 | .append(" post codes within ") 138 | .append(calc.distance) 139 | .append(" miles from ") 140 | .append(postcode.postcode) 141 | .append("."); 142 | 143 | if (near.size() == 100) { 144 | message.append(" The list was capped to 100 postcodes."); 145 | } 146 | 147 | flash("success", message.toString()); 148 | 149 | return ok(index.render(distanceCalcForm, form(Geocode.class), near)); 150 | } 151 | } 152 | 153 | protected static List findNearMiles(double latitude, double longitude, int miles, int limit) { 154 | BasicDBObject geoNearCommand = new BasicDBObject(); 155 | geoNearCommand.put("geoNear", "pcu"); 156 | double coord[] = {latitude, longitude}; 157 | geoNearCommand.put("near", coord); 158 | geoNearCommand.put("maxDistance", miles / 69.17); 159 | geoNearCommand.put("num", limit); 160 | geoNearCommand.put("spherical", true); 161 | 162 | CommandResult geoNearResult = Model.datastore.getDB().command(geoNearCommand); 163 | BasicDBList geoNearResults = (BasicDBList) geoNearResult.get("results"); 164 | 165 | return Lists.newArrayList(Iterables.transform(geoNearResults, new Function() { 166 | @Override 167 | public PostcodeUnit apply(@Nullable Object input) { 168 | BasicDBObject pcuObject = (BasicDBObject) ((BasicDBObject) input).get("obj"); 169 | return morphia.fromDBObject(PostcodeUnit.class, pcuObject); 170 | } 171 | })); 172 | } 173 | } -------------------------------------------------------------------------------- /app/controllers/Server.java: -------------------------------------------------------------------------------- 1 | package controllers; 2 | 3 | import com.fasterxml.jackson.core.JsonFactory; 4 | import com.fasterxml.jackson.core.JsonGenerator; 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import com.google.common.base.Strings; 7 | import com.yammer.metrics.HealthChecks; 8 | import com.yammer.metrics.Metrics; 9 | import com.yammer.metrics.core.*; 10 | import com.yammer.metrics.stats.Snapshot; 11 | import play.Logger; 12 | import play.mvc.Controller; 13 | import play.mvc.Result; 14 | 15 | 16 | import java.io.ByteArrayOutputStream; 17 | import java.io.IOException; 18 | import java.io.PrintWriter; 19 | import java.io.StringWriter; 20 | import java.util.Map; 21 | import java.util.SortedMap; 22 | import java.util.concurrent.TimeUnit; 23 | 24 | /** 25 | * @author Mathias Bogaert 26 | */ 27 | public class Server extends Controller { 28 | public static Result metrics(String classPrefix, boolean pretty) throws Exception { 29 | response().setContentType("application/json"); 30 | response().setHeader("Cache-Control", "must-revalidate,no-cache,no-store"); 31 | 32 | StringWriter writer = new StringWriter(); 33 | JsonFactory factory = new JsonFactory(new ObjectMapper()); 34 | final JsonGenerator json = factory.createGenerator(writer); 35 | if (pretty) { 36 | json.useDefaultPrettyPrinter(); 37 | } 38 | json.writeStartObject(); 39 | { 40 | if (("jvm".equals(classPrefix) || Strings.isNullOrEmpty(classPrefix))) { 41 | writeVmMetrics(json); 42 | } 43 | 44 | ServerMetrics metricProcessor = new ServerMetrics(); 45 | writeRegularMetrics(json, classPrefix, metricProcessor); 46 | } 47 | json.writeEndObject(); 48 | json.close(); 49 | 50 | return ok(writer.toString()); 51 | } 52 | 53 | private static void writeVmMetrics(JsonGenerator json) throws IOException { 54 | VirtualMachineMetrics vm = VirtualMachineMetrics.getInstance(); 55 | 56 | json.writeFieldName("jvm"); 57 | json.writeStartObject(); 58 | { 59 | 60 | json.writeFieldName("vm"); 61 | json.writeStartObject(); 62 | { 63 | json.writeStringField("name", vm.name()); 64 | json.writeStringField("version", vm.version()); 65 | } 66 | json.writeEndObject(); 67 | json.writeFieldName("memory"); 68 | json.writeStartObject(); 69 | { 70 | json.writeNumberField("totalInit", vm.totalInit()); 71 | json.writeNumberField("totalUsed", vm.totalUsed()); 72 | json.writeNumberField("totalMax", vm.totalMax()); 73 | json.writeNumberField("totalCommitted", vm.totalCommitted()); 74 | 75 | json.writeNumberField("heapInit", vm.heapInit()); 76 | json.writeNumberField("heapUsed", vm.heapUsed()); 77 | json.writeNumberField("heapMax", vm.heapMax()); 78 | json.writeNumberField("heapCommitted", vm.heapCommitted()); 79 | 80 | json.writeNumberField("heap_usage", vm.heapUsage()); 81 | json.writeNumberField("non_heap_usage", vm.nonHeapUsage()); 82 | json.writeFieldName("memory_pool_usages"); 83 | json.writeStartObject(); 84 | { 85 | for (Map.Entry pool : vm.memoryPoolUsage().entrySet()) { 86 | json.writeNumberField(pool.getKey(), pool.getValue()); 87 | } 88 | } 89 | json.writeEndObject(); 90 | } 91 | json.writeEndObject(); 92 | 93 | json.writeNumberField("daemon_thread_count", vm.daemonThreadCount()); 94 | json.writeNumberField("thread_count", vm.threadCount()); 95 | json.writeNumberField("current_time", Clock.defaultClock().time()); 96 | json.writeNumberField("uptime", vm.uptime()); 97 | json.writeNumberField("fd_usage", vm.fileDescriptorUsage()); 98 | 99 | json.writeFieldName("thread-states"); 100 | json.writeStartObject(); 101 | { 102 | for (Map.Entry entry : vm.threadStatePercentages() 103 | .entrySet()) { 104 | json.writeNumberField(entry.getKey().toString().toLowerCase(), 105 | entry.getValue()); 106 | } 107 | } 108 | json.writeEndObject(); 109 | 110 | json.writeFieldName("garbage-collectors"); 111 | json.writeStartObject(); 112 | { 113 | for (Map.Entry entry : vm.garbageCollectors() 114 | .entrySet()) { 115 | json.writeFieldName(entry.getKey()); 116 | json.writeStartObject(); 117 | { 118 | final VirtualMachineMetrics.GarbageCollectorStats gc = entry.getValue(); 119 | json.writeNumberField("runs", gc.getRuns()); 120 | json.writeNumberField("time", gc.getTime(TimeUnit.MILLISECONDS)); 121 | } 122 | json.writeEndObject(); 123 | } 124 | } 125 | json.writeEndObject(); 126 | } 127 | json.writeEndObject(); 128 | } 129 | 130 | public static void writeRegularMetrics(JsonGenerator json, String classPrefix, MetricProcessor processor) throws IOException { 131 | for (Map.Entry> entry : Metrics.defaultRegistry().groupedMetrics().entrySet()) { 132 | if (classPrefix == null || entry.getKey().startsWith(classPrefix)) { 133 | json.writeFieldName(entry.getKey()); 134 | json.writeStartObject(); 135 | { 136 | for (Map.Entry subEntry : entry.getValue().entrySet()) { 137 | json.writeFieldName(subEntry.getKey().getName()); 138 | try { 139 | subEntry.getValue().processWith(processor, subEntry.getKey(), new Context(json, true)); 140 | } catch (Exception e) { 141 | Logger.warn("Error writing out " + subEntry.getKey(), e); 142 | } 143 | } 144 | } 145 | json.writeEndObject(); 146 | } 147 | } 148 | } 149 | 150 | static final class Context { 151 | final boolean showFullSamples; 152 | final JsonGenerator json; 153 | 154 | Context(JsonGenerator json, boolean showFullSamples) { 155 | this.json = json; 156 | this.showFullSamples = showFullSamples; 157 | } 158 | } 159 | 160 | public static class ServerMetrics implements MetricProcessor { 161 | @Override 162 | public void processMeter(MetricName name, Metered meter, Context context) throws Exception { 163 | final JsonGenerator json = context.json; 164 | json.writeStartObject(); 165 | { 166 | json.writeStringField("type", "meter"); 167 | json.writeStringField("event_type", meter.eventType()); 168 | writeMeteredFields(meter, json); 169 | } 170 | json.writeEndObject(); 171 | } 172 | 173 | @Override 174 | public void processCounter(MetricName name, Counter counter, Context context) throws Exception { 175 | final JsonGenerator json = context.json; 176 | json.writeStartObject(); 177 | { 178 | json.writeStringField("type", "counter"); 179 | json.writeNumberField("count", counter.count()); 180 | } 181 | json.writeEndObject(); 182 | } 183 | 184 | @Override 185 | public void processHistogram(MetricName name, Histogram histogram, Context context) throws Exception { 186 | final JsonGenerator json = context.json; 187 | json.writeStartObject(); 188 | { 189 | json.writeStringField("type", "histogram"); 190 | json.writeNumberField("count", histogram.count()); 191 | writeSummarizable(histogram, json); 192 | writeSampling(histogram, json); 193 | 194 | if (context.showFullSamples) { 195 | json.writeObjectField("values", histogram.getSnapshot().getValues()); 196 | } 197 | } 198 | json.writeEndObject(); 199 | } 200 | 201 | private static Object evaluateGauge(Gauge gauge) { 202 | try { 203 | return gauge.value(); 204 | } catch (RuntimeException e) { 205 | Logger.warn("Error evaluating gauge", e); 206 | return "error reading gauge: " + e.getMessage(); 207 | } 208 | } 209 | 210 | @Override 211 | public void processTimer(MetricName name, Timer timer, Context context) throws Exception { 212 | final JsonGenerator json = context.json; 213 | json.writeStartObject(); 214 | { 215 | json.writeStringField("type", "timer"); 216 | json.writeFieldName("duration"); 217 | json.writeStartObject(); 218 | { 219 | json.writeStringField("unit", timer.durationUnit().toString().toLowerCase()); 220 | writeSummarizable(timer, json); 221 | writeSampling(timer, json); 222 | if (context.showFullSamples) { 223 | json.writeObjectField("values", timer.getSnapshot().getValues()); 224 | } 225 | } 226 | json.writeEndObject(); 227 | 228 | json.writeFieldName("rate"); 229 | json.writeStartObject(); 230 | { 231 | writeMeteredFields(timer, json); 232 | } 233 | json.writeEndObject(); 234 | } 235 | json.writeEndObject(); 236 | } 237 | 238 | @Override 239 | public void processGauge(MetricName name, Gauge gauge, Context context) throws Exception { 240 | final JsonGenerator json = context.json; 241 | json.writeStartObject(); 242 | { 243 | json.writeStringField("type", "gauge"); 244 | json.writeObjectField("value", evaluateGauge(gauge)); 245 | } 246 | json.writeEndObject(); 247 | } 248 | 249 | private static void writeSummarizable(Summarizable metric, JsonGenerator json) throws IOException { 250 | json.writeNumberField("min", metric.min()); 251 | json.writeNumberField("max", metric.max()); 252 | json.writeNumberField("mean", metric.mean()); 253 | json.writeNumberField("std_dev", metric.stdDev()); 254 | } 255 | 256 | private static void writeSampling(Sampling metric, JsonGenerator json) throws IOException { 257 | final Snapshot snapshot = metric.getSnapshot(); 258 | json.writeNumberField("median", snapshot.getMedian()); 259 | json.writeNumberField("p75", snapshot.get75thPercentile()); 260 | json.writeNumberField("p95", snapshot.get95thPercentile()); 261 | json.writeNumberField("p98", snapshot.get98thPercentile()); 262 | json.writeNumberField("p99", snapshot.get99thPercentile()); 263 | json.writeNumberField("p999", snapshot.get999thPercentile()); 264 | } 265 | 266 | private static void writeMeteredFields(Metered metered, JsonGenerator json) throws IOException { 267 | json.writeStringField("unit", metered.rateUnit().toString().toLowerCase()); 268 | json.writeNumberField("count", metered.count()); 269 | json.writeNumberField("mean", metered.meanRate()); 270 | json.writeNumberField("m1", metered.oneMinuteRate()); 271 | json.writeNumberField("m5", metered.fiveMinuteRate()); 272 | json.writeNumberField("m15", metered.fifteenMinuteRate()); 273 | } 274 | } 275 | 276 | public static Result health() { 277 | response().setContentType("text/plain"); 278 | response().setHeader("Cache-Control", "must-revalidate,no-cache,no-store"); 279 | 280 | Map results = HealthChecks.defaultRegistry().runHealthChecks(); 281 | 282 | if (results.isEmpty()) { 283 | return badRequest("No health checks registered."); 284 | } else { 285 | StringWriter stringWriter = new StringWriter(); 286 | PrintWriter writer = new PrintWriter(stringWriter); 287 | 288 | for (Map.Entry entry : results.entrySet()) { 289 | final HealthCheck.Result result = entry.getValue(); 290 | if (result.isHealthy()) { 291 | if (result.getMessage() != null) { 292 | writer.format("* %s: OK\n %s\n", entry.getKey(), result.getMessage()); 293 | } else { 294 | writer.format("* %s: OK\n", entry.getKey()); 295 | } 296 | } else { 297 | if (result.getMessage() != null) { 298 | writer.format("! %s: ERROR\n! %s\n", entry.getKey(), result.getMessage()); 299 | } 300 | 301 | @SuppressWarnings("ThrowableResultOfMethodCallIgnored") 302 | final Throwable error = result.getError(); 303 | if (error != null) { 304 | writer.println(); 305 | error.printStackTrace(writer); 306 | writer.println(); 307 | } 308 | } 309 | } 310 | 311 | if (isAllHealthy(results)) { 312 | return ok(stringWriter.toString()); 313 | } else { 314 | return internalServerError(stringWriter.toString()); 315 | } 316 | } 317 | } 318 | 319 | private static boolean isAllHealthy(Map results) { 320 | for (HealthCheck.Result result : results.values()) { 321 | if (!result.isHealthy()) { 322 | return false; 323 | } 324 | } 325 | return true; 326 | } 327 | 328 | public static Result threaddump() throws Exception { 329 | response().setContentType("text/plain"); 330 | response().setHeader("Cache-Control", "must-revalidate,no-cache,no-store"); 331 | 332 | ByteArrayOutputStream out = new ByteArrayOutputStream(); 333 | VirtualMachineMetrics.getInstance().threadDump(out); 334 | 335 | return ok(out.toString("UTF-8")); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /app/models/CartesianLocation.java: -------------------------------------------------------------------------------- 1 | package models; 2 | 3 | import org.mongodb.morphia.annotations.Embedded; 4 | import org.mongodb.morphia.annotations.Property; 5 | import com.google.common.base.Objects; 6 | 7 | /** 8 | * @author Mathias Bogaert 9 | */ 10 | @Embedded 11 | public class CartesianLocation { 12 | @Property("e") 13 | public int eastings; 14 | 15 | @Property("n") 16 | public int northings; 17 | 18 | public CartesianLocation() { 19 | } 20 | 21 | public CartesianLocation(int eastings, int northings) { 22 | this.eastings = eastings; 23 | this.northings = northings; 24 | } 25 | 26 | @Override 27 | public String toString() { 28 | return Objects.toStringHelper(this) 29 | .add("eastings", eastings) 30 | .add("northings", northings) 31 | .toString(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/models/Location.java: -------------------------------------------------------------------------------- 1 | package models; 2 | 3 | import org.mongodb.morphia.annotations.Embedded; 4 | import org.mongodb.morphia.annotations.Property; 5 | import com.google.common.base.Objects; 6 | 7 | /** 8 | * @author Mathias Bogaert 9 | */ 10 | @Embedded 11 | public class Location { 12 | @Property("lat") 13 | public double latitude; 14 | 15 | @Property("lng") 16 | public double longitude; 17 | 18 | public Location() { 19 | } 20 | 21 | public Location(double latitude, double longitude) { 22 | this.latitude = latitude; 23 | this.longitude = longitude; 24 | } 25 | 26 | @Override 27 | public String toString() { 28 | return Objects.toStringHelper(this) 29 | .add("latitude", latitude) 30 | .add("longitude", longitude) 31 | .toString(); 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /app/models/Model.java: -------------------------------------------------------------------------------- 1 | package models; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.google.inject.Inject; 5 | import com.mongodb.DBCollection; 6 | import com.mongodb.DBObject; 7 | import com.mongodb.ReadPreference; 8 | import com.mongodb.WriteResult; 9 | import org.bson.types.CodeWScope; 10 | import org.bson.types.ObjectId; 11 | import org.mongodb.morphia.Datastore; 12 | import org.mongodb.morphia.Key; 13 | import org.mongodb.morphia.annotations.Id; 14 | import org.mongodb.morphia.query.*; 15 | 16 | import java.util.Collections; 17 | import java.util.Iterator; 18 | import java.util.List; 19 | 20 | /** 21 | * @author Mathias Bogaert 22 | */ 23 | public abstract class Model { 24 | @Inject 25 | public static Datastore datastore; // requestStaticInjection(..) 26 | 27 | @Id 28 | @JsonIgnore 29 | public ObjectId id; 30 | 31 | @Override 32 | public boolean equals(Object o) { 33 | if (this == o) return true; 34 | if (o == null || getClass() != o.getClass()) return false; 35 | 36 | Model model = (Model) o; 37 | 38 | if (id != null ? !id.equals(model.id) : model.id != null) return false; 39 | 40 | return true; 41 | } 42 | 43 | @Override 44 | public int hashCode() { 45 | return id != null ? id.hashCode() : 0; 46 | } 47 | 48 | public Key key() { 49 | return datastore.getKey(this); 50 | } 51 | 52 | public void save() { 53 | datastore.save(this); 54 | } 55 | 56 | public void merge() { 57 | datastore.merge(this); 58 | } 59 | 60 | public WriteResult delete() { 61 | return datastore.delete(this); 62 | } 63 | 64 | /** 65 | * @author Mathias Bogaert 66 | */ 67 | public static final class Finder implements Query { 68 | private final Class type; 69 | 70 | public Finder(Class type) { 71 | this.type = type; 72 | } 73 | 74 | public T byId(String id) { 75 | return datastore.get(type, ObjectId.massageToObjectId(id)); 76 | } 77 | 78 | public T byId(ObjectId objectId) { 79 | return datastore.get(type, objectId); 80 | } 81 | 82 | public List byIds(Iterable ids) { 83 | if (ids == null || !ids.iterator().hasNext()) return Collections.emptyList(); 84 | return datastore.get(type, ids).asList(); 85 | } 86 | 87 | public long count() { 88 | return datastore.getCount(type); 89 | } 90 | 91 | public Query query() { 92 | return datastore.find(type); 93 | } 94 | 95 | @Override 96 | public Query filter(String condition, Object value) { 97 | return query().filter(condition, value); 98 | } 99 | 100 | @Override 101 | public FieldEnd> field(String field) { 102 | return query().field(field); 103 | } 104 | 105 | @Override 106 | public FieldEnd criteria(String field) { 107 | return query().criteria(field); 108 | } 109 | 110 | @Override 111 | public CriteriaContainer and(Criteria... criteria) { 112 | return query().and(criteria); 113 | } 114 | 115 | @Override 116 | public CriteriaContainer or(Criteria... criteria) { 117 | return query().or(criteria); 118 | } 119 | 120 | @Override 121 | public Query where(String js) { 122 | return query().where(js); 123 | } 124 | 125 | @Override 126 | public Query where(CodeWScope js) { 127 | return query().where(js); 128 | } 129 | 130 | @Override 131 | public Query order(String condition) { 132 | return query().order(condition); 133 | } 134 | 135 | @Override 136 | public Query limit(int value) { 137 | return query().limit(value); 138 | } 139 | 140 | @Override 141 | public Query batchSize(int value) { 142 | return query().batchSize(value); 143 | } 144 | 145 | @Override 146 | public Query maxScan(int value) { 147 | return query().maxScan(value); 148 | } 149 | 150 | @Override 151 | public Query offset(int value) { 152 | return query().offset(value); 153 | } 154 | 155 | @Override 156 | public Query upperIndexBound(DBObject upperBound) { 157 | return query().upperIndexBound(upperBound); 158 | } 159 | 160 | @Override 161 | public Query lowerIndexBound(DBObject lowerBound) { 162 | return query().lowerIndexBound(lowerBound); 163 | } 164 | 165 | @Override 166 | @Deprecated 167 | public Query skip(int value) { 168 | return query().skip(value); 169 | } 170 | 171 | @Override 172 | public Query enableValidation() { 173 | return query().enableValidation(); 174 | } 175 | 176 | @Override 177 | public Query disableValidation() { 178 | return query().disableValidation(); 179 | } 180 | 181 | @Override 182 | public Query hintIndex(String idxName) { 183 | return query().hintIndex(idxName); 184 | } 185 | 186 | @Override 187 | public Query retrievedFields(boolean include, String... fields) { 188 | return query().retrievedFields(include, fields); 189 | } 190 | 191 | @Override 192 | public Query retrieveKnownFields() { 193 | return query().retrieveKnownFields(); 194 | } 195 | 196 | @Override 197 | public Query enableSnapshotMode() { 198 | return query().enableSnapshotMode(); 199 | } 200 | 201 | @Override 202 | public Query disableSnapshotMode() { 203 | return query().disableSnapshotMode(); 204 | } 205 | 206 | @Override 207 | public Query queryNonPrimary() { 208 | return query().queryNonPrimary(); 209 | } 210 | 211 | @Override 212 | public Query queryPrimaryOnly() { 213 | return query().queryPrimaryOnly(); 214 | } 215 | 216 | @Override 217 | public Query useReadPreference(ReadPreference readPref) { 218 | return query().useReadPreference(readPref); 219 | } 220 | 221 | @Override 222 | public Query disableCursorTimeout() { 223 | return query().disableCursorTimeout(); 224 | } 225 | 226 | @Override 227 | public Query enableCursorTimeout() { 228 | return query().enableCursorTimeout(); 229 | } 230 | 231 | @Override 232 | public Class getEntityClass() { 233 | return type; 234 | } 235 | 236 | @Override 237 | public int getOffset() { 238 | return query().getOffset(); 239 | } 240 | 241 | @Override 242 | public int getLimit() { 243 | return query().getLimit(); 244 | } 245 | 246 | @Override 247 | public int getBatchSize() { 248 | return query().getBatchSize(); 249 | } 250 | 251 | @Override 252 | public DBObject getQueryObject() { 253 | return query().getQueryObject(); 254 | } 255 | 256 | @Override 257 | public DBObject getSortObject() { 258 | return query().getSortObject(); 259 | } 260 | 261 | @Override 262 | public DBObject getFieldsObject() { 263 | return query().getFieldsObject(); 264 | } 265 | 266 | @Override 267 | public DBCollection getCollection() { 268 | return query().getCollection(); 269 | } 270 | 271 | @Override 272 | public T get() { 273 | return query().get(); 274 | } 275 | 276 | @Override 277 | public Key getKey() { 278 | return query().getKey(); 279 | } 280 | 281 | @Override 282 | public List asList() { 283 | return query().asList(); 284 | } 285 | 286 | @Override 287 | public List> asKeyList() { 288 | return query().asKeyList(); 289 | } 290 | 291 | @Override 292 | public Iterable fetch() { 293 | return query().fetch(); 294 | } 295 | 296 | @Override 297 | public Iterable fetchEmptyEntities() { 298 | return query().fetchEmptyEntities(); 299 | } 300 | 301 | @Override 302 | public Iterable> fetchKeys() { 303 | return query().fetchKeys(); 304 | } 305 | 306 | @Override 307 | public long countAll() { 308 | return query().countAll(); 309 | } 310 | 311 | @Override 312 | public Iterator tail() { 313 | return query().tail(); 314 | } 315 | 316 | @Override 317 | public Iterator tail(boolean awaitData) { 318 | return query().tail(awaitData); 319 | } 320 | 321 | @Override 322 | public Iterator iterator() { 323 | return query().iterator(); 324 | } 325 | 326 | @Override 327 | public Query cloneQuery() { 328 | return query().cloneQuery(); 329 | } 330 | } 331 | } -------------------------------------------------------------------------------- /app/models/PostcodeUnit.java: -------------------------------------------------------------------------------- 1 | package models; 2 | 3 | import org.mongodb.morphia.annotations.Embedded; 4 | import org.mongodb.morphia.annotations.Entity; 5 | import org.mongodb.morphia.annotations.Indexed; 6 | import org.mongodb.morphia.annotations.Property; 7 | import org.mongodb.morphia.utils.IndexDirection; 8 | import com.google.common.base.Objects; 9 | 10 | /** 11 | * @author Mathias Bogaert 12 | */ 13 | @Entity(value = "pcu", noClassnameStored = true, concern = "NORMAL") 14 | public class PostcodeUnit extends Model { 15 | @Property("p") 16 | @Indexed(unique = true) 17 | public String postcode; 18 | 19 | @Property("q") 20 | public String pqi; // quality indicator, 10 = best, 90 = least 21 | 22 | @Embedded("c") 23 | public CartesianLocation cartesianLocation; 24 | 25 | @Embedded("l") 26 | @Indexed(IndexDirection.GEO2D) 27 | public Location location; 28 | 29 | // FINDERS ---------- 30 | 31 | public static final Finder find = new Finder(PostcodeUnit.class); 32 | 33 | public PostcodeUnit() { 34 | } 35 | 36 | public PostcodeUnit(String postcode) { 37 | this.postcode = postcode; 38 | } 39 | 40 | public PostcodeUnit(String postcode, String pqi, Location location) { 41 | this.postcode = postcode; 42 | this.pqi = pqi; 43 | this.location = location; 44 | } 45 | 46 | @Override 47 | public String toString() { 48 | return Objects.toStringHelper(this) 49 | .add("postcode", postcode) 50 | .add("pqi", pqi) 51 | .add("cartesianLocation", cartesianLocation) 52 | .add("location", location) 53 | .toString(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/models/csv/CodePointOpenCsvEntry.java: -------------------------------------------------------------------------------- 1 | package models.csv; 2 | 3 | import com.google.common.base.Objects; 4 | import org.apache.camel.dataformat.bindy.annotation.CsvRecord; 5 | import org.apache.camel.dataformat.bindy.annotation.DataField; 6 | 7 | /** 8 | * Represents a Code-Point Open CSV entry. 9 | * 10 | * Code-Point Open is a postal geography dataset that features a set of geographically referenced points that 11 | * represent each of the 1.7 million postcode units in Great Britain. The centre of the postcode unit is derived 12 | * from the precise coordinates of addresses sharing the same postcode unit in Ordnance Survey’s large-scale address 13 | * database. 14 | * 15 | * @author Mathias Bogaert 16 | */ 17 | @CsvRecord(separator = ",") 18 | public class CodePointOpenCsvEntry { 19 | @DataField(pos = 1) 20 | private String postcode; 21 | 22 | @DataField(pos = 2) 23 | private String positionalQualityIndicator; 24 | 25 | @DataField(pos = 3) 26 | private String eastings; 27 | 28 | @DataField(pos = 4) 29 | private String northings; 30 | 31 | @DataField(pos = 5) 32 | private String countryCode; 33 | 34 | @DataField(pos = 6) 35 | private String nhsRegionalHa; 36 | 37 | @DataField(pos = 7) 38 | private String nhsHa; 39 | 40 | @DataField(pos = 8) 41 | private String adminCountryCode; 42 | 43 | @DataField(pos = 9) 44 | private String adminDistrictCode; 45 | 46 | @DataField(pos = 10) 47 | private String adminWardCode; 48 | 49 | public String getPostcode() { 50 | return postcode; 51 | } 52 | 53 | public void setPostcode(String postcode) { 54 | this.postcode = postcode; 55 | } 56 | 57 | public String getPositionalQualityIndicator() { 58 | return positionalQualityIndicator; 59 | } 60 | 61 | public void setPositionalQualityIndicator(String positionalQualityIndicator) { 62 | this.positionalQualityIndicator = positionalQualityIndicator; 63 | } 64 | 65 | public String getEastings() { 66 | return eastings; 67 | } 68 | 69 | public void setEastings(String eastings) { 70 | this.eastings = eastings; 71 | } 72 | 73 | public String getNorthings() { 74 | return northings; 75 | } 76 | 77 | public void setNorthings(String northings) { 78 | this.northings = northings; 79 | } 80 | 81 | public String getCountryCode() { 82 | return countryCode; 83 | } 84 | 85 | public void setCountryCode(String countryCode) { 86 | this.countryCode = countryCode; 87 | } 88 | 89 | public String getNhsRegionalHa() { 90 | return nhsRegionalHa; 91 | } 92 | 93 | public void setNhsRegionalHa(String nhsRegionalHa) { 94 | this.nhsRegionalHa = nhsRegionalHa; 95 | } 96 | 97 | public String getNhsHa() { 98 | return nhsHa; 99 | } 100 | 101 | public void setNhsHa(String nhsHa) { 102 | this.nhsHa = nhsHa; 103 | } 104 | 105 | public String getAdminCountryCode() { 106 | return adminCountryCode; 107 | } 108 | 109 | public void setAdminCountryCode(String adminCountryCode) { 110 | this.adminCountryCode = adminCountryCode; 111 | } 112 | 113 | public String getAdminDistrictCode() { 114 | return adminDistrictCode; 115 | } 116 | 117 | public void setAdminDistrictCode(String adminDistrictCode) { 118 | this.adminDistrictCode = adminDistrictCode; 119 | } 120 | 121 | public String getAdminWardCode() { 122 | return adminWardCode; 123 | } 124 | 125 | public void setAdminWardCode(String adminWardCode) { 126 | this.adminWardCode = adminWardCode; 127 | } 128 | 129 | @Override 130 | public String toString() { 131 | return Objects.toStringHelper(this) 132 | .add("postcode", postcode) 133 | .add("positionalQualityIndicator", positionalQualityIndicator) 134 | .add("eastings", eastings) 135 | .add("northings", northings) 136 | .add("countryCode", countryCode) 137 | .add("nhsRegionalHa", nhsRegionalHa) 138 | .add("nhsHa", nhsHa) 139 | .add("adminCountryCode", adminCountryCode) 140 | .add("adminDistrictCode", adminDistrictCode) 141 | .add("adminWardCode", adminWardCode) 142 | .toString(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /app/modules/CamelcodeModule.java: -------------------------------------------------------------------------------- 1 | package modules; 2 | 3 | import com.google.inject.AbstractModule; 4 | import org.apache.camel.CamelContext; 5 | import org.apache.camel.impl.DefaultCamelContext; 6 | 7 | /** 8 | * @author Mathias Bogaert 9 | */ 10 | public class CamelcodeModule extends AbstractModule { 11 | @Override 12 | protected void configure() { 13 | bind(CamelContext.class).to(DefaultCamelContext.class).asEagerSingleton(); 14 | } 15 | } -------------------------------------------------------------------------------- /app/modules/MorphiaModule.java: -------------------------------------------------------------------------------- 1 | package modules; 2 | 3 | import org.mongodb.morphia.Datastore; 4 | import org.mongodb.morphia.Morphia; 5 | import org.mongodb.morphia.mapping.DefaultCreator; 6 | import com.google.inject.AbstractModule; 7 | import com.google.inject.Provides; 8 | import com.mongodb.DBObject; 9 | import com.mongodb.Mongo; 10 | import com.mongodb.MongoURI; 11 | import models.Model; 12 | import models.PostcodeUnit; 13 | import play.Application; 14 | import play.Logger; 15 | 16 | import java.net.UnknownHostException; 17 | 18 | /** 19 | * @author Mathias Bogaert 20 | */ 21 | public class MorphiaModule extends AbstractModule { 22 | @Override 23 | protected void configure() { 24 | requireBinding(Application.class); 25 | requestStaticInjection(Model.class); 26 | } 27 | 28 | @Provides 29 | Morphia createMorphia(final Application application) { 30 | Morphia morphia = new Morphia(); 31 | morphia.getMapper().getOptions().objectFactory = new DefaultCreator() { 32 | @Override 33 | protected ClassLoader getClassLoaderForClass(String clazz, DBObject object) { 34 | return application.classloader(); 35 | } 36 | }; 37 | 38 | morphia.map(PostcodeUnit.class); 39 | 40 | return morphia; 41 | } 42 | 43 | @Provides 44 | Datastore createDatastore(Mongo mongo, Morphia morphia, final Application application) { 45 | Datastore datastore = morphia.createDatastore( 46 | mongo, 47 | application.configuration().getString("mongodb.db"), 48 | application.configuration().getString("mongodb.username"), 49 | application.configuration().getString("mongodb.password").toCharArray()); 50 | 51 | datastore.ensureIndexes(); 52 | 53 | Logger.info("Connected to MongoDB [" + mongo.debugString() + "] database [" + datastore.getDB().getName() + "]"); 54 | return datastore; 55 | } 56 | 57 | @Provides 58 | Mongo create(final Application application) { 59 | try { 60 | return new Mongo(new MongoURI(application.configuration().getString("mongodb.uri"))); 61 | } catch (UnknownHostException e) { 62 | addError(e); 63 | return null; 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/services/CPOCsvCamelWatchService.java: -------------------------------------------------------------------------------- 1 | package services; 2 | 3 | import akka.actor.ActorRef; 4 | import com.google.common.base.Throwables; 5 | import com.google.common.util.concurrent.AbstractService; 6 | import com.google.inject.Inject; 7 | import com.google.inject.name.Named; 8 | import models.csv.CodePointOpenCsvEntry; 9 | import org.apache.camel.CamelContext; 10 | import org.apache.camel.Exchange; 11 | import org.apache.camel.Processor; 12 | import org.apache.camel.builder.RouteBuilder; 13 | import org.apache.camel.model.dataformat.BindyType; 14 | 15 | import java.util.Map; 16 | 17 | /** 18 | * Service that watches the 'cpo.watch' directory for CodePoint-Open CSV files using Apache Camel. 19 | * 20 | * @author Mathias Bogaert 21 | */ 22 | public class CPOCsvCamelWatchService extends AbstractService { 23 | private final CamelContext camelContext; 24 | 25 | @Inject 26 | public CPOCsvCamelWatchService(@Named("ProcessCPOCsvEntry") final ActorRef actorRef, 27 | CamelContext camelContext, 28 | final @Named("cpo.from") String cpoFrom) throws Exception { 29 | this.camelContext = camelContext; 30 | 31 | camelContext.addRoutes(new RouteBuilder() { 32 | @Override 33 | public void configure() throws Exception { 34 | from(cpoFrom) 35 | .unmarshal().bindy(BindyType.Csv, "models.csv") 36 | .split(body()) 37 | .process(new Processor() { 38 | @SuppressWarnings("unchecked") 39 | @Override 40 | public void process(Exchange exchange) throws Exception { 41 | Object body = exchange.getIn().getBody(); 42 | 43 | if (body instanceof Map) { 44 | Map csvEntryMap = (Map) body; 45 | 46 | for (CodePointOpenCsvEntry entry : csvEntryMap.values()) { 47 | actorRef.tell(entry, ActorRef.noSender()); 48 | } 49 | } else { 50 | throw new RuntimeException("something went wrong; message body is no map!"); 51 | } 52 | } 53 | }); 54 | } 55 | }); 56 | } 57 | 58 | @Override 59 | protected void doStart() { 60 | try { 61 | camelContext.start(); 62 | } catch (Exception e) { 63 | throw Throwables.propagate(e); 64 | } 65 | } 66 | 67 | @Override 68 | protected void doStop() { 69 | try { 70 | camelContext.stop(); 71 | } catch (Exception e) { 72 | throw Throwables.propagate(e); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/services/MongoService.java: -------------------------------------------------------------------------------- 1 | package services; 2 | 3 | import com.google.common.util.concurrent.AbstractService; 4 | import com.google.inject.Inject; 5 | import com.mongodb.Mongo; 6 | import com.mongodb.MongoException; 7 | import com.yammer.metrics.HealthChecks; 8 | import com.yammer.metrics.core.HealthCheck; 9 | 10 | /** 11 | * Service that shuts down the MongoDB connection. 12 | * 13 | * @author Mathias Bogaert 14 | */ 15 | public class MongoService extends AbstractService { 16 | public static final String HEALTH_CHECK_NAME = "mongo.connection"; 17 | 18 | private final Mongo mongo; 19 | 20 | @Inject 21 | public MongoService(Mongo mongo) { 22 | this.mongo = mongo; 23 | } 24 | 25 | @Override 26 | protected void doStart() { 27 | HealthChecks.register(new HealthCheck(HEALTH_CHECK_NAME) { 28 | @Override 29 | protected Result check() throws Exception { 30 | try { 31 | mongo.getDatabaseNames(); 32 | return Result.healthy(mongo.debugString()); 33 | } catch (MongoException e) { 34 | return Result.unhealthy(e); 35 | } 36 | } 37 | }); 38 | } 39 | 40 | @Override 41 | protected void doStop() { 42 | HealthChecks.defaultRegistry().unregister(HEALTH_CHECK_NAME); 43 | 44 | mongo.close(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/utils/MoreMatchers.java: -------------------------------------------------------------------------------- 1 | package utils; 2 | 3 | import com.google.inject.TypeLiteral; 4 | import com.google.inject.matcher.AbstractMatcher; 5 | import com.google.inject.matcher.Matcher; 6 | 7 | /** 8 | * A factory of {@link Matcher}s that can be used in 9 | * {@link com.google.inject.AbstractModule}.bindListener(matcher,listener) 10 | * 11 | * @author Mathias Bogaert 12 | */ 13 | public class MoreMatchers { 14 | private static class SubClassesOf extends AbstractMatcher> { 15 | private final Class baseClass; 16 | 17 | private SubClassesOf(Class baseClass) { 18 | this.baseClass = baseClass; 19 | } 20 | 21 | @Override 22 | public boolean matches(TypeLiteral t) { 23 | return baseClass.isAssignableFrom(t.getRawType()); 24 | } 25 | } 26 | 27 | /** 28 | * Matcher matches all classes that extends, implements or is the same as baseClass 29 | * 30 | * @param baseClass the class to match subclasses for 31 | * @return Matcher 32 | */ 33 | public static Matcher> subclassesOf(Class baseClass) { 34 | return new SubClassesOf(baseClass); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/views/index.scala.html: -------------------------------------------------------------------------------- 1 | @(distanceCalc: Form[Application.DistanceCalc], geocode: Form[Application.Geocode], postcodeUnits: List[models.PostcodeUnit]) 2 | 3 | @implicitField = @{helper.FieldConstructor(twitterBootstrapFieldConstructor.f)} 4 | 5 | @main("Welcome to CamelCode") { 6 |
7 |
8 |

CamelCode

9 | 10 |

11 | A tech demo built using Play Framework that 12 | imports the CodePoint Open UK 13 | postcode dataset and offers a Geocoding REST API. 14 |

15 |
16 |
17 | 18 |
19 |
20 |
21 |

Geocode

22 |

23 | Geocoding UK postcodes has never been so easy! The 1.7 million 24 | CodePoint Open postcodes 25 | are imported in under one minute, and average response time is below 10ms on modern hardware. Try these: 26 | BS10 6TF, 27 | SW1A 2AA. 28 |

29 |
30 |
31 |

Postcode map

32 |

33 | Visit the postcode map. Click anywhere to display postcodes. 34 | CTRL-Double click for distances. 35 |

36 |
37 |
38 |

Shoulders

39 |

40 | Built standing on the shoulders of 41 | Play Framework 2.2 , 42 | Apache Camel , 43 | GeoTools , 44 | MongoDB , 45 | Morphia , 46 | Google Guice , 47 | Twitter Bootstrap and 48 | Font Awesome . 49 |

50 |
51 |
52 |
53 |
54 |

Completely free

55 |

56 | The CamelCode project is free for commercial use. 57 | Follow @@analytically 58 | for updates and programming tips and tricks. Development sponsored by Coen Recruitment. 59 |

60 |
61 |
62 | 67 | 68 |
69 |
70 | @helper.form(routes.Application.calc()) { 71 |
72 | @helper.input(distanceCalc("postcode"), '_label -> "Postcode:") { (id, name, value, args) => 73 | 74 | } 75 | 76 | @helper.input(distanceCalc("distance"), '_label -> "Within (miles):") { (id, name, value, args) => 77 | 78 | } 79 | 80 | 81 |
82 | } 83 |
84 |
85 | @helper.form(routes.Application.ll) { 86 |
87 | @helper.input(geocode("postcode"), '_label -> "Postcode:") { (id, name, value, args) => 88 | 89 | } 90 | 91 | 92 |
93 | } 94 |
95 |
96 | @helper.form(routes.Application.en) { 97 |
98 | @helper.input(geocode("postcode"), '_label -> "Postcode:") { (id, name, value, args) => 99 | 100 | } 101 | 102 | 103 |
104 | } 105 |
106 |
107 |
108 |
109 |

Metrics

110 | 111 | Check the server 112 | metrics and 113 | health. 114 |
115 |
116 | 117 |
118 | 119 | @if(flash.containsKey("success")) { 120 |
121 | Done! @flash.get("success") 122 |
123 | } 124 | 125 | @if(postcodeUnits.size > 0) { 126 |
127 |
    128 | @for(unit <- postcodeUnits) { 129 |
  • @unit.postcode - @unit.location.latitude, @unit.location.longitude
  • 130 | } 131 |
132 |
133 | } 134 | 135 | Coded by @@analytically. 136 |
137 | } 138 | -------------------------------------------------------------------------------- /app/views/main.scala.html: -------------------------------------------------------------------------------- 1 | @(title: String, 2 | styles: Html = Html(""), 3 | scripts: Html = Html(""))(content: Html) 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | @title - CamelCode 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 32 | 33 | 34 | 35 | 36 | 43 | @styles 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | @scripts 52 | 53 | @content 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/views/map.scala.html: -------------------------------------------------------------------------------- 1 | @main("Postcode Map") { 2 | 3 |
4 | 78 | } -------------------------------------------------------------------------------- /app/views/twitterBootstrapFieldConstructor.scala.html: -------------------------------------------------------------------------------- 1 | @(elements: helper.FieldElements) 2 | 3 |
4 | 5 |
6 | @elements.input 7 | @elements.errors(elements.lang).mkString(", ") 8 | @elements.infos(elements.lang).mkString(", ") 9 |
10 |
-------------------------------------------------------------------------------- /conf/application.conf: -------------------------------------------------------------------------------- 1 | # This is the main configuration file for the application. 2 | # ~~~~~ 3 | 4 | # Secret key 5 | # ~~~~~ 6 | # The secret key is used to secure cryptographics functions. 7 | # If you deploy your application to several instances be sure to use the same key! 8 | application.secret="_PvKT/CRyPM[rLyV:a<>s9G6DhoyNCRBjdgi5WqGYH;X<2Y6pa`e5TUy9:RxhdVW" 9 | 10 | # The application languages 11 | # ~~~~~ 12 | application.langs="en" 13 | 14 | # Akka Configuration 15 | 16 | akka.default-dispatcher.core-pool-size-min = 16 17 | akka.default-dispatcher.core-pool-size-max = 64 18 | 19 | # Logger 20 | # ~~~~~ 21 | # You can also configure logback (http://logback.qos.ch/), by providing a logger.xml file in the conf directory . 22 | 23 | # Root logger: 24 | logger.root=ERROR 25 | 26 | # Logger used by the framework: 27 | logger.play=INFO 28 | 29 | # Logger provided to your application: 30 | logger.application=DEBUG 31 | 32 | # From where should we pickup CodePoint Open CSV files 33 | cpo.from="file://codepointopen/?move=done" 34 | 35 | # MongoDB configuration 36 | # See http://www.mongodb.org/display/DOCS/Connections 37 | # ~~~~~ 38 | 39 | mongodb.uri="mongodb://localhost/" 40 | mongodb.db="camelcode" 41 | mongodb.username=null 42 | mongodb.password="" 43 | -------------------------------------------------------------------------------- /conf/routes: -------------------------------------------------------------------------------- 1 | # Routes 2 | # This file defines all application routes (Higher priority routes first) 3 | # ~~~~ 4 | 5 | # Home page 6 | GET / controllers.Application.index() 7 | GET /map controllers.Application.map() 8 | 9 | POST /latlng controllers.Application.ll 10 | GET /latlng/:postcode controllers.Application.latLng(postcode: String) 11 | 12 | GET /en controllers.Application.en 13 | GET /eastingnorthings/:postcode controllers.Application.eastingsNorthings(postcode: String) 14 | 15 | GET /near/:lat/:lng controllers.Application.near(lat: String, lng: String) 16 | POST /calc controllers.Application.calc() 17 | 18 | # Utilities for debugging server issues 19 | GET /health controllers.Server.health() 20 | GET /threaddump controllers.Server.threaddump() 21 | GET /servermetrics controllers.Server.metrics(classPrefix ?= "", pretty:Boolean ?= true) 22 | 23 | # Map static resources from the /public folder to the /assets URL path 24 | GET /assets/*file controllers.Assets.at(path="/public", file) 25 | -------------------------------------------------------------------------------- /lib/com.springsource.javax.media.jai.core-1.1.3.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/lib/com.springsource.javax.media.jai.core-1.1.3.jar -------------------------------------------------------------------------------- /project/Build.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import Keys._ 3 | import play.Project._ 4 | 5 | object Build extends sbt.Build { 6 | val appName = "camelcode" 7 | val appVersion = "1.0-SNAPSHOT" 8 | 9 | val appDependencies = Seq( 10 | javaCore, 11 | 12 | "org.apache.camel" % "camel-core" % "2.11.4", 13 | "org.apache.camel" % "camel-csv" % "2.11.4", 14 | "org.apache.camel" % "camel-bindy" % "2.11.4", 15 | 16 | "org.geotools" % "gt-main" % "10.5" excludeAll 17 | ExclusionRule(organization = "javax.media") 18 | , 19 | 20 | "org.geotools" % "gt-epsg-hsql" % "10.5" excludeAll 21 | ExclusionRule(organization = "javax.media") 22 | , 23 | 24 | "org.reflections" % "reflections" % "0.9.9-RC1", 25 | 26 | // Metrics 27 | "com.yammer.metrics" % "metrics-core" % "2.2.0", 28 | 29 | // Guice 30 | "com.google.inject" % "guice" % "3.0", 31 | "com.google.inject.extensions" % "guice-assistedinject" % "3.0", 32 | "com.google.inject.extensions" % "guice-multibindings" % "3.0", 33 | "com.google.inject.extensions" % "guice-throwingproviders" % "3.0", 34 | 35 | // Morphia 36 | "org.mongodb" % "mongo-java-driver" % "2.11.4", 37 | "org.mongodb.morphia" % "morphia" % "0.107", 38 | "org.mongodb.morphia" % "morphia-logging-slf4j" % "0.107" 39 | ) 40 | 41 | val main = play.Project(appName, appVersion, appDependencies).settings( 42 | resolvers += "Open Source Geospatial Foundation Repository" at "http://download.osgeo.org/webdav/geotools/", 43 | resolvers += "OpenGeo Maven Repository" at "http://repo.opengeo.org" 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.1 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // Comment to get more information during initialization 2 | logLevel := Level.Warn 3 | 4 | // The Typesafe repository 5 | resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/" 6 | 7 | // Use the Play sbt plugin for Play projects 8 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.2.2") 9 | -------------------------------------------------------------------------------- /public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/public/images/favicon.png -------------------------------------------------------------------------------- /public/images/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/public/images/layers.png -------------------------------------------------------------------------------- /public/images/maki/marker-solid-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/public/images/maki/marker-solid-12.png -------------------------------------------------------------------------------- /public/images/maki/marker-solid-18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/public/images/maki/marker-solid-18.png -------------------------------------------------------------------------------- /public/images/maki/marker-solid-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/public/images/maki/marker-solid-24.png -------------------------------------------------------------------------------- /public/images/popup-close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/public/images/popup-close.png -------------------------------------------------------------------------------- /public/images/zoom-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/public/images/zoom-in.png -------------------------------------------------------------------------------- /public/images/zoom-out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/public/images/zoom-out.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/screenshot.png -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytically/camelcode/0c9116f0c185d820439e7c8895405f6db7a503f9/screenshot2.png --------------------------------------------------------------------------------