Repository: streamlit/streamlit

Issue:
Plotly color scale expands to fill available labels, but doesn't contract again
Load the example below. Because there's only 2 labels initially, the color scale takes this into account. If the slider is set all the way to max, the color scale will accomodate all 20 colors. However, when the slider is set back to 2, the scale is stuck at a 20 colors, and doesn't rescale to maximize contrast between 2.

Is this intentional?

Streamlit version 0.48.1
Plotly version 4.2.1

```
import plotly.express as px
import streamlit as st
import pandas as pd
import numpy as np

n_labels = st.slider(min_value=2, max_value=20, value=2, label="Number of labels")

data = pd.DataFrame(
    {
        "x": np.random.normal(0, 1, 20),
        "y": np.random.normal(0, 1, 20),
        "label": np.random.randint(1, n_labels + 1, 20),
    }
)

fig = px.scatter(data, x="x", y="y", color="label")

st.write(fig)
```


Discussion:

Resolving patch:
diff --git a/lib/streamlit/DeltaGenerator.py b/lib/streamlit/DeltaGenerator.py
--- a/lib/streamlit/DeltaGenerator.py
+++ b/lib/streamlit/DeltaGenerator.py
@@ -26,7 +26,6 @@
 import json
 import random
 import textwrap
-import traceback
 from datetime import datetime
 from datetime import date
 from datetime import time
@@ -1726,14 +1725,20 @@ def empty(self, element):
         element.empty.unused = True
 
     @_with_element
-    def map(self, element, data):
+    def map(self, element, data, zoom=None):
         """Display a map with points on it.
 
+        This is a wrapper around st.deck_gl_chart to quickly create scatterplot
+        charts on top of a map, with auto-centering and auto-zoom.
+
         Parameters
         ----------
         data : pandas.DataFrame, pandas.Styler, numpy.ndarray, Iterable, dict,
             or None
             The data to be plotted. Must have 'lat' and 'lon' columns.
+        zoom : int
+            Zoom level as specified in
+            https://wiki.openstreetmap.org/wiki/Zoom_levels
 
         Example
         -------
@@ -1751,15 +1756,9 @@ def map(self, element, data):
            height: 600px
 
         """
-        import streamlit.elements.data_frame_proto as data_frame_proto
-        LAT_LON = ['lat', 'lon']
-        if not set(data.columns) >= set(LAT_LON):
-            raise Exception('Map data must contain "lat" and "lon" columns.')
-        if (data['lon'].isnull().values.any() or
-            data['lat'].isnull().values.any()):
-            raise Exception('Map data must be numeric.')
-        data_frame_proto.marshall_data_frame(
-            data[LAT_LON], element.map.points)
+
+        import streamlit.elements.map as map
+        map.marshall(element, data, zoom)
 
     @_with_element
     def deck_gl_chart(self, element, data=None, spec=None, **kwargs):
