| (ns metabase.api.tiles (:require [clojure.set :as set] [metabase.api.common :as api] [metabase.api.macros :as api.macros] [metabase.legacy-mbql.normalize :as mbql.normalize] [metabase.legacy-mbql.util :as mbql.u] [metabase.query-processor :as qp] [metabase.query-processor.util :as qp.util] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.json :as json] [metabase.util.malli.schema :as ms]) (:import (java.awt Color) (java.awt.image BufferedImage) (java.io ByteArrayOutputStream) (javax.imageio ImageIO))) |
(set! *warn-on-reflection* true) | |
--------------------------------------------------- CONSTANTS ---------------------------------------------------- | |
(def ^:private ^:const tile-size 256.0) (def ^:private ^:const pixel-origin (double (/ tile-size 2))) (def ^:private ^:const pin-size 6) (def ^:private ^:const pixels-per-lon-degree (double (/ tile-size 360))) (def ^:private ^:const pixels-per-lon-radian (double (/ tile-size (* 2 Math/PI)))) | |
Limit for number of pins to query for per tile. | (def ^:private ^:const tile-coordinate-limit 2000) |
---------------------------------------------------- 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 ---------------------------------------------------- | |
Get the latitude & longitude of the upper left corner of a given tile. | (defn- x+y+zoom->lat-lon
[^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})) |
Add an | (defn- query-with-inside-filter
[details lat-field lon-field 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
lat-field
lon-field
(top-left :lat)
(top-left :lon)
(bottom-right :lat)
(bottom-right :lon)]]
(update details :filter mbql.u/combine-filter-clauses inside-filter))) |
--------------------------------------------------- RENDERING ---------------------------------------------------- | |
(defn- create-tile ^BufferedImage [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 (/ (inc 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. (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)))))) | |
Adjust native queries to be an mbql from a source query so we can add the filter clause. | (defn- native->source-query
[query]
(if (contains? query :native)
(let [native (set/rename-keys (:native query) {:query :native})]
{:database (:database query)
:type :query
:query {:source-query native}})
query)) |
---------------------------------------------------- ENDPOINT ---------------------------------------------------- | |
Parse a string into an integer if it can be otherwise return the string. Intended to determine whether something is a field id or a field name. | (defn- int-or-string
[x]
(if (re-matches #"\d+" x)
(Integer/parseInt x)
x)) |
Makes a field reference for | (defn- field-ref
[id-or-name]
(let [id-or-name' (int-or-string id-or-name)]
[:field id-or-name' (when (string? id-or-name') {:base-type :type/Float})])) |
Transform a card's query into a query finding coordinates in a particular region.
| (defn- query->tiles-query
[query {:keys [zoom x y lat-field lon-field]}]
(-> query
native->source-query
(update :query query-with-inside-filter
lat-field lon-field
x y zoom)
(assoc-in [:query :fields] [lat-field lon-field])
(assoc-in [:query :limit] tile-coordinate-limit))) |
TODO - this can be reworked to be async instead TODO - this should reduce results from the QP in a streaming fashion instead of requiring them all to be in memory at the same time | (api.macros/defendpoint :get "/:zoom/:x/:y/:lat-field/:lon-field"
"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."
[{:keys [zoom x y lat-field lon-field]} :- [:map
[:zoom ms/Int]
[:x ms/Int]
[:y ms/Int]
[:lat-field :string]
[:lon-field :string]]
{:keys [query]} :- [:map
[:query ms/JSONString]]]
(let [lat-field-ref (field-ref lat-field)
lon-field-ref (field-ref lon-field)
query
(mbql.normalize/normalize (json/decode+kw query))
updated-query (query->tiles-query query {:zoom zoom :x x :y y
:lat-field lat-field-ref
:lon-field lon-field-ref})
{:keys [status], {:keys [rows cols]} :data, :as result}
(qp/process-query
(qp/userland-query updated-query {:executed-by api/*current-user-id*
:context :map-tiles}))
lat-key (qp.util/field-ref->key lat-field-ref)
lon-key (qp.util/field-ref->key lon-field-ref)
find-fn (fn [lat-or-lon-key]
(first (keep-indexed
(fn [idx col] (when (= (qp.util/field-ref->key (:field_ref col)) lat-or-lon-key) idx))
cols)))
lat-idx (find-fn lat-key)
lon-idx (find-fn lon-key)
points (for [row rows]
[(nth row lat-idx) (nth row lon-idx)])]
(if (= status :completed)
{:status 200
:headers {"Content-Type" "image/png"}
:body (tile->byte-array (create-tile zoom points))}
(throw (ex-info (tru "Query failed")
;; `result` might be a `core.async` channel or something we're not expecting
(assoc (when (map? result) result) :status-code 400)))))) |