From d48b77be9edb8db5973837bea98fb99b2327740d Mon Sep 17 00:00:00 2001
From: xaralis <filip.varecha@fragaria.cz>
Date: Fri, 27 May 2022 09:34:01 +0100
Subject: [PATCH] feat(maps_utils): improve UX and allow muting individual
 features

---
 .../district_geo_feature_detail_page.html     |   1 +
 .../maps_utils/geo-feature-collection.js      | 109 ++++++++++++++----
 2 files changed, 89 insertions(+), 21 deletions(-)

diff --git a/district/templates/district/district_geo_feature_detail_page.html b/district/templates/district/district_geo_feature_detail_page.html
index 00c48618..6d654537 100644
--- a/district/templates/district/district_geo_feature_detail_page.html
+++ b/district/templates/district/district_geo_feature_detail_page.html
@@ -74,6 +74,7 @@
                 data-display-zoom-control="false"
                 data-display-legend="false"
                 data-display-popups="false"
+                data-handle-clicks="false"
                 data-initial-zoom="{{ page.initial_zoom }}"
                 data-tile-server-config="{{ js_map.tile_server_config }}"
                 data-tile-style="{{ js_map.style }}"
diff --git a/maps_utils/static/maps_utils/geo-feature-collection.js b/maps_utils/static/maps_utils/geo-feature-collection.js
index 9588f2ad..c157372b 100644
--- a/maps_utils/static/maps_utils/geo-feature-collection.js
+++ b/maps_utils/static/maps_utils/geo-feature-collection.js
@@ -126,6 +126,10 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
             type: Boolean,
             default: true,
         },
+        handleClicks: {
+            type: Boolean,
+            default: true,
+        },
         height: {
             type: String,
             default: "50rem",
@@ -204,9 +208,16 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
     },
     methods: {
         initialize(categories, geojson) {
-            // Annotate with proper id, slug and composed url.
+            // Annotate with proper composed url and a stash of markers.
             const annotatedFeatures = geojson.features.map((f) => {
+                // Identifier in URL.
                 f.properties.url = `${f.id}-${f.slug}`;
+                // Muted property to control opacity.
+                f.properties.muted = false;
+                // Marker stash.
+                f.properties.markers = [];
+                // Layer link.
+                f.properties.layer = null;
                 return f;
             });
 
@@ -280,10 +291,10 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
             // Get style for given feature.
             const style = (feature) => ({
                 fillColor: colorForFeature(feature),
-                weight: 2,
-                opacity: 0.7,
+                weight: 3,
+                opacity: 0.8,
                 color: colorForFeature(feature),
-                fillOpacity: 0.5,
+                fillOpacity: 0.6,
             });
 
             const markerIconForCategory = (categoryName, number) =>
@@ -314,6 +325,9 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
                         offset: [0, -64],
                     });
 
+                // Add marker to feature marker list.
+                feature.properties.markers.push(featureMarker);
+
                 // Add item marker to the cluster.
                 markers.addLayer(featureMarker);
 
@@ -326,7 +340,9 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
              */
             const pointToLayer = (feature, latlng) => {
                 const onClick = (evt) => {
-                    this.zoomToPoint(evt.latlng, feature);
+                    if (this.handleClicks) {
+                        this.zoomToPoint(evt.latlng, feature);
+                    }
                 };
                 return addMarker(feature, latlng, onClick);
             };
