From 2fbcc2999b5f34a7439fdf27cba3657c8dd7c48d Mon Sep 17 00:00:00 2001
From: Tom Robinson <tlrobinson@gmail.com>
Date: Thu, 27 Oct 2016 15:50:26 -0700
Subject: [PATCH] Port PinMap to Leaflet

---
 .../metabase/lib/visualization_settings.js    |  10 +-
 .../src/metabase/visualizations/PinMap.jsx    | 147 ++++++++++++------
 package.json                                  |   2 +-
 yarn.lock                                     |   6 +-
 4 files changed, 111 insertions(+), 54 deletions(-)

diff --git a/frontend/src/metabase/lib/visualization_settings.js b/frontend/src/metabase/lib/visualization_settings.js
index 0dc02200d88..d1d2e6a8a27 100644
--- a/frontend/src/metabase/lib/visualization_settings.js
+++ b/frontend/src/metabase/lib/visualization_settings.js
@@ -624,7 +624,6 @@ const SETTINGS = {
         }),
         getHidden: (series, vizSettings) => vizSettings["map.type"] !== "region"
     },
-    // TODO: translate legacy settings
     "map.zoom": {
         default: 9
     },
@@ -633,6 +632,15 @@ const SETTINGS = {
     },
     "map.center_longitude": {
         default: -122.4376
+    },
+    "map.pin_type": {
+        title: "Pin type",
+        widget: ChartSettingSelect,
+        props: {
+            options: [{ name: "Tiles", value: "tiles" }, { name: "Markers", value: "markers" }]
+        },
+        getDefault: (series) => series[0].data.rows.length >= 2000 ? "tiles" : "markers",
+        getHidden: (series, vizSettings) => vizSettings["map.type"] !== "pin"
     }
 };
 
diff --git a/frontend/src/metabase/visualizations/PinMap.jsx b/frontend/src/metabase/visualizations/PinMap.jsx
index 14e4f6e62a9..5db01f95abf 100644
--- a/frontend/src/metabase/visualizations/PinMap.jsx
+++ b/frontend/src/metabase/visualizations/PinMap.jsx
@@ -4,12 +4,22 @@ import React, { Component, PropTypes } from "react";
 import ReactDOM from "react-dom";
 
 import { hasLatitudeAndLongitudeColumns } from "metabase/lib/schema_metadata";
-
 import { LatitudeLongitudeError } from "metabase/visualizations/lib/errors";
+import { formatValue } from "metabase/lib/formatting";
+
+import "leaflet/dist/leaflet.css";
+import L from "leaflet/dist/leaflet-src.js";
 
 import _ from "underscore";
 import cx from "classnames";
 
