From 564008a85cf5507ac418d071e2d0f555e6d5fe48 Mon Sep 17 00:00:00 2001 From: akacarlyann Date: Thu, 29 Mar 2018 14:56:25 -0700 Subject: [PATCH 1/3] Add 3-d option for choropleth extrusion; support pitch and bearing arguments for MapViz --- docs-markdown/utils.md | 36 + docs-markdown/viz.md | 10 +- .../notebooks/choropleth-viz-example.ipynb | 1706 +---------------- mapboxgl/templates/base.html | 2 + mapboxgl/templates/choropleth.html | 19 +- mapboxgl/templates/vector_choropleth.html | 50 +- mapboxgl/utils.py | 70 + mapboxgl/viz.py | 69 +- tests/test_html.py | 30 +- tests/test_utils.py | 15 +- 10 files changed, 350 insertions(+), 1657 deletions(-) diff --git a/docs-markdown/utils.md b/docs-markdown/utils.md index 8b73d62..d8f61cb 100644 --- a/docs-markdown/utils.md +++ b/docs-markdown/utils.md @@ -92,6 +92,20 @@ Parameter | Description --|-- breaks | List of float values + +## create_numeric_stops +Convert a Python list of a data domain (such as `[0, 1, 5, 100, 10000]`) into a generic numeric ramp between a minimum and maximum value, as in for a heatmap, choropleth 3-D extrusions, etc. + +### Params +**create_numeric_stops**(_breaks, min_value, max_value_) + +Parameter | Description +--|-- +breaks | List of float values +min_value | Minimum ramp value +max_value | Maximum ramp value + + ## create_color_stops Convert a Python list of a data domain (such as `[0, 1, 5, 100, 10000]`) into color ramp stops. Color ramps can be from colorBrewer, or a custom list of color values. @@ -181,3 +195,25 @@ match_color_stops = [ ] color = color_map('California', match_color_stops, default_color='grey)') ``` + + +## height_map +Return a height value (in meters) interpolated from given height_stops; for use with vector-based visualizations using fill-extrusion layers. + +### Params +**height_map**(_lookup, height_stops, default_height=10.0_) + +Parameter | Description +--|-- +lookup | value is numeric for interpolated heights or string for categorical height stops +height_stops | height ramp stops generated from `create_numeric_stops`, or custom list of numeric or categorical stops with paired heights +default_height | height, in meters, for display of 3-D extrusion on map + +### Usage +```python +from mapboxgl.utils import create_numeric_stops, height_map + +# interpolate height +height_stops = create_numeric_stops([0, 50, 100, 500, 1500], 0, 150000) +height = height_map(117, height_stops) +``` diff --git a/docs-markdown/viz.md b/docs-markdown/viz.md index 388d9da..25133b5 100644 --- a/docs-markdown/viz.md +++ b/docs-markdown/viz.md @@ -21,7 +21,7 @@ The `MapViz` class is the parent class of the various `mapboxgl-jupyter` visualizations. You can use this class to set default values for all visualizations rather than calling them directly from the other visualization objects. ### Params -**MapViz**(_data, access_token=None, center=(0, 0), below_layer='', opacity=1, div_id='map', height='500px', style='mapbox://styles/mapbox/light-v9?optimize=true', width='100%', zoom=0, min_zoom=0, max_zoom=24_) +**MapViz**(_data, access_token=None, center=(0, 0), below_layer='', opacity=1, div_id='map', height='500px', style='mapbox://styles/mapbox/light-v9?optimize=true', width='100%', zoom=0, min_zoom=0, max_zoom=24, pitch=0, bearing=0_) Parameter | Description --|-- @@ -34,6 +34,8 @@ width | The CSS width of the HTML div id in % or pixels. height | The CSS height of the HTML map div in % or pixels. zoom | starting zoom level for map opacity | opacity of map data layer +pitch | starting pitch (in degrees) for map +bearing | starting bearing (in degrees) for map ### Methods **as_iframe**(_self, html_data_) @@ -320,7 +322,7 @@ viz.show() The `ChoroplethViz` object handles the creation of a choropleth map and inherits from the `MapViz` class. It applies a thematic map style to polygon features with color shading in proportion to the intensity of the data being displayed. Choropleth polygons can be initialized with geojson source or vector source styled using the data-join technique. ### Params -**ChoroplethViz**(_data, vector_url=None, vector_layer_name=None, vector_join_property=None, data_join_property=None, # vector only label_property=None, color_property=None, color_stops=None, color_default='grey', color_function_type='interpolate', line_color='white', line_stroke='solid', line_width=1, *args, **kwargs_) +**ChoroplethViz**(_data, vector_url=None, vector_layer_name=None, vector_join_property=None, data_join_property=None, # vector only label_property=None, color_property=None, color_stops=None, color_default='grey', color_function_type='interpolate', line_color='white', line_stroke='solid', line_width=1, height_property=None, height_stops=None, height_default=0.0, height_function_type='interpolate', *args, **kwargs_) Parameter | Description | Example --|--|-- @@ -337,6 +339,10 @@ color_function_type | property to determine type of expression used by Mapbox to line_color | property to determine choropleth border line color | "#FFFFFF" line_stroke | property to determine choropleth border line stroke (one of solid (-), dashed (--), dotted (:), dash dot (-.)) | "solid" or "-" line_width | property to determine choropleth border line width | 1 +height_property | feature property for determining polygon height in 3D extruded choropleth map | "density" +height_stops | property for determining 3D extrusion height | [[0, 0], [500, 50000], [1500, 150000]] +height_default | default height (in meters) for 3D extruded polygons on map | 1500.0 +height_function_type | roperty to determine `type` used by Mapbox to assign height | "interpolate" [View options](https://github.com/mapbox/mapboxgl-jupyter/blob/master/docs-markdown/viz.md#params) diff --git a/examples/notebooks/choropleth-viz-example.ipynb b/examples/notebooks/choropleth-viz-example.ipynb index d79f763..c880bc5 100644 --- a/examples/notebooks/choropleth-viz-example.ipynb +++ b/examples/notebooks/choropleth-viz-example.ipynb @@ -11,7 +11,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -32,308 +32,11 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": { "scrolled": false }, - "outputs": [ - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# create choropleth from polygon features stored as GeoJSON\n", "viz = ChoroplethViz('https://raw.githubusercontent.com/mapbox/mapboxgl-jupyter/master/examples/data/us-states.geojson', \n", @@ -351,6 +54,32 @@ "viz.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add 3-D Extrusion" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# adjust view angle\n", + "viz.bearing = -15\n", + "viz.pitch = 45\n", + "\n", + "# add extrusion to viz using interpolation keyed on density in GeoJSON features\n", + "viz.height_property = 'density'\n", + "viz.height_stops = create_numeric_stops([0, 50, 100, 500, 1500, 5000], 0, 500000)\n", + "viz.height_function_type = 'interpolate'\n", + "\n", + "# render again\n", + "viz.show()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -360,306 +89,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "match_color_stops = [\n", " ['Massachusetts', 'rgb(46,204,113)'],\n", @@ -691,334 +123,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# must be JSON object (need to extend to use referenced JSON file)\n", "data = [{\"id\": \"01\", \"name\": \"Alabama\", \"density\": 94.65}, {\"id\": \"02\", \"name\": \"Alaska\", \"density\": 1.264}, {\"id\": \"04\", \"name\": \"Arizona\", \"density\": 57.05}, {\"id\": \"05\", \"name\": \"Arkansas\", \"density\": 56.43}, {\"id\": \"06\", \"name\": \"California\", \"density\": 241.7}, {\"id\": \"08\", \"name\": \"Colorado\", \"density\": 49.33}, {\"id\": \"09\", \"name\": \"Connecticut\", \"density\": 739.1}, {\"id\": \"10\", \"name\": \"Delaware\", \"density\": 464.3}, {\"id\": \"11\", \"name\": \"District of Columbia\", \"density\": 10065}, {\"id\": \"12\", \"name\": \"Florida\", \"density\": 353.4}, {\"id\": \"13\", \"name\": \"Georgia\", \"density\": 169.5}, {\"id\": \"15\", \"name\": \"Hawaii\", \"density\": 214.1}, {\"id\": \"16\", \"name\": \"Idaho\", \"density\": 19.15}, {\"id\": \"17\", \"name\": \"Illinois\", \"density\": 231.5}, {\"id\": \"18\", \"name\": \"Indiana\", \"density\": 181.7}, {\"id\": \"19\", \"name\": \"Iowa\", \"density\": 54.81}, {\"id\": \"20\", \"name\": \"Kansas\", \"density\": 35.09}, {\"id\": \"21\", \"name\": \"Kentucky\", \"density\": 110}, {\"id\": \"22\", \"name\": \"Louisiana\", \"density\": 105}, {\"id\": \"23\", \"name\": \"Maine\", \"density\": 43.04}, {\"id\": \"24\", \"name\": \"Maryland\", \"density\": 596.3}, {\"id\": \"25\", \"name\": \"Massachusetts\", \"density\": 840.2}, {\"id\": \"26\", \"name\": \"Michigan\", \"density\": 173.9}, {\"id\": \"27\", \"name\": \"Minnesota\", \"density\": 67.14}, {\"id\": \"28\", \"name\": \"Mississippi\", \"density\": 63.5}, {\"id\": \"29\", \"name\": \"Missouri\", \"density\": 87.26}, {\"id\": \"30\", \"name\": \"Montana\", \"density\": 6.858}, {\"id\": \"31\", \"name\": \"Nebraska\", \"density\": 23.97}, {\"id\": \"32\", \"name\": \"Nevada\", \"density\": 24.8}, {\"id\": \"33\", \"name\": \"New Hampshire\", \"density\": 147}, {\"id\": \"34\", \"name\": \"New Jersey\", \"density\": 1189}, {\"id\": \"35\", \"name\": \"New Mexico\", \"density\": 17.16}, {\"id\": \"36\", \"name\": \"New York\", \"density\": 412.3}, {\"id\": \"37\", \"name\": \"North Carolina\", \"density\": 198.2}, {\"id\": \"38\", \"name\": \"North Dakota\", \"density\": 9.916}, {\"id\": \"39\", \"name\": \"Ohio\", \"density\": 281.9}, {\"id\": \"40\", \"name\": \"Oklahoma\", \"density\": 55.22}, {\"id\": \"41\", \"name\": \"Oregon\", \"density\": 40.33}, {\"id\": \"42\", \"name\": \"Pennsylvania\", \"density\": 284.3}, {\"id\": \"44\", \"name\": \"Rhode Island\", \"density\": 1006}, {\"id\": \"45\", \"name\": \"South Carolina\", \"density\": 155.4}, {\"id\": \"46\", \"name\": \"South Dakota\", \"density\": 98.07}, {\"id\": \"47\", \"name\": \"Tennessee\", \"density\": 88.08}, {\"id\": \"48\", \"name\": \"Texas\", \"density\": 98.07}, {\"id\": \"49\", \"name\": \"Utah\", \"density\": 34.3}, {\"id\": \"50\", \"name\": \"Vermont\", \"density\": 67.73}, {\"id\": \"51\", \"name\": \"Virginia\", \"density\": 204.5}, {\"id\": \"53\", \"name\": \"Washington\", \"density\": 102.6}, {\"id\": \"54\", \"name\": \"West Virginia\", \"density\": 77.06}, {\"id\": \"55\", \"name\": \"Wisconsin\", \"density\": 105.2}, {\"id\": \"56\", \"name\": \"Wyoming\", \"density\": 5.851}, {\"id\": \"72\", \"name\": \"Puerto Rico\", \"density\": 1082}]\n", @@ -1050,334 +157,9 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# must be JSON object (need to extend to use referenced JSON file)\n", "data = [{\"id\": \"01\", \"name\": \"Alabama\", \"density\": 94.65}, {\"id\": \"02\", \"name\": \"Alaska\", \"density\": 1.264}, {\"id\": \"04\", \"name\": \"Arizona\", \"density\": 57.05}, {\"id\": \"05\", \"name\": \"Arkansas\", \"density\": 56.43}, {\"id\": \"06\", \"name\": \"California\", \"density\": 241.7}, {\"id\": \"08\", \"name\": \"Colorado\", \"density\": 49.33}, {\"id\": \"09\", \"name\": \"Connecticut\", \"density\": 739.1}, {\"id\": \"10\", \"name\": \"Delaware\", \"density\": 464.3}, {\"id\": \"11\", \"name\": \"District of Columbia\", \"density\": 10065}, {\"id\": \"12\", \"name\": \"Florida\", \"density\": 353.4}, {\"id\": \"13\", \"name\": \"Georgia\", \"density\": 169.5}, {\"id\": \"15\", \"name\": \"Hawaii\", \"density\": 214.1}, {\"id\": \"16\", \"name\": \"Idaho\", \"density\": 19.15}, {\"id\": \"17\", \"name\": \"Illinois\", \"density\": 231.5}, {\"id\": \"18\", \"name\": \"Indiana\", \"density\": 181.7}, {\"id\": \"19\", \"name\": \"Iowa\", \"density\": 54.81}, {\"id\": \"20\", \"name\": \"Kansas\", \"density\": 35.09}, {\"id\": \"21\", \"name\": \"Kentucky\", \"density\": 110}, {\"id\": \"22\", \"name\": \"Louisiana\", \"density\": 105}, {\"id\": \"23\", \"name\": \"Maine\", \"density\": 43.04}, {\"id\": \"24\", \"name\": \"Maryland\", \"density\": 596.3}, {\"id\": \"25\", \"name\": \"Massachusetts\", \"density\": 840.2}, {\"id\": \"26\", \"name\": \"Michigan\", \"density\": 173.9}, {\"id\": \"27\", \"name\": \"Minnesota\", \"density\": 67.14}, {\"id\": \"28\", \"name\": \"Mississippi\", \"density\": 63.5}, {\"id\": \"29\", \"name\": \"Missouri\", \"density\": 87.26}, {\"id\": \"30\", \"name\": \"Montana\", \"density\": 6.858}, {\"id\": \"31\", \"name\": \"Nebraska\", \"density\": 23.97}, {\"id\": \"32\", \"name\": \"Nevada\", \"density\": 24.8}, {\"id\": \"33\", \"name\": \"New Hampshire\", \"density\": 147}, {\"id\": \"34\", \"name\": \"New Jersey\", \"density\": 1189}, {\"id\": \"35\", \"name\": \"New Mexico\", \"density\": 17.16}, {\"id\": \"36\", \"name\": \"New York\", \"density\": 412.3}, {\"id\": \"37\", \"name\": \"North Carolina\", \"density\": 198.2}, {\"id\": \"38\", \"name\": \"North Dakota\", \"density\": 9.916}, {\"id\": \"39\", \"name\": \"Ohio\", \"density\": 281.9}, {\"id\": \"40\", \"name\": \"Oklahoma\", \"density\": 55.22}, {\"id\": \"41\", \"name\": \"Oregon\", \"density\": 40.33}, {\"id\": \"42\", \"name\": \"Pennsylvania\", \"density\": 284.3}, {\"id\": \"44\", \"name\": \"Rhode Island\", \"density\": 1006}, {\"id\": \"45\", \"name\": \"South Carolina\", \"density\": 155.4}, {\"id\": \"46\", \"name\": \"South Dakota\", \"density\": 98.07}, {\"id\": \"47\", \"name\": \"Tennessee\", \"density\": 88.08}, {\"id\": \"48\", \"name\": \"Texas\", \"density\": 98.07}, {\"id\": \"49\", \"name\": \"Utah\", \"density\": 34.3}, {\"id\": \"50\", \"name\": \"Vermont\", \"density\": 67.73}, {\"id\": \"51\", \"name\": \"Virginia\", \"density\": 204.5}, {\"id\": \"53\", \"name\": \"Washington\", \"density\": 102.6}, {\"id\": \"54\", \"name\": \"West Virginia\", \"density\": 77.06}, {\"id\": \"55\", \"name\": \"Wisconsin\", \"density\": 105.2}, {\"id\": \"56\", \"name\": \"Wyoming\", \"density\": 5.851}, {\"id\": \"72\", \"name\": \"Puerto Rico\", \"density\": 1082}]\n", @@ -1405,6 +187,32 @@ "viz.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add 3-D Extrusion to Vector Choropleth Map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# adjust view angle\n", + "viz.bearing = -15\n", + "viz.pitch = 45\n", + "\n", + "# add extrusion to viz using interpolation keyed on density in GeoJSON features\n", + "viz.height_property = 'density'\n", + "viz.height_stops = create_numeric_stops([0, 50, 100, 500, 1500, 5000], 0, 500000)\n", + "viz.height_function_type = 'interpolate'\n", + "\n", + "# render again\n", + "viz.show()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -1414,60 +222,9 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Zip Code ZCTA2010 Census Population
0100116769
1100229049
\n", - "
" - ], - "text/plain": [ - " Zip Code ZCTA 2010 Census Population\n", - "0 1001 16769\n", - "1 1002 29049" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Load data from sample csv\n", "import pandas as pd\n", @@ -1479,7 +236,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1495,334 +252,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Create the viz\n", "viz = ChoroplethViz(data, \n", @@ -1850,9 +282,9 @@ "summary": "Mapboxgl Python Data Visualization example" }, "kernelspec": { - "display_name": "Python3.6 Conda", + "display_name": "Python 3", "language": "python", - "name": "myenv" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -1864,7 +296,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.3" + "version": "3.6.1" } }, "nbformat": 4, diff --git a/mapboxgl/templates/base.html b/mapboxgl/templates/base.html index 7a054cc..e44948c 100644 --- a/mapboxgl/templates/base.html +++ b/mapboxgl/templates/base.html @@ -9,6 +9,8 @@ style: {{ style }}, center: {{ center }}, zoom: {{ zoom }}, + pitch: {{ pitch }}, + bearing: {{ bearing }}, transformRequest: (url, resourceType)=> { if ( url.slice(0,22) == 'https://api.mapbox.com' ) { //Add Python Plugin identifier for Mapbox API traffic diff --git a/mapboxgl/templates/choropleth.html b/mapboxgl/templates/choropleth.html index 3e72f16..be2b605 100644 --- a/mapboxgl/templates/choropleth.html +++ b/mapboxgl/templates/choropleth.html @@ -74,7 +74,24 @@ "text-halo-color": "white", "text-halo-width": 1 } - }, "{{belowLayer}}" ); + }, "{{ belowLayer }}" ); + + // Optional extrusion layer + {% if extrudeChoropleth %} + + map.addLayer({ + id: "choropleth-extrusion", + type: "fill-extrusion", + source: "data", + paint: { + "fill-extrusion-opacity": {{ opacity }}, + "fill-extrusion-color": generatePropertyExpression("{{ colorType }}", "{{ colorProperty }}", {{ colorStops }}, "{{ defaultColor }}"), + "fill-extrusion-height": generatePropertyExpression("{{ heightType }}", "{{ heightProperty }}", {{ heightStops }}, {{ defaultHeight }}), + } + }, "{{ belowLayer }}"); + + {% endif %} + {% endblock choropleth %} diff --git a/mapboxgl/templates/vector_choropleth.html b/mapboxgl/templates/vector_choropleth.html index 3bb0ee4..582b33a 100644 --- a/mapboxgl/templates/vector_choropleth.html +++ b/mapboxgl/templates/vector_choropleth.html @@ -6,13 +6,19 @@ {% if joinData %} let joinData = {{ joinData }}; - var popUpKeys = {}; + var popUpKeys = {}, + heightPopUpKeys = {}; // Create filter for layers from join data - let layerFilter = ['in', "{{ vectorJoinColorProperty }}"] + let layerFilter = ['in', "{{ vectorJoinDataProperty }}"] joinData.forEach(function(row, index) { popUpKeys[row["{{ dataJoinProperty }}"]] = row["{{ colorProperty }}"]; + + {% if extrudeChoropleth %} + heightPopUpKeys[row["{{ dataJoinProperty }}"]] = row["{{ heightProperty }}"]; + {% endif %} + layerFilter.push(row["{{ dataJoinProperty }}"]); }); @@ -33,7 +39,7 @@ "paint": { "fill-color": { "type": "categorical", - "property": "{{ vectorJoinColorProperty }}", + "property": "{{ vectorJoinDataProperty }}", "stops": {{ vectorColorStops }}, "default": "{{ defaultColor }}" }, @@ -81,7 +87,35 @@ "text-halo-width": 1 }, filter: layerFilter - }, "{{belowLayer}}" ); + }, "{{ belowLayer }}" ); + + // Optional extrusion layer + {% if extrudeChoropleth %} + + map.addLayer({ + id: "choropleth-extrusion", + type: "fill-extrusion", + "source": "vector-data", + "source-layer": "{{ vectorLayer }}", + paint: { + "fill-extrusion-opacity": {{ opacity }}, + "fill-extrusion-color": { + "type": "categorical", + "property": "{{ vectorJoinDataProperty }}", + "stops": {{ vectorColorStops }}, + "default": "{{ defaultColor }}" + }, + "fill-extrusion-height": { + "type": "categorical", + "property": "{{ vectorJoinDataProperty }}", + "stops": {{ vectorHeightStops }}, + "default": {{ defaultHeight }} + } + }, + filter: layerFilter + }, "{{ belowLayer }}"); + + {% endif %} {% endblock choropleth %} @@ -99,7 +133,13 @@ } // Add property from joined data to vector's feature popup - popup_html += '
  • ' + "{{ colorProperty }}".toUpperCase() + ': ' + popUpKeys[f.properties["{{ vectorJoinColorProperty }}"]] + '
  • ' + popup_html += '
  • ' + "{{ colorProperty }}".toUpperCase() + ': ' + popUpKeys[f.properties["{{ vectorJoinDataProperty }}"]] + '
  • ' + + {% if extrudeChoropleth %} + + popup_html += '
  • ' + "{{ heightProperty }}".toUpperCase() + ': ' + heightPopUpKeys[f.properties["{{ vectorJoinDataProperty }}"]] + '
  • ' + + {% endif %} popup_html += '' popup.setLngLat(e.lngLat) diff --git a/mapboxgl/utils.py b/mapboxgl/utils.py index 0951b25..73bdcad 100644 --- a/mapboxgl/utils.py +++ b/mapboxgl/utils.py @@ -110,6 +110,13 @@ def create_weight_stops(breaks): return stops +def create_numeric_stops(breaks, min_value, max_value): + """Convert data breaks into a general numeric ramp (height, radius, weight, etc.) + """ + weight_breaks = scale_between(min_value, max_value, len(breaks)) + return [list(x) for x in zip(breaks, weight_breaks)] + + def create_color_stops(breaks, colors='RdYlGn', color_ramps=color_ramps): """Convert a list of breaks into color stops using colors from colorBrewer or a custom list of color values in RGB, RGBA, HSL, CSS text, or HEX format. @@ -258,3 +265,66 @@ def img_encode(arr, **kwargs): img_str = base64.b64encode(sio.getvalue()).decode() return 'data:image/{};base64,{}'.format(img_format, img_str) + + +def height_map(lookup, height_stops, default_height=0.0): + """Return a height value (in meters) interpolated from given height_stops; + for use with vector-based visualizations using fill-extrusion layers + """ + # if no height_stops, use default height + if len(height_stops) == 0: + return default_height + + # dictionary to lookup height from match-type height_stops + match_map = dict((x, y) for (x, y) in height_stops) + + # if lookup matches stop exactly, return corresponding height (first priority) + # (includes non-numeric height_stop "keys" for finding height by match) + if lookup in match_map.keys(): + return match_map.get(lookup) + + # if lookup value numeric, map height by interpolating from height scale + if isinstance(lookup, (int, float, complex)): + + # try ordering stops + try: + stops, heights = zip(*sorted(height_stops)) + + # if not all stops are numeric, attempt looking up as if categorical stops + except TypeError: + return match_map.get(lookup, default_height) + + # for interpolation, all stops must be numeric + if not all(isinstance(x, (int, float, complex)) for x in stops): + return default_height + + # check if lookup value in stops bounds + if float(lookup) <= stops[0]: + return heights[0] + + elif float(lookup) >= stops[-1]: + return heights[-1] + + # check if lookup value matches any stop value + elif float(lookup) in stops: + return heights[stops.index(lookup)] + + # interpolation required + else: + + # identify bounding height stop values + lower = max([stops[0]] + [x for x in stops if x < lookup]) + upper = min([stops[-1]] + [x for x in stops if x > lookup]) + + # heights from bounding stops + lower_height = heights[stops.index(lower)] + upper_height = heights[stops.index(upper)] + + # compute linear "relative distance" from lower bound height to upper bound height + distance = (lookup - lower) / (upper - lower) + + # return string representing rgb height value + return lower_height + distance * (upper_height - lower_height) + + # default height value catch-all + return default_height diff --git a/mapboxgl/viz.py b/mapboxgl/viz.py index 1af855f..2b28d90 100644 --- a/mapboxgl/viz.py +++ b/mapboxgl/viz.py @@ -6,7 +6,7 @@ import numpy from mapboxgl.errors import TokenError -from mapboxgl.utils import color_map +from mapboxgl.utils import color_map, height_map from mapboxgl import templates from mapboxgl.utils import img_encode @@ -28,7 +28,9 @@ def __init__(self, width='100%', zoom=0, min_zoom=0, - max_zoom=24): + max_zoom=24, + pitch=0, + bearing=0): """Construct a MapViz object :param data: GeoJSON Feature Collection @@ -40,6 +42,8 @@ def __init__(self, :param height: The CSS height of the HTML map div in % or pixels. :param zoom: starting zoom level for map :param opacity: opacity of map data layer + :param pitch: starting pitch (in degrees) for map + :param bearing: starting bearing (in degrees) for map """ if access_token is None: @@ -62,6 +66,8 @@ def __init__(self, self.label_property = None self.min_zoom = min_zoom self.max_zoom = max_zoom + self.pitch = pitch + self.bearing = bearing def as_iframe(self, html_data): """Build the HTML representation for the mapviz.""" @@ -102,7 +108,9 @@ def create_html(self): belowLayer=self.below_layer, opacity=self.opacity, minzoom=self.min_zoom, - maxzoom=self.max_zoom) + maxzoom=self.max_zoom, + pitch=self.pitch, + bearing=self.bearing) if self.label_property is None: options.update(labelProperty=None) @@ -385,6 +393,10 @@ def __init__(self, line_color='white', line_stroke='solid', line_width=1, + height_property=None, + height_stops=None, + height_default=0.0, + height_function_type='interpolate', *args, **kwargs): """Construct a Mapviz object @@ -395,14 +407,17 @@ def __init__(self, :param vector_join_property: property to aid in determining color for styling vector polygons :param data_join_property: property to join json data to vector features :param label_property: property to use for marker label - :param color_property: property to determine circle color - :param color_stops: property to determine circle color - :param color_default: property to determine default circle color if match lookup fails + :param color_property: property to determine polygon color + :param color_stops: property to determine polygon color + :param color_default: property to determine default polygon color if match lookup fails :param color_function_type: property to determine `type` used by Mapbox to assign color :param line_color: property to determine choropleth line color :param line_stroke: property to determine choropleth line stroke (solid, dashed, dotted, dash dot) :param line_width: property to determine choropleth line width - + :param height_property: feature property for determining polygon height in 3D extruded choropleth map + :param height_stops: property for determining 3D extrusion height + :param height_default: default height for 3D extruded polygons + :param height_function_type: roperty to determine `type` used by Mapbox to assign height """ super(ChoroplethViz, self).__init__(data, *args, **kwargs) @@ -426,6 +441,10 @@ def __init__(self, self.line_color = line_color self.line_stroke = line_stroke self.line_width = line_width + self.height_property = height_property + self.height_stops = height_stops + self.height_default = height_default + self.height_function_type = height_function_type def generate_vector_color_map(self): """Generate color stops array for use with match expression in mapbox template""" @@ -440,6 +459,23 @@ def generate_vector_color_map(self): return vector_stops + def generate_vector_height_map(self): + """Generate height stops array for use with match expression in mapbox template""" + vector_stops = [] + + if self.height_function_type == 'match': + match_height = self.height_stops + + for row in self.data: + + # map height to JSON feature using height_property + height = height_map(row[self.height_property], self.height_stops, self.height_default) + + # link to vector feature using data_join_property (from JSON object) + vector_stops.append([row[self.data_join_property], height]) + + return vector_stops + def add_unique_template_variables(self, options): """Update map template variables specific to heatmap visual""" @@ -456,6 +492,9 @@ def add_unique_template_variables(self, options): # default to solid line self.line_dash_array = [1, 0] + # check if choropleth map should include 3-D extrusion + self.extrude = all([bool(self.height_property), bool(self.height_stops)]) + # common variables for vector and geojson-based choropleths options.update(dict( colorStops=self.color_stops, @@ -466,7 +505,15 @@ def add_unique_template_variables(self, options): lineDashArray=self.line_dash_array, lineStroke=self.line_stroke, lineWidth=self.line_width, + extrudeChoropleth=self.extrude, )) + if self.extrude: + options.update(dict( + heightType=self.height_function_type, + heightProperty=self.height_property, + heightStops=self.height_stops, + defaultHeight=self.height_default, + )) # vector-based choropleth map variables if self.vector_source: @@ -474,11 +521,15 @@ def add_unique_template_variables(self, options): vectorUrl=self.vector_url, vectorLayer=self.vector_layer_name, vectorColorStops=self.generate_vector_color_map(), - vectorJoinColorProperty=self.vector_join_property, + vectorJoinDataProperty=self.vector_join_property, joinData=json.dumps(self.data, ensure_ascii=False), dataJoinProperty=self.data_join_property, )) - + if self.extrude: + options.update(dict( + vectorHeightStops=self.generate_vector_height_map(), + )) + # geojson-based choropleth map variables else: options.update(dict( diff --git a/tests/test_html.py b/tests/test_html.py index 5887474..6f4ca41 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -8,7 +8,7 @@ from mapboxgl.viz import * from mapboxgl.errors import TokenError -from mapboxgl.utils import create_color_stops +from mapboxgl.utils import create_color_stops, create_numeric_stops from matplotlib.pyplot import imread @@ -193,6 +193,34 @@ def test_display_vector_ChoroplethViz(display): display.assert_called_once() +@patch('mapboxgl.viz.display') +def test_display_vector_extruded_ChoroplethViz(display): + """Assert that show calls the mocked display function when using data-join technique + for ChoroplethViz. + """ + data = [{"id": "06", "name": "California", "density": 241.7}, + {"id": "11", "name": "District of Columbia", "density": 10065}, + {"id": "25", "name": "Massachusetts", "density": 840.2}, + {"id": "30", "name": "Montana", "density": 6.858}, + {"id": "36", "name": "New York", "density": 412.3}, + {"id": "49", "name": "Utah", "density": 34.3}, + {"id": "72", "name": "Puerto Rico", "density": 1082}] + + viz = ChoroplethViz(data, + vector_url='mapbox://mapbox.us_census_states_2015', + vector_layer_name='states', + vector_join_property='STATEFP', + data_join_property='id', + color_property='density', + color_stops=create_color_stops([0, 50, 100, 500, 1500], colors='YlOrRd'), + height_property='density', + height_stops=create_numeric_stops([0, 50, 100, 500, 1500, 10000], 0, 1000000), + access_token=TOKEN + ) + viz.show() + display.assert_called_once() + + @patch('mapboxgl.viz.display') def test_min_zoom(display, data): viz = GraduatedCircleViz(data, diff --git a/tests/test_utils.py b/tests/test_utils.py index 9065707..82af9dd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,8 +7,8 @@ from matplotlib.pyplot import imread from mapboxgl.utils import (df_to_geojson, scale_between, create_radius_stops, - create_weight_stops, create_color_stops, img_encode, - rgb_tuple_from_str, color_map) + create_weight_stops, create_numeric_stops, create_color_stops, + img_encode, rgb_tuple_from_str, color_map, height_map) @pytest.fixture() @@ -153,3 +153,14 @@ def test_color_map_interp_exact(): assert color_map(0.0, interp_stops, 'rgb(32,32,32)') == 'rgb(255,0,0)' +def test_create_numeric_stops(): + """Create numeric stops from custom breaks""" + domain = [7678.214347826088, 5793.63142857143, 1200] + stops = create_numeric_stops(domain, 1, 10) + assert stops == [[7678.214347826088, 1.0], [5793.63142857143, 4.0], [1200, 7.0]] + + +def test_height_map(): + """Interpolate height from numeric height stops""" + stops = [[0.0, 0], [50.0, 5000.0], [1000.0, 100000.0]] + assert height_map(117.0, stops, default_height=0.0) == 11700.0 From 51e021d1ce503d994a8b080f8ecdf3bdf3915e9d Mon Sep 17 00:00:00 2001 From: akacarlyann Date: Sat, 14 Apr 2018 14:44:09 -0700 Subject: [PATCH 2/3] Remove duplicate add to data-join tooltip if colorProperty == heightProperty; add note to legend title if two properties used for data-driven styling --- mapboxgl/templates/choropleth.html | 13 ++++++++++++- mapboxgl/templates/vector_choropleth.html | 10 ++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/mapboxgl/templates/choropleth.html b/mapboxgl/templates/choropleth.html index be2b605..21d97f8 100644 --- a/mapboxgl/templates/choropleth.html +++ b/mapboxgl/templates/choropleth.html @@ -10,7 +10,18 @@ {% block legend %} var legend = document.getElementById('legend'); - calcCircleColorLegend({{ colorStops }}, "{{ colorProperty }}"); + + {% if extrudeChoropleth %} + {% if colorStops and colorProperty and heightProperty %} + {% if colorProperty != heightProperty and extrudeChoropleth %} + calcCircleColorLegend({{ colorStops }}, "{{ colorProperty }} vs. {{ heightProperty }}"); + {% else %} + calcCircleColorLegend({{ colorStops }}, "{{ colorProperty }}"); + {% endif %} + {% endif %} + {% else %} + calcCircleColorLegend({{ colorStops }}, "{{ colorProperty }}"); + {% endif %} {% endblock legend %} diff --git a/mapboxgl/templates/vector_choropleth.html b/mapboxgl/templates/vector_choropleth.html index 582b33a..139b913 100644 --- a/mapboxgl/templates/vector_choropleth.html +++ b/mapboxgl/templates/vector_choropleth.html @@ -16,7 +16,9 @@ popUpKeys[row["{{ dataJoinProperty }}"]] = row["{{ colorProperty }}"]; {% if extrudeChoropleth %} - heightPopUpKeys[row["{{ dataJoinProperty }}"]] = row["{{ heightProperty }}"]; + {% if colorProperty != heightProperty %} + heightPopUpKeys[row["{{ dataJoinProperty }}"]] = row["{{ heightProperty }}"]; + {% endif %} {% endif %} layerFilter.push(row["{{ dataJoinProperty }}"]); @@ -136,9 +138,9 @@ popup_html += '
  • ' + "{{ colorProperty }}".toUpperCase() + ': ' + popUpKeys[f.properties["{{ vectorJoinDataProperty }}"]] + '
  • ' {% if extrudeChoropleth %} - - popup_html += '
  • ' + "{{ heightProperty }}".toUpperCase() + ': ' + heightPopUpKeys[f.properties["{{ vectorJoinDataProperty }}"]] + '
  • ' - + {% if colorProperty != heightProperty %} + popup_html += '
  • ' + "{{ heightProperty }}".toUpperCase() + ': ' + heightPopUpKeys[f.properties["{{ vectorJoinDataProperty }}"]] + '
  • ' + {% endif %} {% endif %} popup_html += '' From 92178f4d4bd9433737acb06381ed14b27c76c55c Mon Sep 17 00:00:00 2001 From: akacarlyann Date: Mon, 16 Apr 2018 14:30:25 -0700 Subject: [PATCH 3/3] Extend tests for height_map function --- tests/test_utils.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 82af9dd..687bb1d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -163,4 +163,28 @@ def test_create_numeric_stops(): def test_height_map(): """Interpolate height from numeric height stops""" stops = [[0.0, 0], [50.0, 5000.0], [1000.0, 100000.0]] - assert height_map(117.0, stops, default_height=0.0) == 11700.0 + assert height_map(117.0, stops, 0.0) == 11700.0 + + +def test_height_map_match(): + """Interpolate height from numeric height stops""" + match_stops = [['road', 1.0], ['fence', 15.0], ['wall', 10.0]] + assert height_map('fence', match_stops, 0.0) == 15.0 + + +def test_height_map_no_stops(): + """Return default if length of stops argument is 0""" + stops = [] + assert height_map(117.0, stops, 42) == 42 + + +def test_height_map_default(): + """Default value when look up does not match any stop in stops""" + stops = [[0.0, 0], [50.0, 5000.0], [1000.0, 100000.0]] + assert height_map(-1.0, stops, 42) == 0 + + +def test_height_map_exact(): + """Compute mapping for lookup value exactly matching numeric stop in stops""" + stops = [[0.0, 0], [50.0, 5000.0], [1000.0, 100000.0]] + assert height_map(50.0, stops, 42) == 5000.0 \ No newline at end of file