diff --git a/docs/viz.md b/docs/viz.md index 3fb60a8..8e19e2b 100644 --- a/docs/viz.md +++ b/docs/viz.md @@ -22,7 +22,7 @@ The `MapViz` class is the parent class of the various `mapboxgl-jupyter` visuali ### Params -**MapViz**(_data, vector_url=None, vector_layer_name=None, vector_join_property=None, data_join_property=None, disable_data_join=False, access_token=None, center=(0, 0), below_layer='', opacity=1, div_id='map', height='500px', style='mapbox://styles/mapbox/light-v9?optimize=true', label_property=None, label_size=8, label_color='#131516', label_halo_color='white', label_halo_width=1, width='100%', zoom=0, min_zoom=0, max_zoom=24, pitch=0, bearing=0, box_zoom_on=True, double_click_zoom_on=True, scroll_zoom_on=True, touch_zoom_on=True, legend=True, legend_layout='vertical', legend_gradient=False, legend_style='', legend_fill='white', legend_header_fill='white', legend_text_color='#6e6e6e', legend_text_numeric_precision=None, legend_title_halo_color='white', legend_key_shape='square', legend_key_borders_on=True, popup_open_action='hover'_) +**MapViz**(_data, vector_url=None, vector_layer_name=None, vector_join_property=None, data_join_property=None, disable_data_join=False, access_token=None, center=(0, 0), below_layer='', opacity=1, div_id='map', height='500px', style='mapbox://styles/mapbox/light-v9?optimize=true', label_property=None, label_size=8, label_color='#131516', label_halo_color='white', label_halo_width=1, width='100%', zoom=0, min_zoom=0, max_zoom=24, pitch=0, bearing=0, box_zoom_on=True, double_click_zoom_on=True, scroll_zoom_on=True, touch_zoom_on=True, legend=True, legend_layout='vertical', legend_function='color', legend_gradient=False, legend_style='', legend_fill='white', legend_header_fill='white', legend_text_color='#6e6e6e', legend_text_numeric_precision=None, legend_title_halo_color='white', legend_key_shape='square', legend_key_borders_on=True, popup_open_action='hover'_) Parameter | Description | Example --|--|-- @@ -57,6 +57,7 @@ label_halo_color | color of text halo outline | 'white' label_halo_width | width (in pixels) of text halo outline | 1 legend | controls visibility of map legend | True legend_layout | controls orientation of map legend | 'horizontal' +legend_function | controls whether legend is color or radius-based | 'color' legend_style | reserved for future custom CSS loading | '' legend_gradient | boolean to determine appearance of legend keys; takes precedent over legend_key_shape | False legend_fill | string background color for legend | 'white' diff --git a/examples/notebooks/legend-controls.ipynb b/examples/notebooks/legend-controls.ipynb index 6864468..da4de22 100644 --- a/examples/notebooks/legend-controls.ipynb +++ b/examples/notebooks/legend-controls.ipynb @@ -215,6 +215,33 @@ "viz2.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Variable Radius Legend for a graduated circle viz" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Modify the viz\n", + "viz2.legend_layout = 'horizontal'\n", + "viz2.legend_text_numeric_precision = 0\n", + "\n", + "# Switch to a legend based on the radius property\n", + "viz2.legend_function = 'radius'\n", + "\n", + "# Variable radius legend uses MapViz.color_default to set legend item color\n", + "viz2.color_default = '#0d3d79'\n", + "\n", + "# Show updated viz\n", + "viz2.show()" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/mapboxgl/errors.py b/mapboxgl/errors.py index 7500131..435b274 100644 --- a/mapboxgl/errors.py +++ b/mapboxgl/errors.py @@ -7,8 +7,12 @@ class ValueError(ValueError): class SourceDataError(ValueError): - pass + pass + + +class LegendError(ValueError): + pass class DateConversionError(ValueError): - pass \ No newline at end of file + pass \ No newline at end of file diff --git a/mapboxgl/templates/graduated_circle.html b/mapboxgl/templates/graduated_circle.html index b52410a..429af2e 100644 --- a/mapboxgl/templates/graduated_circle.html +++ b/mapboxgl/templates/graduated_circle.html @@ -10,9 +10,19 @@ {% block legend %} {% if showLegend %} + {% if colorStops and colorProperty and radiusProperty %} - calcColorLegend({{ colorStops }}, "{{ colorProperty }} vs. {{ radiusProperty }}"); + + calcColorLegend({{ colorStops }}, "{{ colorProperty }}"); + + {% endif %} + + {% if radiusStops and radiusProperty %} + + calcRadiusLegend({{ radiusStops }}, "{{ radiusProperty }}", "{{ defaultColor }}"); + {% endif %} + {% endif %} {% endblock legend %} diff --git a/mapboxgl/templates/main.html b/mapboxgl/templates/main.html index 13e3313..edd0dba 100644 --- a/mapboxgl/templates/main.html +++ b/mapboxgl/templates/main.html @@ -49,7 +49,7 @@ .legend.vertical .legend-item {white-space: nowrap;} .legend-value {display: inline-block; line-height: 18px; vertical-align: top;} .legend.horizontal ul.legend-content li.legend-item .legend-value, - .legend.horizontal ul.legend-content li.legend-item {display: inline-block; float: left; width: 30px; margin-bottom: 0; text-align: center; height: 30px;} + .legend.horizontal ul.legend-content li.legend-item {display: inline-block; float: left; width: 30px; margin-bottom: 0; text-align: center; min-height: 30px;} /* legend key styles */ .legend-key {display: inline-block; height: 10px;} @@ -72,8 +72,14 @@ .legend.vertical.contig li.legend-item {height: 15px;} .legend.vertical.contig {padding-bottom: 6px;} + /* vertical radius legend */ + .legend.horizontal.legend-variable-radius ul.legend-content li.legend-item .legend-value, + .legend.horizontal.legend-variable-radius ul.legend-content li.legend-item {width: 30px; min-height: 20px;} + + {% block extra_css %}{% endblock extra_css %} + @@ -84,19 +90,18 @@ var legendHeader; function calcColorLegend(myColorStops, title) { - // create legend - var legend = document.createElement('div'); + var legend = document.createElement('div'), + legendContainer = document.getElementsByClassName('mapboxgl-ctrl-bottom-right')[0]; + if ('{{ legendKeyShape }}' === 'contiguous-bar') { legend.className = 'legend {{ legendLayout }} contig'; } else { legend.className = 'legend {{ legendLayout }}'; } - - legend.id = 'legend'; + legend.id = 'legend-0'; document.body.appendChild(legend); - // add legend header and content elements var mytitle = document.createElement('div'), legendContent = document.createElement('ul'); @@ -108,41 +113,35 @@ legendHeader.appendChild(mytitle); legend.appendChild(legendHeader); legend.appendChild(legendContent); - if ({{ legendGradient|safe }} === true) { - var gradientText = 'linear-gradient(to right, '; - var gradient = document.createElement('div'); + var gradientText = 'linear-gradient(to right, ', + gradient = document.createElement('div'); gradient.className = 'gradient-bar'; legend.appendChild(gradient); } - // calculate a legend entries on a Mapbox GL Style Spec property function stops array for (p = 0; p < myColorStops.length; p++) { - if (!!document.getElementById('legend-points-value-' + p)) { - //update the legend if it already exists - document.getElementById('legend-points-value-' + p).textContent = myColorStops[p][0]; - document.getElementById('legend-points-id-' + p).style.backgroundColor = myColorStops[p][1]; + if (!!document.getElementById('legend-color-points-value-' + p)) { + // update the legend if it already exists + document.getElementById('legend-color-points-value-' + p).textContent = myColorStops[p][0]; + document.getElementById('legend-color-points-id-' + p).style.backgroundColor = myColorStops[p][1]; } else { // create the legend if it doesn't yet exist var item = document.createElement('li'); item.className = 'legend-item'; - var key = document.createElement('span'); key.className = 'legend-key {{ legendKeyShape }}'; - key.id = 'legend-points-id-' + p; - key.style.backgroundColor = myColorStops[p][1]; - + key.id = 'legend-color-points-id-' + p; + key.style.backgroundColor = myColorStops[p][1]; var value = document.createElement('span'); value.className = 'legend-value'; - value.id = 'legend-points-value-' + p; - + value.id = 'legend-color-points-value-' + p; item.appendChild(key); item.appendChild(value); legendContent.appendChild(item); - data = document.getElementById('legend-points-value-' + p) - + data = document.getElementById('legend-color-points-value-' + p) // round number values in legend if precision defined if ((typeof(myColorStops[p][0]) == 'number') && (typeof({{ legendNumericPrecision }}) == 'number')) { data.textContent = myColorStops[p][0].toFixed({{ legendNumericPrecision }}); @@ -150,7 +149,6 @@ else { data.textContent = myColorStops[p][0]; } - // add color stop to gradient list if ({{ legendGradient|safe }} === true) { if (p < myColorStops.length - 1) { @@ -165,22 +163,18 @@ } } } - if ({{ legendGradient|safe }} === true) { // convert to gradient scale appearance gradient.style.background = gradientText; - // hide legend keys generated above var keys = document.getElementsByClassName('legend-key'); for (var i=0; i < keys.length; i++) { keys[i].style.visibility = 'hidden'; } - if ('{{ legendLayout }}' === 'vertical') { gradient.style.height = (legendContent.offsetHeight - 6) + 'px'; } } - // add class for styling bordered legend keys if ({{ legendKeyBordersOn|safe }}) { var keys = document.getElementsByClassName('legend-key'); @@ -196,11 +190,132 @@ } } } + // update right-margin for compact Mapbox attribution based on calculated legend width + updateAttribMargin(legend); + updateLegendMargin(legend); +} + + +function calcRadiusLegend(myRadiusStops, title, color) { + + // maximum legend item height + var maxLegendItemHeight = 2 * myRadiusStops[myRadiusStops.length - 1][1]; + + // create legend + var legend = document.createElement('div'); + legend.className = 'legend {{ legendLayout }} legend-variable-radius'; + + legend.id = 'legend-1'; + document.body.appendChild(legend); + + // add legend header and content elements + var mytitle = document.createElement('div'), + legendContent = document.createElement('ul'); + legendHeader = document.createElement('div'); + mytitle.textContent = title; + mytitle.className = 'legend-title' + legendHeader.className = 'legend-header' + legendContent.className = 'legend-content' + legendHeader.appendChild(mytitle); + legend.appendChild(legendHeader); + legend.appendChild(legendContent); + + // calculate a legend entries on a Mapbox GL Style Spec property function stops array + for (p = 0; p < myRadiusStops.length; p++) { + if (!!document.getElementById('legend-radius-points-value-' + p)) { + //update the legend if it already exists + document.getElementById('legend-radius-points-value-' + p).textContent = myRadiusStops[p][0]; + document.getElementById('legend-radius-points-id-' + p).style.backgroundColor = color; + } + else { + // create the legend if it doesn't yet exist + var item = document.createElement('li'); + item.className = 'legend-item'; + item.height = '' + maxLegendItemHeight + 'px'; + + var key = document.createElement('span'); + key.className = 'legend-key {{ legendKeyShape }}'; + key.id = 'legend-radius-points-id-' + p; + key.style.backgroundColor = color; + + key.style.width = '' + myRadiusStops[p][1] * 2 + 'px'; + key.style.height = '' + myRadiusStops[p][1] * 2 + 'px'; + + keyVerticalMargin = (maxLegendItemHeight - myRadiusStops[p][1] * 2) * 0.5; + key.style.marginTop = '' + keyVerticalMargin + 'px'; + key.style.marginBottom = '' + keyVerticalMargin + 'px'; + + var value = document.createElement('span'); + value.className = 'legend-value'; + value.id = 'legend-radius-points-value-' + p; + + item.appendChild(key); + item.appendChild(value); + legendContent.appendChild(item); + + data = document.getElementById('legend-radius-points-value-' + p) + + // round number values in legend if precision defined + if ((typeof(myRadiusStops[p][0]) == 'number') && (typeof({{ legendNumericPrecision }}) == 'number')) { + data.textContent = myRadiusStops[p][0].toFixed({{ legendNumericPrecision }}); + } + else { + data.textContent = myRadiusStops[p][0]; + } + } + } + + // add class for styling bordered legend keys + if ({{ legendKeyBordersOn|safe }}) { + var keys = document.getElementsByClassName('legend-key'); + for (var i=0; i < keys.length; i++) { + if (keys[i]) { + keys[i].classList.add('bordered'); + } + } + } // update right-margin for compact Mapbox attribution based on calculated legend width + updateAttribMargin(legend); + updateLegendMargin(legend); + +} + + +function updateAttribMargin(legend) { + + // default margin is based on calculated legend width var attribMargin = legend.offsetWidth + 15; - document.getElementsByClassName('mapboxgl-ctrl-attrib')[0].style.marginRight = attribMargin.toString() + 'px'; + + // if horizontal legend layout (multiple legends are stacked vertically) + if ('{{ legendLayout }}' === 'horizontal') { + document.getElementsByClassName('mapboxgl-ctrl-attrib')[0].style.marginRight = (attribMargin).toString() + 'px'; + } + // vertical legend layout means multiple legends are side-by-side + else if ('{{ legendLayout }}' === 'vertical') { + var currentMargin = Number(document.getElementsByClassName('mapboxgl-ctrl-attrib')[0].style.marginRight.replace('px', '')); + document.getElementsByClassName('mapboxgl-ctrl-attrib')[0].style.marginRight = (attribMargin + currentMargin).toString() + 'px'; + } +} + +function updateLegendMargin(legend) { + + var verticalLegends = document.getElementsByClassName('legend vertical'), + horizontalLegends = document.getElementsByClassName('legend horizontal'); + + if (verticalLegends.length > 1) { + for (i = 1; i < verticalLegends.length; i++) { + verticalLegends[i].style.marginRight = (legend.offsetWidth - 5).toString() + 'px'; + var legend = verticalLegends[i]; + } + } + else if (horizontalLegends.length > 1) { + for (i = 1; i < horizontalLegends.length; i++) { + horizontalLegends[i].style.marginBottom = (legend.offsetHeight + 15).toString() + 'px'; + var legend = horizontalLegends[i]; + } + } } diff --git a/mapboxgl/viz.py b/mapboxgl/viz.py index 8748501..761bed2 100644 --- a/mapboxgl/viz.py +++ b/mapboxgl/viz.py @@ -7,7 +7,7 @@ import numpy import requests -from mapboxgl.errors import TokenError +from mapboxgl.errors import TokenError, LegendError from mapboxgl.utils import color_map, numeric_map, img_encode, geojson_to_dict_list from mapboxgl import templates @@ -105,6 +105,7 @@ def __init__(self, touch_zoom_on=True, legend=True, legend_layout='vertical', + legend_function='color', legend_gradient=False, legend_style='', legend_fill='white', @@ -146,6 +147,7 @@ def __init__(self, :param touch_zoom_on: boolean indicating if map can be zoomed with two-finger touch gestures :param legend: boolean for whether to show legend on map :param legend_layout: determines if horizontal or vertical legend used + :param legend_function: controls whether legend is color or radius-based :param legend_style: reserved for future custom CSS loader :param legend_gradient: boolean to determine if legend keys are discrete or gradient :param legend_fill: string background color for legend, default is white @@ -202,8 +204,11 @@ def __init__(self, self.double_click_zoom_on = double_click_zoom_on self.scroll_zoom_on = scroll_zoom_on self.touch_zoom_on = touch_zoom_on + + # legend configuration self.legend = legend self.legend_layout = legend_layout + self.legend_function = legend_function self.legend_style = legend_style self.legend_gradient = legend_gradient self.legend_fill = legend_fill @@ -270,9 +275,15 @@ def create_html(self, filename=None): ) if self.legend: + + if all([self.legend, self.legend_gradient, self.legend_function == 'radius']): + raise LegendError(' '.join(['Gradient legend format not compatible with a variable radius legend.', + 'Please either change `legend_gradient` to False or `legend_function` to "color".'])) + options.update( showLegend=self.legend, legendLayout=self.legend_layout, + legendFunction=self.legend_function, legendStyle=self.legend_style, # reserve for custom CSS legendGradient=json.dumps(self.legend_gradient), legendFill=self.legend_fill, diff --git a/tests/test_html.py b/tests/test_html.py index 6b6eaf8..b409a87 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -8,7 +8,7 @@ import pytest from mapboxgl.viz import * -from mapboxgl.errors import TokenError +from mapboxgl.errors import TokenError, LegendError from mapboxgl.utils import create_color_stops, create_numeric_stops from matplotlib.pyplot import imread @@ -115,6 +115,20 @@ def test_html_GraduatedCricleViz(data): assert "" in viz.create_html() +def test_radius_legend_GraduatedCircleViz(data): + """Raises a LegendError if legend is set to 'radius' legend_function and + legend_gradient is True. + """ + with pytest.raises(LegendError): + viz = GraduatedCircleViz(data, + color_property="Avg Medicare Payments", + radius_property="Avg Covered Charges", + legend_function='radius', + legend_gradient=True, + access_token=TOKEN) + viz.create_html() + + def test_html_ChoroplethViz(polygon_data): viz = ChoroplethViz(polygon_data, color_property="density", @@ -409,4 +423,4 @@ def test_display_RasterTileViz(display, data): """Assert that show calls the mocked display function """ tiles_url = 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png' - viz = RasterTilesViz(tiles_url, access_token=TOKEN) + viz = RasterTilesViz(tiles_url, access_token=TOKEN) \ No newline at end of file