@@ -337,7 +353,11 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
              */
             const onEachFeature = (feature, layer) => {
                 const markerPosLatLng = [];
-                const onClick = (evt) => this.zoomToLayer(layer, true);
+                const onClick = (evt) => {
+                    if (this.handleClicks) {
+                        this.zoomToLayer(layer, true);
+                    }
+                }
 
                 const markerForPolyCoords = (coords) => {
                     // Find pole of inaccessibility (not centroid) for the polygon
@@ -371,14 +391,18 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
                  **/
                 if (feature.geometry.type == "Polygon") {
                     markerForPolyCoords(feature.geometry.coordinates);
+                    feature.properties.layer = layer;
                 } else if (feature.geometry.type == "MultiPolygon") {
                     feature.geometry.coordinates.forEach(markerForPolyCoords);
+                    feature.properties.layer = layer;
                 } else if (feature.geometry.type == "LineString") {
                     markerForLineStringCoords(feature.geometry.coordinates);
+                    feature.properties.layer = layer;
                 } else if (feature.geometry.type == "MultiLineString") {
                     feature.geometry.coordinates.forEach(
                         markerForLineStringCoords
                     );
+                    feature.properties.layer = layer;
                 } else if (feature.geometry.type == "MultiPoint") {
                     // Supported via `pointToLayer`, noop here.
                 } else {
@@ -390,10 +414,13 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
                 if (markerPosLatLng.length) {
                     markerPosLatLng.forEach((pos) => {
                         addMarker(feature, pos, onClick);
-                        // Bind click event on the layer.
-                        layer.on({
-                            click: (evt) => this.zoomToLayer(evt.target, true),
-                        });
+
+                        if (this.handleClicks) {
+                            // Bind click event on the layer.
+                            layer.on({
+                                click: (evt) => this.zoomToLayer(evt.target, true),
+                            });
+                        }
                     });
                 }
             };
@@ -453,7 +480,7 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
 
             // If query is present when starting, locate the item and zoom to it.
             if (urlParams.has("item")) {
-                this.zoomToFeature(urlParams.get("item"), false);
+                this.zoomToFeature(urlParams.get("item"), false, true);
             } else if (this.currentItem) {
                 this.closeItemInfo(false);
             }
@@ -478,15 +505,43 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
         toggleExpandCategory(category) {
             category.expanded = !category.expanded;
         },
+        /**
+         * Toggle muted state for a feature.
+         * When feature is muted, it will be less opaque in the list. Used for
+         * better orientation when there are lots of features.
+         * @param {Object} feature
+         */
+        toggleMuted(feature) {
+            feature.properties.muted = !feature.properties.muted;
+
+            feature.properties.markers.forEach((marker) => {
+                marker.setOpacity(feature.properties.muted ? 0.2 : 1);
+            });
+
+            if (feature.properties.layer) {
+                feature.properties.layer.setStyle(feature.properties.muted ? {
+                    opacity: 0.3,
+                    fillOpacity: 0.2,
+                } : {
+                    opacity: 0.8,
+                    fillOpacity: 0.6,
+                });
+            }
+        },
         /**
          * Zoom to a point.
          *
          * @param {L.Latlng} Latlng
          * @param {Object} feature
          * @param {Boolean} updateUrl whether to push new state to history.
+         * @param {Boolean} zoom whether to zoom to the item
          */
-        zoomToPoint(latlng, feature, updateUrl = true) {
-            this.map.panTo(latlng);
+        zoomToPoint(latlng, feature, updateUrl = true, zoom = false) {
+            if (zoom) {
+                this.map.flyTo(latlng, 18);
+            } else {
+                this.map.panTo(latlng);
+            }
             this.currentItem = feature.properties;
 
             if (updateUrl) {
@@ -498,9 +553,15 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
          *
          * @param {L.Layer} layer
          * @param {Boolean} updateUrl whether to push new state to history.
+         * @param {Boolean} zoom whether to zoom to the items
          */
-        zoomToLayer(layer, updateUrl = true) {
-            this.map.fitBounds(layer.getBounds());
+        zoomToLayer(layer, updateUrl = true, zoom = false) {
+            if (zoom) {
+                this.map.flyToBounds(layer.getBounds());
+            } else {
+                this.map.panInsideBounds(layer.getBounds());
+            }
+
             this.currentItem = layer.feature.properties;
 
             if (updateUrl) {
@@ -513,13 +574,18 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
          *
          * @param {String} slugUrl
          * @param {Boolean} updateUrl whether to push new state to history.
+         * @param {Boolean} zoom whether to zoom to the items
          */
-        zoomToFeature(slugUrl, updateUrl = true) {
+        zoomToFeature(slugUrl, updateUrl = true, zoom = false) {
             const layer = Object.values(this.layer._layers).find(
                 (l) => l.feature.properties.slug == slugUrl
             );
             if (layer) {
-                this.zoomToLayer(layer, updateUrl);
+                if (layer.feature.geometry.type == "Point") {
+                    this.zoomToPoint(layer._latlng, layer.feature, updateUrl, zoom);
+                } else {
+                    this.zoomToLayer(layer, updateUrl, zoom);
+                }
             }
         },
         /**
@@ -552,10 +618,10 @@ const GeoFeatureCollection = Vue.component("GeoFeatureCollection", {
                                 <button class="head-allcaps-4xs cursor-pointer" @click="toggleExpandCategory(category)">{{ category.name }} ({{ category.items.length }})</button>
                             </div>
                             <ul v-show="category.expanded" :class="{'mb-2': index != categoryCount - 1}">
-                                <li v-for="item in category.items" :key="item.properties.title">
-                                    <button @click="zoomToFeature(item.properties.slug)" class="text-left leading-tight text-xs" style="max-width: 17em;">
-                                        <span v-if="item.properties.index" class="rounded-full inline-flex items-center justify-center bg-grey-125 font-bold text-center text-2xs w-4 h-4 mr-1">{{ item.properties.index }}</span>
-                                        <span>{{ item.properties.title }}</span>
+                                <li v-for="feature in category.items" :key="feature.properties.title">
+                                    <button @click="toggleMuted(feature)" class="text-left leading-tight text-xs" style="max-width: 17em;" :class="{'opacity-50': feature.properties.muted}">
+                                        <span v-if="feature.properties.index" class="rounded-full inline-flex items-center justify-center bg-grey-125 font-bold text-center text-2xs w-4 h-4 mr-1">{{ feature.properties.index }}</span>
+                                        <span>{{ feature.properties.title }}</span>
                                     </button>
                                 </li>
                             </ul>
@@ -610,6 +676,7 @@ Array.from(document.getElementsByClassName("v-geo-feature-collection")).forEach(
                         displayZoomControl:
                             el.dataset.displayZoomControl != "false",
                         displayPopups: el.dataset.displayPopups != "false",
+                        handleClicks: el.dataset.handleClicks != "false",
                         height: el.dataset.height,
                         tileStyle: el.dataset.tileStyle,
                         initialZoom: parseInt(el.dataset.initialZoom),
-- 
GitLab