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