diff --git a/lib/streamlit/elements/map.py b/lib/streamlit/elements/map.py
new file mode 100644
--- /dev/null
+++ b/lib/streamlit/elements/map.py
@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018-2019 Streamlit Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Python 2/3 compatibility
+from __future__ import print_function, division, unicode_literals, \
+    absolute_import
+from streamlit.compatibility import setup_2_3_shims
+
+setup_2_3_shims(globals())
+
+import pandas as pd
+from math import sqrt
+
+from streamlit.logger import get_logger
+
+LOGGER = get_logger(__name__)
+
+ZOOM_LEVELS = [
+    360,
+    180,
+    90,
+    45,
+    22.5,
+    11.25,
+    5.625,
+    2.813,
+    1.406,
+    0.703,
+    0.352,
+    0.176,
+    0.088,
+    0.044,
+    0.022,
+    0.011,
+    0.005,
+    0.003,
+    0.001,
+    0.0005
+]
+
+
+def _get_zoom_level(distance):
+    """Get the zoom level for a given distance in degrees.
+
+    See https://wiki.openstreetmap.org/wiki/Zoom_levels for reference.
+
+    Parameters
+    ----------
+    distance : float
+        How many degrees of longitude should fit in the map.
+
+    Returns
+    -------
+    int
+        The zoom level, from 0 to 29.
+
+    """
+
+    for i, v in enumerate(ZOOM_LEVELS):
+        if ZOOM_LEVELS[i] > distance > ZOOM_LEVELS[i + 1]:
+            return i - 1
+
+
+def _get_bounding_rectangle(geolocations):
+    """
+    Gets the maximum and minimum latitudes and longitude for a given array of
+    locations
+
+    param geolocations: array of lat and lon
+    """
+    maxLat = -85
+    minLat = 85
+    maxLon = -180
+    minLon = 180
+
+    for location in geolocations:
+        lat = location[0]
+        lon = location[1]
+
+        if lat > maxLat:
+            maxLat = lat
+        if lat < minLat:
+            minLat = lat
+        if lon > maxLon:
+            maxLon = lon
+        if lon < minLon:
+            minLon = lon
+
+    return {'min': (minLat, minLon), 'max': (maxLat, maxLon)}
+
+
+def _calculateDistance(x1, y1, x2, y2):
+    """
+        Calculate distance between two locations
+    """
+
+    dist = sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
+    return dist
+
+
+def marshall(element, data, zoom=None):
+    """
+        Marshall a proto with DeckGL chart info.
+
+        This is a shorthand for DeltaGenerator.deck_gl_chart,
+        which will auto center and auto zoom the chart.
+        If it is needed you can specify the zoom param.
+
+        See DeltaGenerator.deck_gl_chart for docs.
+    """
+
+    LAT_LON = ['lat', 'lon']
+    if not set(data.columns) >= set(LAT_LON):
+        raise Exception('Map data must contain "lat" and "lon" columns.')
+    if data['lon'].isnull().values.any() or data['lat'].isnull().values.any():
+        raise Exception('Map data must be numeric.')
+
+    if isinstance(data, pd.DataFrame):
+        bounding = _get_bounding_rectangle(data[LAT_LON].values)
+    else:
+        bounding = _get_bounding_rectangle(data)
+
+    center_viewport = [
+        (bounding['max'][0] + bounding['min'][0]) / 2,
+        (bounding['max'][1] + bounding['min'][1]) / 2,
+    ]
+
+    width = bounding['min'][1] + bounding['max'][1]
+    height = bounding['min'][0] + bounding['max'][0]
+
+    if width > height:
+        longitudeDistance = _calculateDistance(center_viewport[0],
+                                               center_viewport[1],
+                                               center_viewport[0],
+                                               bounding['max'][1])
+    else:
+        longitudeDistance = _calculateDistance(center_viewport[0],
+                                               center_viewport[1],
+                                               bounding['max'][0],
+                                               center_viewport[1])
+
+    if zoom is None:
+        zoom = _get_zoom_level(longitudeDistance)
+
+    import streamlit.elements.deck_gl as deck_gl
+    deck_gl.marshall(element.deck_gl_chart,
+                     viewport={
+                         'latitude': center_viewport[0],
+                         'longitude': center_viewport[1],
+                         'zoom': zoom,
+                         'pitch': 50,
+                     },
+                     layers=[{
+                         'type': 'HexagonLayer',
+                         'data': data,
+                         'radius': 200,
+                         'elevationScale': 4,
+                         'elevationRange': [0, 1000],
+                         'pickable': True,
+                         'extruded': True,
+                     }, {
+                         'type': 'ScatterplotLayer',
+                         'data': data,
+                     }])


