| (ns metabase.tiles.api (: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.card :as qp.card] [metabase.query-processor.dashboard :as qp.dashboard] [metabase.query-processor.util :as qp.util] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.json :as json] [metabase.util.malli.registry :as mr] [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 [source-query (-> (:native query) (set/rename-keys {:query :native}) (cond-> (:parameters query) (assoc :parameters (:parameters query))))] {:database (:database query) :type :query :query {:source-query source-query}}) query)) |
---------------------------------------------------- ENDPOINTS ---------------------------------------------------- | |
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- tiles-query [query zoom x y lat-field lon-field] (let [query (mbql.normalize/normalize query) lat-field-ref (field-ref lat-field) lon-field-ref (field-ref lon-field)] (-> query native->source-query (update :query query-with-inside-filter lat-field-ref lon-field-ref x y zoom) (assoc-in [:query :fields] [lat-field-ref lon-field-ref]) (assoc-in [:query :limit] tile-coordinate-limit)))) |
TODO -- what if the field name contains a slash? Are we expected to URL-encode it? I don't think we have any code that handles that. | (mr/def :api.tiles/field-id-or-name [:string {:api/regex #"[^/]+"}]) |
(mr/def :api.tiles/route-params [:map [:zoom ms/Int] [:x ms/Int] [:y ms/Int] [:lat-field :api.tiles/field-id-or-name] [:lon-field :api.tiles/field-id-or-name]]) | |
(defn- result->points [{{:keys [rows cols]} :data} lat-field lon-field] (let [lat-key (qp.util/field-ref->key (field-ref lat-field)) lon-key (qp.util/field-ref->key (field-ref lon-field)) 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)] (for [row rows] [(nth row lat-idx) (nth row lon-idx)]))) | |
TODO - this should be async and stream results from the QP instead of requiring them all to be in memory at the same time | (defn- tiles-response [result zoom points] (if (= (:status result) :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))))) |
These endpoints provides an image with the appropriate pins rendered given a MBQL | (api.macros/defendpoint :get "/:zoom/:x/:y/:lat-field/:lon-field" "Generates a single tile image for an ad-hoc query." [{:keys [zoom x y lat-field lon-field]} :- :api.tiles/route-params {:keys [query]} :- [:map [:query ms/JSONString]]] (let [query (json/decode+kw query) updated-query (tiles-query query zoom x y lat-field lon-field) result (qp/process-query (qp/userland-query updated-query {:executed-by api/*current-user-id* :context :map-tiles})) points (result->points result lat-field lon-field)] (tiles-response result zoom points))) |
Generates a single tile image for a dashcard and returns a Ring response that contains the data as a PNG | (defn process-tiles-query-for-card [card-id parameters zoom x y lat-field lon-field] (let [result (qp.card/process-query-for-card card-id :api {:parameters parameters :context :map-tiles :make-run (constantly (fn [query info] (-> query (update :info merge info) (tiles-query zoom x y lat-field lon-field) qp/userland-query qp/process-query)))}) points (result->points result lat-field lon-field)] (tiles-response result zoom points))) |
Generates a single tile image for a dashcard and returns a Ring response that contains the data as a PNG | (defn process-tiles-query-for-dashcard [dashboard-id dashcard-id card-id parameters zoom x y lat-field lon-field] (let [result (qp.dashboard/process-query-for-dashcard :dashboard-id dashboard-id :dashcard-id dashcard-id :card-id card-id :export-format :api :parameters parameters :context :map-tiles :make-run (constantly (fn [query info] (-> query (update :info merge info) (tiles-query zoom x y lat-field lon-field) qp/userland-query qp/process-query)))) points (result->points result lat-field lon-field)] (tiles-response result zoom points))) |
(api.macros/defendpoint :get "/:card-id/:zoom/:x/:y/:lat-field/:lon-field" "Generates a single tile image for a saved Card." [{:keys [card-id zoom x y lat-field lon-field]} :- [:merge :api.tiles/route-params [:map [:card-id ms/PositiveInt]]] {:keys [parameters]} :- [:map [:parameters {:optional true} ms/JSONString]]] (let [parameters (json/decode+kw parameters)] (process-tiles-query-for-card card-id parameters zoom x y lat-field lon-field))) | |
(api.macros/defendpoint :get "/:dashboard-id/dashcard/:dashcard-id/card/:card-id/:zoom/:x/:y/:lat-field/:lon-field" "Generates a single tile image for a dashcard." [{:keys [dashboard-id dashcard-id card-id zoom x y lat-field lon-field]} :- [:merge :api.tiles/route-params [:map [:dashboard-id ms/PositiveInt] [:dashcard-id ms/PositiveInt] [:card-id ms/PositiveInt]]] {:keys [parameters]} :- [:map [:parameters {:optional true} ms/JSONString]]] (let [parameters (json/decode+kw parameters)] (process-tiles-query-for-dashcard dashboard-id dashcard-id card-id parameters zoom x y lat-field lon-field))) | |