+const MARKER_ICON = L.icon({
+    iconUrl: "/app/img/pin.png",
+    iconSize: [28, 32],
+    iconAnchor: [15, 24],
+    popupAnchor: [0, -13]
+});
+
 export default class PinMap extends Component {
     static displayName = "Pin Map";
     static identifier = "pin_map";
@@ -91,48 +101,30 @@ export default class PinMap extends Component {
             const element = ReactDOM.findDOMNode(this.refs.map);
             const { settings, series: [{ data }] } = this.props;
 
-            const mapOptions = {
-                zoom: settings["map.zoom"],
-                center: new google.maps.LatLng(
-                    settings["map.center_latitude"],
-                    settings["map.center_longitude"]
-                ),
-                mapTypeId: google.maps.MapTypeId.MAP,
-                scrollwheel: false
-            };
-
-            const map = this.map = new google.maps.Map(element, mapOptions);
-
-            if (data.rows.length < 2000) {
-                let tooltip = new google.maps.InfoWindow();
-                let { latitudeIndex, longitudeIndex } = this.getLatLongIndexes();
-                for (let row of data.rows) {
-                    let marker = new google.maps.Marker({
-                        position: new google.maps.LatLng(row[latitudeIndex], row[longitudeIndex]),
-                        map: map,
-                        icon: "/app/img/pin.png"
-                    });
-                    marker.addListener("click", () => {
-                        let tooltipElement = document.createElement("div");
-                        ReactDOM.render(<ObjectDetailTooltip row={row} cols={data.cols} />, tooltipElement);
-                        tooltip.setContent(tooltipElement);
-                        tooltip.open(map, marker);
-                    });
-                }
-            } else {
-                map.overlayMapTypes.push(new google.maps.ImageMapType({
-                    getTileUrl: this.getTileUrl,
-                    tileSize: new google.maps.Size(256, 256)
-                }));
-            }
+            const map = this.map = L.map(element, {
+                scrollWheelZoom: false,
+            })
 
-            map.addListener("center_changed", () => {
-                let center = map.getCenter();
-                this.onMapCenterChange(center.lat(), center.lng());
-            });
+            map.setView([
+                settings["map.center_latitude"],
+                settings["map.center_longitude"]
+            ], settings["map.zoom"]);
 
-            map.addListener("zoom_changed", () => {
-                this.onMapZoomChange(map.getZoom());
+            L.tileLayer("http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+                attribution: 'Map data © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors'
+            }).addTo(map);
+
+            this.pinMarkerLayer = L.layerGroup([]);
+            this.pinTileLayer = L.tileLayer("", {});
+            this.popup = L.popup();
+
+            map.on("moveend", () => {
+                const { lat, lng } = map.getCenter();
+                this.onMapCenterChange(lat, lng);
+            });
+            map.on("zoomend", () => {
+                const zoom = map.getZoom();
+                this.onMapZoomChange(zoom);
             });
         } catch (err) {
             console.error(err);
@@ -140,21 +132,78 @@ export default class PinMap extends Component {
         }
     }
 
+    _createMarker = (index) => {
+        const marker = L.marker([0,0], { icon: MARKER_ICON });
+        marker.on("click", () => {
+            const { series: [{ data }] } = this.props;
+            const { popup } = this;
+            const el = document.createElement("div");
+            ReactDOM.render(<ObjectDetailTooltip row={data.rows[index]} cols={data.cols} />, el);
+            marker.unbindPopup();
+            marker.bindPopup(el, popup);
+            marker.openPopup();
+        });
+        return marker;
+    }
+
     componentDidUpdate() {
-        if (typeof google !== "undefined") {
-            google.maps.event.trigger(this.map, "resize");
+        try {
+            const { map, pinTileLayer, pinMarkerLayer } = this;
+            const { settings, series: [{ data: { rows } }] } = this.props;
+            const type = settings["map.pin_type"];
+
+            if (type === "markers") {
+                const { latitudeIndex, longitudeIndex } = this.getLatLongIndexes();
+                let markers = pinMarkerLayer.getLayers();
+                let max = Math.max(rows.length, markers.length);
+                for (let i = 0; i < max; i++) {
+                    if (i >= rows.length) {
+                        pinMarkerLayer.removeLayer(markers[i]);
+                    }
+                    if (i >= markers.length) {
+                        const marker = this._createMarker(i);
+                        pinMarkerLayer.addLayer(marker);
+                        markers.push(marker);
+                    }
+
+                    if (i < rows.length) {
+                        const { lat, lng } = markers[i].getLatLng();
+                        if (lat !== rows[i][latitudeIndex] || lng !== rows[i][longitudeIndex]) {
+                            markers[i].setLatLng([rows[i][latitudeIndex], rows[i][longitudeIndex]]);
+                        }
+                    }
+                }
+
+                if (!map.hasLayer(pinMarkerLayer)) {
+                    map.removeLayer(pinTileLayer);
+                    map.addLayer(pinMarkerLayer);
+                }
+            } else if (type === "tiles") {
+                const newUrl = this.getTileUrl({ x: "{x}", y: "{y}"}, "{z}");
+                if (newUrl !== pinTileLayer._url) {
+                    pinTileLayer.setUrl(newUrl)
+                }
+
+                if (!map.hasLayer(pinTileLayer)) {
+                    map.removeLayer(pinMarkerLayer);
+                    map.addLayer(pinTileLayer);
+                }
+            }
+        } catch (err) {
+            console.error(err);
+            this.props.onRenderError(err.message || err);
         }
     }
 
     render() {
-        const { className, isEditing } = this.props;
+        const { className, isEditing, isDashboard } = this.props;
         const { lat, lon, zoom } = this.state;
         const disableUpdateButton = lat == null && lon == null && zoom == null;
         return (
             <div className={className + " PinMap relative"} onMouseDownCapture={(e) =>e.stopPropagation() /* prevent dragging */}>
-                <div className="absolute top left bottom right" ref="map"></div>
-                { isEditing ?
-                    <div className={cx("PinMapUpdateButton Button Button--small absolute top right m1", { "PinMapUpdateButton--disabled": disableUpdateButton })} onClick={this.updateSettings}>
+                <div className="absolute top left bottom right z1" ref="map"></div>
+                { isEditing || !isDashboard ?
+                    <div className={cx("PinMapUpdateButton Button Button--small absolute top right m1 z2", { "PinMapUpdateButton--disabled": disableUpdateButton })} onClick={this.updateSettings}>
                         Save as default view
                     </div>
                 : null }
@@ -168,8 +217,8 @@ const ObjectDetailTooltip = ({ row, cols }) =>
         <tbody>
             { cols.map((col, index) =>
                 <tr>
-                    <td>{col.display_name}</td>
-                    <td>{row[index]}</td>
+                    <td className="pr1">{col.display_name}:</td>
+                    <td>{formatValue(row[index], { column: col, jsx: true })}</td>
                 </tr>
             )}
         </tbody>
diff --git a/package.json b/package.json
index d80fbdf9908..9070e5d5f2e 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
     "inflection": "^1.7.1",
     "isomorphic-fetch": "^2.2.1",
     "js-cookie": "^2.1.2",
-    "leaflet": "^0.7.7",
+    "leaflet": "^1.0.1",
     "moment": "2.14.1",
     "node-libs-browser": "^0.5.3",
     "normalizr": "^2.0.0",
diff --git a/yarn.lock b/yarn.lock
index 56e18bb0f1b..ae61ae5d518 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3901,9 +3901,9 @@ lazy-cache@^1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
 
-leaflet@^0.7.7:
-  version "0.7.7"
-  resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-0.7.7.tgz#1e352ba54e63d076451fa363c900890cb2cf75ee"
+leaflet:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.0.1.tgz#5868c389b556cd85481198ff8bfe59a8de0932db"
 
 levn@^0.3.0, levn@~0.3.0:
   version "0.3.0"
-- 
GitLab