-
Notifications
You must be signed in to change notification settings - Fork 4.9k
/
tiles.clj
169 lines (144 loc) · 7.27 KB
/
tiles.clj
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
(ns metabase.api.tiles
"`/api/tiles` endpoints."
(:require [cheshire.core :as json]
[compojure.core :refer [GET]]
[metabase
[query-processor :as qp]
[util :as u]]
[metabase.api.common :as api]
[metabase.mbql
[normalize :as normalize]
[util :as mbql.u]]
[metabase.util
[i18n :refer [tru]]
[schema :as su]])
(:import java.awt.Color
java.awt.image.BufferedImage
[java.io ByteArrayInputStream ByteArrayOutputStream]
javax.imageio.ImageIO))
;;; --------------------------------------------------- CONSTANTS ----------------------------------------------------
(def ^:private ^:const tile-size 256.0)
(def ^:private ^:const pixel-origin (float (/ tile-size 2)))
(def ^:private ^:const pin-size 6)
(def ^:private ^:const pixels-per-lon-degree (float (/ tile-size 360)))
(def ^:private ^:const pixels-per-lon-radian (float (/ tile-size (* 2 Math/PI))))
;;; ---------------------------------------------------- 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 (/ (* 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}))
(defn- query-with-inside-filter
"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-left (x+y+zoom->lat-lon x y zoom)
bottom-right (x+y+zoom->lat-lon (inc x) (inc y) zoom)
inside-filter [:inside
[:field-id lat-field-id]
[:field-id lon-field-id]
(top-left :lat)
(top-left :lon)
(bottom-right :lat)
(bottom-right :lon)]]
(update details :filter mbql.u/combine-filter-clauses 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)
color-blue (new Color 76 157 230)
color-white (Color/white)]
(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
(.setColor graphics color-white)
(.fillRect graphics (tile-pixel :x) (tile-pixel :y) pin-size pin-size)
(.setColor graphics color-blue)
(.fillRect graphics (inc (tile-pixel :x)) (inc (tile-pixel :y)) (- pin-size 2) (- pin-size 2))))
(catch Throwable e
(.printStackTrace e))
(finally
(.dispose graphics)))
tile))
(defn- tile->byte-array ^bytes [^BufferedImage tile]
(let [output-stream (ByteArrayOutputStream.)]
(try
(when-not (ImageIO/write tile "png" output-stream) ; returns `true` if successful -- see JavaDoc
(throw (Exception. (str (tru "No appropriate image writer found!")))))
(.flush output-stream)
(.toByteArray output-stream)
(catch Throwable e
(byte-array 0)) ; return empty byte array if we fail for some reason
(finally
(u/ignore-exceptions
(.close output-stream))))))
;;; ---------------------------------------------------- ENDPOINT ----------------------------------------------------
;; TODO - this can be reworked to be `defendpoint-async` instead
(api/defendpoint GET "/:zoom/:x/:y/:lat-field-id/:lon-field-id/:lat-col-idx/:lon-col-idx/"
"This endpoints provides an image with the appropriate pins rendered given a MBQL QUERY (passed as a GET query
string param). We evaluate the query and find the set of lat/lon pairs which are relevant and then render the
appropriate ones. It's expected that to render a full map view several calls will be made to this endpoint in
parallel."
[zoom x y lat-field-id lon-field-id lat-col-idx lon-col-idx query]
{zoom su/IntString
x su/IntString
y su/IntString
lat-field-id su/IntGreaterThanZero
lon-field-id su/IntGreaterThanZero
lat-col-idx su/IntString
lon-col-idx su/IntString
query su/JSONString}
(let [zoom (Integer/parseInt zoom)
x (Integer/parseInt x)
y (Integer/parseInt y)
lat-col-idx (Integer/parseInt lat-col-idx)
lon-col-idx (Integer/parseInt lon-col-idx)
query
(normalize/normalize (json/parse-string query keyword))
updated-query
(-> query
(update :query query-with-inside-filter lat-field-id lon-field-id x y zoom)
(assoc :async? false))
{:keys [status], {:keys [rows]} :data, :as result}
(qp/process-query-and-save-execution! updated-query
{:executed-by api/*current-user-id*
:context :map-tiles})
;; make sure query completed successfully, or API endpoint should return 400
_
(when-not (= status :completed)
(throw (ex-info (str (tru "Query failed"))
;; `result` might be a `core.async` channel or something we're not expecting
(assoc (when (map? result) result) :status-code 400))))
points
(for [row 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 (ByteArrayInputStream. (tile->byte-array (create-tile zoom points)))}))
(api/define-routes)