From f6a86fddad6c7bc532f1f184d186196aa4e7f8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cam=20Sa=C3=BCl?= <cammsaul@gmail.com> Date: Fri, 2 Oct 2015 16:59:19 -0700 Subject: [PATCH] Rewrite Java code for rendering map tiles in Clojure --- project.clj | 2 - .../api/tiles/GoogleMapPinsOverlay.java | 109 -------------- .../metabase/corvus/api/tiles/MapPixel.java | 24 --- .../metabase/corvus/api/tiles/MapPoint.java | 24 --- src/metabase/api/tiles.clj | 137 ++++++++++++------ 5 files changed, 92 insertions(+), 204 deletions(-) delete mode 100644 src/java/com/metabase/corvus/api/tiles/GoogleMapPinsOverlay.java delete mode 100644 src/java/com/metabase/corvus/api/tiles/MapPixel.java delete mode 100644 src/java/com/metabase/corvus/api/tiles/MapPoint.java diff --git a/project.clj b/project.clj index 8f7489c12c2..cc8c80303cd 100644 --- a/project.clj +++ b/project.clj @@ -52,12 +52,10 @@ [swiss-arrows "1.0.0"]] ; 'Magic wand' macro -<>, etc. :plugins [[lein-environ "1.0.0"] ; easy access to environment variables [lein-ring "0.9.3"]] ; start the HTTP server with 'lein ring server' - :java-source-paths ["src/java"] :main ^:skip-aot metabase.core :manifest {"Liquibase-Package" "liquibase.change,liquibase.changelog,liquibase.database,liquibase.parser,liquibase.precondition,liquibase.datatype,liquibase.serializer,liquibase.sqlgenerator,liquibase.executor,liquibase.snapshot,liquibase.logging,liquibase.diff,liquibase.structure,liquibase.structurecompare,liquibase.lockservice,liquibase.sdk,liquibase.ext"} :target-path "target/%s" :javac-options ["-target" "1.6" "-source" "1.6"] - ;; :jar-exclusions [#"\.java"] Circle CI doesn't like regexes because it's using the EDN reader and is retarded :ring {:handler metabase.core/app :init metabase.core/init :destroy metabase.core/destroy} diff --git a/src/java/com/metabase/corvus/api/tiles/GoogleMapPinsOverlay.java b/src/java/com/metabase/corvus/api/tiles/GoogleMapPinsOverlay.java deleted file mode 100644 index 9d3ac1783b0..00000000000 --- a/src/java/com/metabase/corvus/api/tiles/GoogleMapPinsOverlay.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.metabase.corvus.api.tiles; - -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Vector; - -/** - * Created by agilliland on 3/23/15. - */ -public class GoogleMapPinsOverlay { - - public static final int TILE_SIZE = 256; - public static final MapPoint PIXEL_ORIGIN = new MapPoint(TILE_SIZE / 2, TILE_SIZE / 2); - public static final double PIXELS_PER_LON_DEGREE = TILE_SIZE / 360.0; - public static final double PIXELS_PER_LON_RADIAN = TILE_SIZE / (2 * Math.PI); - - - private final int numTiles; - private final BufferedImage tile; - - - public GoogleMapPinsOverlay(int zoom, ArrayList<ArrayList> points) { - // the zoom is an integer which tells us how many tiles (256x256) are in view at the moment - this.numTiles = 1 << zoom; - - // create an empty image to serve as our pins overlay image - this.tile = new BufferedImage(TILE_SIZE, TILE_SIZE, BufferedImage.TYPE_INT_ARGB); - - // do the real work here - render(points); - } - - - // given a set of lat/lon points this will update the image and render a "pin" for each point - private void render(ArrayList<ArrayList> points) { - Graphics g = this.tile.getGraphics(); - g.setColor(Color.red); - try { - for(ArrayList point : points) { - // add a pin to the image - double latitude = (Double) point.get(0); - double longitude = (Double) point.get(1); - - // determine map point given our lat/long - double SinY = this.bound(Math.sin(this.degreesToRadians(latitude)), -0.9999, 0.9999); - MapPoint mapPoint = new MapPoint( - PIXEL_ORIGIN.getX() + (longitude * PIXELS_PER_LON_DEGREE), - PIXEL_ORIGIN.getY() + 0.5 * Math.log((1 + SinY) / (1 - SinY)) * (PIXELS_PER_LON_RADIAN * -1)); - - // determine pixel location of our map point - MapPixel mapPixel = new MapPixel( - (int) Math.floor(mapPoint.getX() * numTiles), - (int) Math.floor(mapPoint.getY() * numTiles)); - - // convert map pixel to tile pixel - MapPixel tilePixel = new MapPixel( - mapPixel.getX() % TILE_SIZE, - mapPixel.getY() % TILE_SIZE); - - // now draw a "pin" at the given tile pixel location - g.fillOval(tilePixel.getX(), tilePixel.getY(), 5, 5); - } - } catch (Throwable t) { - t.printStackTrace(); - } finally { - g.dispose(); - } - } - - - // return our image as a byte[] array. makes it easy to serialize to any other input stream desired. - public byte[] toByteArray() { - ByteArrayOutputStream baos = null; - try { - baos = new ByteArrayOutputStream(); - ImageIO.write(this.tile, "png", baos); - baos.flush(); - return baos.toByteArray(); - } catch(IOException e) { - return new byte[0]; - } finally { - if (baos != null) { - try { - baos.close(); - } catch (Exception ex) {} - } - } - } - - - // make sure the given value stays within a given min/max range. - private double bound(double val, double min_val, double max_val) { - return Math.min(Math.max(val, min_val), max_val); - } - - private double degreesToRadians(double deg) { - return deg * (Math.PI / 180); - } - - private double radiansToDegrees(double rad) { - return rad / (Math.PI / 180); - } - -} diff --git a/src/java/com/metabase/corvus/api/tiles/MapPixel.java b/src/java/com/metabase/corvus/api/tiles/MapPixel.java deleted file mode 100644 index dff56e0ec31..00000000000 --- a/src/java/com/metabase/corvus/api/tiles/MapPixel.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.metabase.corvus.api.tiles; - -/** - * Created by agilliland on 3/23/15. - */ -public class MapPixel { - - private final int x; - private final int y; - - public MapPixel(int x, int y) { - this.x = x; - this.y = y; - } - - - public int getX() { - return x; - } - - public int getY() { - return y; - } -} diff --git a/src/java/com/metabase/corvus/api/tiles/MapPoint.java b/src/java/com/metabase/corvus/api/tiles/MapPoint.java deleted file mode 100644 index 33b1bbd8f33..00000000000 --- a/src/java/com/metabase/corvus/api/tiles/MapPoint.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.metabase.corvus.api.tiles; - -/** - * Created by agilliland on 3/23/15. - */ -public class MapPoint { - - private final double x; - private final double y; - - public MapPoint(double x, double y) { - this.x = x; - this.y = y; - } - - - public double getX() { - return x; - } - - public double getY() { - return y; - } -} diff --git a/src/metabase/api/tiles.clj b/src/metabase/api/tiles.clj index c1545a8f78c..f57872df92a 100644 --- a/src/metabase/api/tiles.clj +++ b/src/metabase/api/tiles.clj @@ -3,58 +3,104 @@ [compojure.core :refer [GET]] [metabase.api.common :refer :all] [metabase.db :refer :all] - [metabase.driver :as driver]) - (:import (java.util ArrayList - Collection))) + [metabase.driver :as driver] + [metabase.util :as u]) + (:import java.awt.Color + java.awt.image.BufferedImage + (java.io ByteArrayOutputStream IOException) + javax.imageio.ImageIO)) +;;; # ------------------------------------------------------------ CONSTANTS ------------------------------------------------------------ -(def ^:const tile-size 256) -(def ^:const pixel-origin (float (/ tile-size 2))) -(def ^:const pixel-per-lon-degree (float (/ tile-size 360.0))) -(def ^:const pixel-per-lon-radian (float (/ tile-size (* 2 Math/PI)))) +(def ^:private ^:const tile-size 256.0) +(def ^:private ^:const pixel-origin (float (/ tile-size 2))) +(def ^:private ^:const pin-size 5) +(def ^:private ^:const pixels-per-lon-degree (float (/ tile-size 360))) +(def ^:private ^:const pixels-per-lon-radian (float (/ tile-size (* 2 Math/PI)))) -(defn- radians->degrees [rad] - (/ rad (float (/ Math/PI 180)))) -(defn- tile-lat-lon - "Get the Latitude & Longitude of the upper left corner of a given tile" - [x y zoom] +;;; # ------------------------------------------------------------ UTIL FNS ------------------------------------------------------------ + +(defn- degrees->radians ^double [^double degrees] + (* degrees (/ Math/PI 180.0))) + +(defn- radians->degrees ^double [^double radians] + (/ radians (/ Math/PI 180.0))) + + +;;; # ------------------------------------------------------------ QUERY FNS ------------------------------------------------------------ + +(defn- x+y+zoom->lat-lon + "Get the latitude & longitude of the upper left corner of a given tile." + [^double x, ^double y, ^long zoom] (let [num-tiles (bit-shift-left 1 zoom) - corner-x (float (/ (* x tile-size) num-tiles)) - corner-y (float (/ (* y tile-size) num-tiles)) - lon (float (/ (- corner-x pixel-origin) pixel-per-lon-degree)) - lat-radians (/ (- corner-y pixel-origin) (* pixel-per-lon-radian -1)) + corner-x (/ (* x tile-size) num-tiles) + corner-y (/ (* y tile-size) num-tiles) + lon (/ (- corner-x pixel-origin) pixels-per-lon-degree) + lat-radians (/ (- corner-y pixel-origin) (* pixels-per-lon-radian -1)) lat (radians->degrees (- (* 2 (Math/atan (Math/exp lat-radians))) (/ Math/PI 2)))] - {:lat lat - :lon lon})) - + {:lat lat, :lon lon})) (defn- query-with-inside-filter - "Add an 'Inside' filter to the given query to restrict results to a bounding box" + "Add an `INSIDE` filter to the given query to restrict results to a bounding box" [details lat-field-id lon-field-id x y zoom] - (let [{top-lt-lat :lat top-lt-lon :lon} (tile-lat-lon x y zoom) - {bot-rt-lat :lat bot-rt-lon :lon} (tile-lat-lon (+ x 1) (+ y 1) zoom) - inside-filter ["INSIDE", lat-field-id, lon-field-id, top-lt-lat, top-lt-lon, bot-rt-lat, bot-rt-lon]] - (update-in details [:filter] + (let [top-left (x+y+zoom->lat-lon x y zoom) + bottom-right (x+y+zoom->lat-lon (inc x) (inc y) zoom) + inside-filter ["INSIDE" lat-field-id lon-field-id (top-left :lat) (top-left :lon) (bottom-right :lat) (bottom-right :lon)]] + (update details :filter #(match % - ["AND" & _] (conj % inside-filter) - [(_ :guard string?) & _] (conj ["AND"] % inside-filter) - :else inside-filter)))) + ["AND" & _] (conj % inside-filter) + [(_ :guard string?) & _] (conj ["AND"] % inside-filter) + :else inside-filter)))) + + +;;; # ------------------------------------------------------------ RENDERING ------------------------------------------------------------ + +(defn- ^BufferedImage create-tile [zoom points] + (let [num-tiles (bit-shift-left 1 zoom) + tile (BufferedImage. tile-size tile-size (BufferedImage/TYPE_INT_ARGB)) + graphics (.getGraphics tile)] + (.setColor graphics Color/red) + (try + (doseq [[^double lat, ^double lon] points] + (let [sin-y (-> (Math/sin (degrees->radians lat)) + (Math/max -0.9999) ; bound sin-y between -0.9999 and 0.9999 (why ?)) + (Math/min 0.9999)) + point {:x (+ pixel-origin + (* lon pixels-per-lon-degree)) + :y (+ pixel-origin + (* 0.5 + (Math/log (/ (+ 1 sin-y) + (- 1 sin-y))) + (* pixels-per-lon-radian -1.0)))} ; huh? + map-pixel {:x (int (Math/floor (* (point :x) num-tiles))) + :y (int (Math/floor (* (point :y) num-tiles)))} + tile-pixel {:x (mod (map-pixel :x) tile-size) + :y (mod (map-pixel :y) tile-size)}] + ;; now draw a "pin" at the given tile pixel location + (.fillOval graphics (tile-pixel :x) (tile-pixel :y) pin-size pin-size))) + (catch Throwable e + (.printStackTrace e)) + (finally + (.dispose graphics))) + tile)) +(defn- tile->byte-array [^BufferedImage tile] + (let [output-stream (ByteArrayOutputStream.)] + (try + (do (ImageIO/write tile "png" output-stream) ; wrap this in a do or eastwood complains about unused return values + (.flush output-stream) + (.toByteArray output-stream)) + (catch IOException e + (byte-array 0)) ; return empty byte array if we fail for some reason + (finally + (try + (.close output-stream) + (catch Throwable _)))))) -(defn- extract-points - "Takes in a dataset query result object and pulls out the Latitude/Longitude pairs into nested `java.util.ArrayLists`. - This is specific to the way we plan to feed data into `com.metabase.corvus.api.tiles.GoogleMapPinsOverlay`." - [lat-col-idx lon-col-idx {{:keys [rows cols]} :data}] - (if-not (> (count rows) 0) - ;; if we have no rows then return an empty list of points - (ArrayList. (ArrayList.)) - ;; otherwise we go over the data, pull out the lat/lon columns, and convert them to ArrayLists - (ArrayList. ^Collection (map (fn [row] - (ArrayList. ^Collection (vector (nth row lat-col-idx) (nth row lon-col-idx)))) - rows)))) +;;; # ------------------------------------------------------------ ENDPOINT ------------------------------------------------------------ (defendpoint GET "/:zoom/:x/:y/:lat-field/:lon-field/:lat-col-idx/:lon-col-idx/" "This endpoints provides an image with the appropriate pins rendered given a json query. @@ -69,16 +115,17 @@ lat-col-idx String->Integer lon-col-idx String->Integer query String->Dict} - (let [updated-query (assoc query :query (query-with-inside-filter (:query query) lat-field lon-field x y zoom)) - result (driver/dataset-query updated-query {:executed_by *current-user-id* - :synchronously true}) - lat-lon-points (extract-points lat-col-idx lon-col-idx result)] + (let [updated-query (update query :query #(query-with-inside-filter % lat-field lon-field x y zoom)) + result (driver/dataset-query updated-query {:executed_by *current-user-id* + :synchronously true}) + points (for [row (-> result :data :rows)] + [(nth row lat-col-idx) (nth row lon-col-idx)])] ;; manual ring response here. we simply create an inputstream from the byte[] of our image {:status 200 :headers {"Content-Type" "image/png"} - :body (-> (com.metabase.corvus.api.tiles.GoogleMapPinsOverlay. zoom lat-lon-points) - (.toByteArray) - (java.io.ByteArrayInputStream.))})) + :body (-> (create-tile zoom points) + tile->byte-array + java.io.ByteArrayInputStream.)})) (define-routes) -- GitLab