Test patch:
diff --git a/lib/tests/streamlit/map_test.py b/lib/tests/streamlit/map_test.py
new file mode 100644
--- /dev/null
+++ b/lib/tests/streamlit/map_test.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018-2019 Streamlit Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""map unit test."""
+
+# Python 2/3 compatibility
+from __future__ import print_function, division, unicode_literals, \
+    absolute_import
+from streamlit.compatibility import setup_2_3_shims
+
+setup_2_3_shims(globals())
+
+import pandas as pd
+import numpy as np
+import json
+
+from tests import testutil
+import streamlit as st
+
+df1 = pd.DataFrame({
+    'lat': [1, 2, 3, 4],
+    'lon': [10, 20, 30, 40],
+})
+
+
+class DeckGLTest(testutil.DeltaGeneratorTestCase):
+    """Test ability to marshall deck_gl_chart protos."""
+
+    def test_no_args(self):
+        """Test that it can be called with no args."""
+        st.deck_gl_chart()
+
+        c = self.get_delta_from_queue().new_element.deck_gl_chart
+        self.assertEqual(c.HasField('data'), False)
+        self.assertEqual(json.loads(c.spec), {})
+
+    def test_basic(self):
+        """Test that deck_gl_chart can be called with lat/lon."""
+        st.map(df1)
+
+        c = self.get_delta_from_queue().new_element.deck_gl_chart
+
+        self.assertEqual(c.HasField('data'), False)
+        self.assertEqual(len(c.layers), 2)
+
+        deck_gl_spec = json.loads(c.spec)
+
+        assert 'viewport' in deck_gl_spec
+
+        self.assertEqual(deck_gl_spec['viewport']['latitude'], 2.5)
+        self.assertEqual(deck_gl_spec['viewport']['longitude'], 25)
+        self.assertEqual(deck_gl_spec['viewport']['zoom'], 3)
+        self.assertEqual(deck_gl_spec['viewport']['pitch'], 50)
+
+        for layer in c.layers:
+            spec = json.loads(layer.spec)
+            isHexagonLayer = spec['type'] == 'HexagonLayer'
+            isScatterplotLayer = spec['type'] == 'ScatterplotLayer'
+            assert isHexagonLayer or isScatterplotLayer
+
+    def test_missing_column(self):
+        """Test st.map with wrong column label."""
+        df = pd.DataFrame({'notlat': [1, 2, 3], 'lon': [11, 12, 13]})
+        with self.assertRaises(Exception) as ctx:
+            st.map(df)
+
+        self.assertTrue(
+            'Map data must contain "lat" and "lon" columns.' in str(
+                ctx.exception))
+
+    def test_nan_exception(self):
+        """Test st.map with NaN in data."""
+        df = pd.DataFrame({'lat': [1, 2, np.nan], 'lon': [11, 12, 13]})
+        with self.assertRaises(Exception) as ctx:
+            st.map(df)
+
+        self.assertTrue('Map data must be numeric.' in str(ctx.exception))
diff --git a/lib/tests/streamlit/streamlit_test.py b/lib/tests/streamlit/streamlit_test.py
--- a/lib/tests/streamlit/streamlit_test.py
+++ b/lib/tests/streamlit/streamlit_test.py
@@ -85,9 +85,9 @@ def test_st_altair_chart(self):
         df = pd.DataFrame(np.random.randn(3, 3), columns=['a', 'b', 'c'])
 
         c = (alt.Chart(df)
-            .mark_circle()
-            .encode(x='a', y='b', size='c', color='c')
-            .interactive())
+             .mark_circle()
+             .encode(x='a', y='b', size='c', color='c')
+             .interactive())
         st.altair_chart(c)
 
         el = self.get_delta_from_queue().new_element
@@ -180,7 +180,8 @@ def test_st_dataframe(self):
         dg = st.dataframe(df)
         el = self.get_delta_from_queue().new_element
         self.assertEqual(el.data_frame.data.cols[0].int64s.data, [1, 2])
-        self.assertEqual(el.data_frame.columns.plain_index.data.strings.data, ['one', 'two'])
+        self.assertEqual(el.data_frame.columns.plain_index.data.strings.data,
+                         ['one', 'two'])
 
     def test_st_deck_gl_chart(self):
         """Test st.deck_gl_chart."""
@@ -209,7 +210,8 @@ def test_st_deck_gl_chart(self):
         )
 
         self.assertEqual(
-            json.loads(json_format.MessageToJson(data.columns.plain_index.data.strings)),
+            json.loads(json_format.MessageToJson(
+                data.columns.plain_index.data.strings)),
             {
                 'data': ['lat', 'lon']
             }
@@ -270,7 +272,8 @@ def test_st_help(self):
         self.assertEqual(el.doc_string.name, 'header')
         self.assertEqual(el.doc_string.module, 'streamlit')
         self.assertTrue(
-            el.doc_string.doc_string.startswith('Display text in header formatting.'))
+            el.doc_string.doc_string.startswith(
+                'Display text in header formatting.'))
         if sys.version_info >= (3, 0):
             self.assertEqual(el.doc_string.type, '<class \'function\'>')
         else:
@@ -279,7 +282,7 @@ def test_st_help(self):
 
     def test_st_image_PIL_image(self):
         """Test st.image with PIL image."""
-        img = Image.new('RGB', (64, 64), color = 'red')
+        img = Image.new('RGB', (64, 64), color='red')
 
         # Manually calculated by letting the test fail and copying and
         # pasting the result.
@@ -298,9 +301,9 @@ def test_st_image_PIL_image(self):
     def test_st_image_PIL_array(self):
         """Test st.image with a PIL array."""
         imgs = [
-            Image.new('RGB', (64, 64), color = 'red'),
-            Image.new('RGB', (64, 64), color = 'blue'),
-            Image.new('RGB', (64, 64), color = 'green'),
+            Image.new('RGB', (64, 64), color='red'),
+            Image.new('RGB', (64, 64), color='blue'),
+            Image.new('RGB', (64, 64), color='green'),
         ]
         # Manually calculated by letting the test fail and copying and
         # pasting the result.
@@ -394,36 +397,6 @@ def test_st_line_chart(self):
         result = [x['int64s']['data'][0] for x in data['cols']]
         self.assertEqual(result, ['10', '20', '30'])
 
-    def test_st_map(self):
-        """Test st.map."""
-        df = pd.DataFrame({'lat': [1.0, 2.0, 3.0], 'lon': [11.0, 12.0, 13.0]})
-        dg = st.map(df)
-
-        el = self.get_delta_from_queue().new_element
-        data = json.loads(json_format.MessageToJson(el.map.points.data))
-        self.assertEqual(
-            data['cols'][0]['doubles']['data'],
-            [1.0, 2.0, 3.0])
-        self.assertEqual(
-            data['cols'][1]['doubles']['data'],
-            [11.0, 12.0, 13.0])
-
-    def test_st_map_missing_column(self):
-        """Test st.map with wrong column label."""
-        df = pd.DataFrame({'notlat': [1, 2, 3], 'lon': [11, 12, 13]})
-        with self.assertRaises(Exception) as ctx:
-            st.map(df)
-
-        self.assertTrue('Map data must contain "lat" and "lon" columns.' in str(ctx.exception))
-
-    def test_st_map_nan_exception(self):
-        """Test st.map with NaN in data."""
-        df = pd.DataFrame({'lat': [1, 2, np.nan], 'lon': [11, 12, 13]})
-        with self.assertRaises(Exception) as ctx:
-            st.map(df)
-
-        self.assertTrue('Map data must be numeric.' in str(ctx.exception))
-
     def test_st_markdown(self):
         """Test st.markdown."""
         dg = st.markdown('    some markdown  ')
@@ -580,7 +553,8 @@ def test_st_table(self):
         el = self.get_delta_from_queue().new_element
         self.assertEqual(el.table.data.cols[0].int64s.data, [1, 3])
         self.assertEqual(el.table.data.cols[1].int64s.data, [2, 4])
-        self.assertEqual(el.table.columns.plain_index.data.strings.data, ['col1', 'col2'])
+        self.assertEqual(el.table.columns.plain_index.data.strings.data,
+                         ['col1', 'col2'])
 
     def test_st_text(self):
         """Test st.text."""
@@ -644,7 +618,7 @@ def test_st_native_chart(self):
 
     def test_st_text_exception(self):
         """Test st._text_exception."""
-        data ={
+        data = {
             'type': 'ModuleNotFoundError',
             'message': 'No module named \'numpy\'',
             'stack_trace': [
@@ -664,7 +638,3 @@ def test_st_text_exception(self):
         self.assertEqual(el.exception.type, data.get('type'))
         self.assertEqual(el.exception.message, data.get('message'))
         self.assertEqual(el.exception.stack_trace, data.get('stack_trace'))
-
-
-
-