diff --git a/docs-markdown/viz.md b/docs-markdown/viz.md index 25133b5..5422dbe 100644 --- a/docs-markdown/viz.md +++ b/docs-markdown/viz.md @@ -458,4 +458,74 @@ viz.show() ![RasterTilesViz](https://user-images.githubusercontent.com/10407788/37537676-b055a108-2924-11e8-94cb-ad3203b736af.jpg) -[Complete example](https://github.com/mapbox/mapboxgl-jupyter/blob/master/examples/rastertile-viz-types-example.ipynb) \ No newline at end of file +[Complete example](https://github.com/mapbox/mapboxgl-jupyter/blob/master/examples/rastertile-viz-types-example.ipynb) + + +## class LinestringViz + +The `LinestringViz` object handles the creation of a vector or GeoJSON-based Linestring visualization and inherits from the `MapViz` class. + +### Params +**LinestringViz**(_data, vector_url=None, vector_layer_name=None, vector_join_property=None, data_join_property=None, label_property=None, label_size=8, label_color='#131516', label_halo_color='white', label_halo_width=1, color_property=None, color_stops=None, color_default='grey', color_function_type='interpolate', line_stroke='solid', line_width_property=None, line_width_stops=None, line_width_default=1, line_width_function_type='interpolate', *args, **kwargs_) + + +Parameter | Description | Example +--|--|-- +data | can be either GeoJSON (containing polygon features) or JSON for data-join technique with vector polygons | +vector_url | optional property to define vector linestring source | "mapbox://mapbox.mapbox-terrain-v2" +vector_layer_name | property to define target layer of vector source if using vector linestring source | "contour" +vector_join_property | property to aid in determining color for styling vector lines | "ele" +data_join_property | property of json data to use as link to vector features | "elevation" +label_property | property to use for marker label | "elevation" +label_size | size of label text | 8 +label_color | color of label text | '#131516' +label_halo_color | color of label text halo | 'white' +label_halo_width | width of label text halo | 1 +color_property | property to determine line color | "elevation" +color_stops | property to determine line color | [[0, "red"], [0.5, "blue"], [1, "green"]] +color_default | property to determine default line color if match lookup fails | "#F0F0F0" +color_function_type | property to determine type of expression used by Mapbox to assign color | "interpolate" +line_stroke | property to determine line stroke (one of solid (-), dashed (--), dotted (:), dash dot (-.)) | "solid" or "-" +line_width_property | feature property for determining line width | "elevation" +line_width_stops | property to determine line width | [[0, 1], [50000, 2], [150000, 3]] +line_width_default | property to determine default line width if match lookup fails | 1.0 +line_width_function_type | property to determine `type` used by Mapbox to assign line width | "interpolate" + +[MapViz options](https://github.com/mapbox/mapboxgl-jupyter/blob/master/docs-markdown/viz.md#params) + +### Usage +```python +import random +import os + +from mapboxgl.viz import LinestringViz +from mapboxgl.utils import create_color_stops + +# Must be a public token, starting with `pk` +token = os.getenv('MAPBOX_ACCESS_TOKEN') + +# JSON join-data object +data = [{"elevation": x, "weight": random.randint(0,100)} for x in range(0, 21000, 10)] + +viz = LinestringViz(data, + vector_url='mapbox://mapbox.mapbox-terrain-v2', + vector_layer_name='contour', + vector_join_property='ele', + data_join_property='elevation', + color_property='elevation', + color_stops=create_color_stops([0, 25, 50, 75, 100], colors='YlOrRd'), + line_stroke='-', + line_width_default=2, + opacity=0.8, + center=(-122.48, 37.83), + zoom=16, + below_layer='waterway-label' + ) +viz.show() +``` + +![LinestringViz](https://user-images.githubusercontent.com/13527707/39278071-02b6b2fc-48a6-11e8-8492-ae1f991b4b9e.png) + + +[Complete example](https://github.com/mapbox/mapboxgl-jupyter/blob/master/examples/notebooks/linestring-viz.ipynb) + diff --git a/examples/notebooks/linestring-viz.ipynb b/examples/notebooks/linestring-viz.ipynb new file mode 100644 index 0000000..44660d0 --- /dev/null +++ b/examples/notebooks/linestring-viz.ipynb @@ -0,0 +1,179 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Mapboxgl Python Library for location data visualizaiton\n", + "\n", + "https://github.com/mapbox/mapboxgl-jupyter\n", + "\n", + "\n", + "# Linestring Visualizations" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "import os\n", + "\n", + "from mapboxgl.viz import LinestringViz\n", + "from mapboxgl.utils import create_color_stops, create_numeric_stops\n", + "\n", + "# Must be a public token, starting with `pk`\n", + "token = os.getenv('MAPBOX_ACCESS_TOKEN')\n", + "\n", + "# JSON join-data object\n", + "data = [{\"elevation\": x, \"weight\": random.randint(0,100)} for x in range(0, 21000, 10)]\n", + "\n", + "# GeoJSON data object\n", + "geojson = {\n", + " \"type\": \"FeatureCollection\",\n", + " \"features\": [{\n", + " \"type\": \"Feature\",\n", + " \"id\": \"01\", \n", + " \"properties\": {\"sample\": 50, \"weight\": 1}, \n", + " \"geometry\": {\n", + " \"type\": \"LineString\",\n", + " \"coordinates\": [\n", + " [-122.4833858013153, 37.829607404976734],\n", + " [-122.4830961227417, 37.82932776098012],\n", + " [-122.4830746650696, 37.82932776098012],\n", + " [-122.48218417167662, 37.82889558180985],\n", + " [-122.48218417167662, 37.82890193740421],\n", + " [-122.48221099376678, 37.82868372835086],\n", + " [-122.4822163581848, 37.82868372835086],\n", + " [-122.48205006122589, 37.82801003030873]\n", + " ]\n", + " }\n", + " }, {\n", + " \"type\": \"Feature\",\n", + " \"id\": \"02\",\n", + " \"properties\": {\"sample\": 500, \"weight\": 2},\n", + " \"geometry\": {\n", + " \"type\": \"LineString\",\n", + " \"coordinates\": [\n", + " [-122.4833858013153, 37.929607404976734],\n", + " [-122.4830961227417, 37.83]\n", + " ]\n", + " }\n", + " }, {\n", + " \"type\": \"Feature\",\n", + " \"properties\": {\"sample\": 5000, \"weight\": 1},\n", + " \"geometry\": {\n", + " \"type\": \"LineString\",\n", + " \"coordinates\": [\n", + " [-122.48369693756104, 37.83381888486939],\n", + " [-122.48348236083984, 37.83317489144141],\n", + " [-122.48339653015138, 37.83270036637107],\n", + " [-122.48356819152832, 37.832056363179625],\n", + " [-122.48404026031496, 37.83114119107971],\n", + " [-122.48404026031496, 37.83049717427869],\n", + " [-122.48348236083984, 37.829920943955045],\n", + " [-122.48356819152832, 37.82954808664175],\n", + " [-122.48507022857666, 37.82944639795659],\n", + " [-122.48610019683838, 37.82880236636284],\n", + " [-122.48695850372314, 37.82931081282506],\n", + " [-122.48700141906738, 37.83080223556934],\n", + " [-122.48751640319824, 37.83168351665737],\n", + " [-122.48803138732912, 37.832158048267786],\n", + " [-122.48888969421387, 37.83297152392784],\n", + " [-122.48987674713133, 37.83263257682617],\n", + " [-122.49043464660643, 37.832937629287755],\n", + " [-122.49125003814696, 37.832429207817725],\n", + " [-122.49163627624512, 37.832564787218985],\n", + " [-122.49223709106445, 37.83337825839438],\n", + " [-122.49378204345702, 37.83368330777276]\n", + " ]\n", + " }\n", + " }]\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## GeoJSON Test Linestring Source" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "scrolled": false + }, + "outputs": [], + "source": [ + "# make viz with GeoJSON source\n", + "viz = LinestringViz(geojson, \n", + " color_property='sample',\n", + " color_stops=create_color_stops([0, 50, 100, 500, 1500], colors='Blues'),\n", + " line_stroke='--',\n", + " line_width_property='weight',\n", + " line_width_stops=create_numeric_stops([0, 1, 2, 3, 4, 5], 0, 10),\n", + " opacity=0.8,\n", + " center=(-122.48, 37.83),\n", + " zoom=16,\n", + " below_layer='waterway-label'\n", + " )\n", + "viz.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Vector Linestring Source (Topography)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "viz = LinestringViz(data, \n", + " vector_url='mapbox://mapbox.mapbox-terrain-v2',\n", + " vector_layer_name='contour',\n", + " vector_join_property='ele',\n", + " data_join_property='elevation',\n", + " color_property='elevation',\n", + " color_stops=create_color_stops([0, 25, 50, 75, 100], colors='YlOrRd'),\n", + " line_stroke='-',\n", + " line_width_default=2,\n", + " opacity=0.8,\n", + " center=(-122.48, 37.83),\n", + " zoom=16,\n", + " below_layer='waterway-label'\n", + " )\n", + "viz.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.1" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/mapboxgl/templates/linestring.html b/mapboxgl/templates/linestring.html new file mode 100644 index 0000000..10b5b04 --- /dev/null +++ b/mapboxgl/templates/linestring.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} + + +{% block extra_css %} + +{% endblock extra_css %} + +{% block legend %} + + var legend = document.getElementById('legend'); + + {% if colorStops and colorProperty and widthProperty %} + {% if colorProperty != widthProperty %} + calcCircleColorLegend({{ colorStops }}, "{{ colorProperty }} vs. {{ widthProperty }}"); + {% else %} + calcCircleColorLegend({{ colorStops }}, "{{ colorProperty }}"); + {% endif %} + {% elif colorStops and colorProperty %} + calcCircleColorLegend({{ colorStops }}, "{{ colorProperty }}"); + {% else %} + document.getElementById('legend').style.visibility='hidden'; + {% endif %} + +{% endblock legend %} + +{% block map %} + + map.on('style.load', function() { + + {% block linestring %} + + // Add geojson data source + map.addSource("data", { + "type": "geojson", + "data": {{ geojson_data }}, + "buffer": 1, + "maxzoom": 14 + }); + + // Add data layer + map.addLayer({ + "id": "linestring", + "source": "data", + "type": "line", + "layout": { + "line-join": "round", + "line-cap": "round" + }, + "paint": { + {% if lineDashArray %} + "line-dasharray": {{ lineDashArray }}, + {% endif %} + {% if colorProperty %} + "line-color": generatePropertyExpression("{{ colorType }}", "{{ colorProperty }}", {{ colorStops }}, "{{ defaultColor }}"), + {% else %} + "line-color": "{{ defaultColor }}", + {% endif %} + {% if widthProperty %} + "line-width": generatePropertyExpression("{{ widthType }}", "{{ widthProperty }}", {{ widthStops }}, "{{ defaultWidth }}"), + {% else %} + "line-width": {{ defaultWidth }}, + {% endif %} + "line-opacity": {{ opacity }} + } + }, "{{ belowLayer }}" ); + + // Add label layer + map.addLayer({ + "id": "linestring-label", + "source": "data", + "type": "symbol", + "layout": { + {% if labelProperty %} + "text-field": "{{ labelProperty }}", + {% endif %} + "text-size" : generateInterpolateExpression('zoom', [[0,8],[22,16]] ), + "text-offset": [0,-1] + }, + "paint": { + "text-halo-color": "{{ labelHaloColor }}", + "text-halo-width": generatePropertyExpression('interpolate', 'zoom', [[0,{{ labelHaloWidth }}], [18,5* {{ labelHaloWidth }}]]), + "text-color": "{{ labelColor }}" + } + }, "{{belowLayer}}" ); + + {% endblock linestring %} + + // Create a popup + var popup = new mapboxgl.Popup({ + closeButton: false, + closeOnClick: false + }); + + {% block linestring_popup %} + + // Show the popup on mouseover + map.on('mousemove', 'linestring', function(e) { + map.getCanvas().style.cursor = 'pointer'; + + let f = e.features[0]; + let popup_html = '
'; + + for (key in f.properties) { + popup_html += '
  • ' + key + ': ' + f.properties[key] + '
  • ' + } + + popup_html += '
    ' + popup.setLngLat(e.lngLat) + .setHTML(popup_html) + .addTo(map); + }); + + {% endblock linestring_popup %} + + map.on('mouseleave', 'linestring', function() { + map.getCanvas().style.cursor = ''; + popup.remove(); + }); + + // Fly to on click + map.on('click', 'linestring', function(e) { + map.flyTo({ + center: e.lngLat, + zoom: map.getZoom() + 1 + }); + }); + + }); + +{% endblock map %} diff --git a/mapboxgl/templates/vector_linestring.html b/mapboxgl/templates/vector_linestring.html new file mode 100644 index 0000000..9b099f0 --- /dev/null +++ b/mapboxgl/templates/vector_linestring.html @@ -0,0 +1,123 @@ +{% extends "linestring.html" %} + +{% block linestring %} + + // extract JSON property used for data-driven styling to add to popup + {% if joinData %} + + let joinData = {{ joinData }}; + var popUpKeys = {}, + lineWidthPopUpKeys = {}; + + // Create filter for layers from join data + let layerFilter = ['in', "{{ vectorJoinDataProperty }}"] + + joinData.forEach(function(row, index) { + + {% if colorProperty %} + popUpKeys[row["{{ dataJoinProperty }}"]] = row["{{ colorProperty }}"]; + {% endif %} + + {% if widthProperty %} + {% if colorProperty != widthProperty %} + lineWidthPopUpKeys[row["{{ dataJoinProperty }}"]] = row["{{ widthProperty }}"]; + {% endif %} + {% endif %} + + layerFilter.push(row["{{ dataJoinProperty }}"]); + }); + + {% endif %} + + // Add vector data source + map.addSource("vector-data", { + type: "vector", + url: "{{ vectorUrl }}", + }); + + // Add data layer from the vector tile source with data-driven style + map.addLayer({ + "id": "linestring", + "source": "vector-data", + "source-layer": "{{ vectorLayer }}", + "type": "line", + "layout": { + "line-join": "round", + "line-cap": "round" + }, + "paint": { + {% if lineDashArray %} + "line-dasharray": {{ lineDashArray }}, + {% endif %} + "line-color": { + "type": "categorical", + "property": "{{ vectorJoinDataProperty }}", + "stops": {{ vectorColorStops }}, + "default": "{{ defaultColor }}" + }, + "line-width": { + "type": "categorical", + "property": "{{ vectorJoinDataProperty }}", + "stops": {{ vectorWidthStops }}, + "default": {{ defaultWidth }} + }, + + "line-opacity": {{ opacity }} + }, + filter: layerFilter + }, "{{ belowLayer }}" ); + + // Add label layer + map.addLayer({ + "id": "linestring-label", + "source": "vector-data", + "source-layer": "{{ vectorLayer }}", + "type": "symbol", + "layout": { + {% if labelProperty %} + "text-field": "{{ labelProperty }}", + {% endif %} + "text-size" : generateInterpolateExpression('zoom', [[0,8],[22,16]] ), + "text-offset": [0,-1] + }, + "paint": { + "text-halo-color": "{{ labelHaloColor }}", + "text-halo-width": generatePropertyExpression('interpolate', 'zoom', [[0,{{ labelHaloWidth }}], [18,5* {{ labelHaloWidth }}]]), + "text-color": "{{ labelColor }}" + }, + filter: layerFilter + }, "{{belowLayer}}" ); + +{% endblock linestring %} + +{% block linestring_popup %} + + // Show the popup on mouseover + map.on('mousemove', 'linestring', function(e) { + map.getCanvas().style.cursor = 'pointer'; + + let f = e.features[0]; + let popup_html = '
    '; + + for (key in f.properties) { + popup_html += '
  • ' + key + ': ' + f.properties[key] + '
  • ' + } + + // Add property from joined data to vector's feature popup + {% if colorProperty %} + popup_html += '
  • ' + "{{ colorProperty }}".toUpperCase() + ': ' + popUpKeys[f.properties["{{ vectorJoinDataProperty }}"]] + '
  • ' + {% endif %} + + {% if widthProperty %} + {% if colorProperty != widthProperty %} + popup_html += '
  • ' + "{{ widthProperty }}".toUpperCase() + ': ' + lineWidthPopUpKeys[f.properties["{{ vectorJoinDataProperty }}"]] + '
  • ' + {% endif %} + {% endif %} + + popup_html += '
    ' + popup.setLngLat(e.lngLat) + .setHTML(popup_html) + .addTo(map); + }); + +{% endblock linestring_popup %} diff --git a/mapboxgl/utils.py b/mapboxgl/utils.py index 73bdcad..a6f2019 100644 --- a/mapboxgl/utils.py +++ b/mapboxgl/utils.py @@ -250,6 +250,68 @@ def color_map(lookup, color_stops, default_color='rgb(122,122,122)'): return default_color +def numeric_map(lookup, numeric_stops, default=0.0): + """Return a number value interpolated from given numeric_stops + """ + # if no numeric_stops, use default + if len(numeric_stops) == 0: + return default + + # dictionary to lookup value from match-type numeric_stops + match_map = dict((x, y) for (x, y) in numeric_stops) + + # if lookup matches stop exactly, return corresponding stop (first priority) + # (includes non-numeric numeric_stop "keys" for finding value by match) + if lookup in match_map.keys(): + return match_map.get(lookup) + + # if lookup value numeric, map value by interpolating from scale + if isinstance(lookup, (int, float, complex)): + + # try ordering stops + try: + stops, values = zip(*sorted(numeric_stops)) + + # if not all stops are numeric, attempt looking up as if categorical stops + except TypeError: + return match_map.get(lookup, default) + + # for interpolation, all stops must be numeric + if not all(isinstance(x, (int, float, complex)) for x in stops): + return default + + # check if lookup value in stops bounds + if float(lookup) <= stops[0]: + return values[0] + + elif float(lookup) >= stops[-1]: + return values[-1] + + # check if lookup value matches any stop value + elif float(lookup) in stops: + return values[stops.index(lookup)] + + # interpolation required + else: + + # identify bounding 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]) + + # values from bounding stops + lower_value = values[stops.index(lower)] + upper_value = values[stops.index(upper)] + + # compute linear "relative distance" from lower bound to upper bound + distance = (lookup - lower) / (upper - lower) + + # return interpolated value + return lower_value + distance * (upper_value - lower_value) + + # default value catch-all + return default + + def img_encode(arr, **kwargs): """Encode ndarray to base64 string image data diff --git a/mapboxgl/viz.py b/mapboxgl/viz.py index 2b28d90..d5b8ba1 100644 --- a/mapboxgl/viz.py +++ b/mapboxgl/viz.py @@ -8,7 +8,7 @@ from mapboxgl.errors import TokenError from mapboxgl.utils import color_map, height_map from mapboxgl import templates -from mapboxgl.utils import img_encode +from mapboxgl.utils import img_encode, numeric_map GL_JS_VERSION = 'v0.44.1' @@ -604,3 +604,171 @@ def add_unique_template_variables(self, options): tiles_minzoom=self.tiles_minzoom, tiles_maxzoom=self.tiles_maxzoom, tiles_bounds=self.tiles_bounds if self.tiles_bounds else 'undefined')) + + +class LinestringViz(MapViz): + """Create a linestring viz""" + + def __init__(self, + data, + vector_url=None, + vector_layer_name=None, + vector_join_property=None, + data_join_property=None, + label_property=None, + label_size=8, + label_color='#131516', + label_halo_color='white', + label_halo_width=1, + color_property=None, + color_stops=None, + color_default='grey', + color_function_type='interpolate', + line_stroke='solid', + line_width_property=None, + line_width_stops=None, + line_width_default=1, + line_width_function_type='interpolate', + *args, + **kwargs): + """Construct a Mapviz object + + :param data: can be either GeoJSON (containing polygon features) or JSON for data-join technique with vector polygons + :param vector_url: optional property to define vector linestring source + :param vector_layer_name: property to define target layer of vector source + :param vector_join_property: property to aid in determining color for styling vector lines + :param data_join_property: property to join json data to vector features + :param label_property: property to use for marker label + :param label_size: size of label text + :param label_color: color of label text + :param label_halo_color: color of label text halo + :param label_halo_width: width of label text halo + :param color_property: property to determine line color + :param color_stops: property to determine line color + :param color_default: property to determine default line color if match lookup fails + :param color_function_type: property to determine `type` used by Mapbox to assign color + :param line_stroke: property to determine line stroke (solid, dashed, dotted, dash dot) + :param line_width_property: property to determine line width + :param line_width_stops: property to determine line width + :param line_width_default: property to determine default line width if match lookup fails + :param line_width_function_type: property to determine `type` used by Mapbox to assign line width + + """ + super(LinestringViz, self).__init__(data, *args, **kwargs) + + self.vector_url = vector_url + self.vector_layer_name = vector_layer_name + self.vector_join_property = vector_join_property + self.data_join_property = data_join_property + + if self.vector_url is not None and self.vector_layer_name is not None: + self.template = 'vector_linestring' + self.vector_source = True + else: + self.vector_source = False + self.template = 'linestring' + + self.label_property = label_property + self.label_color = label_color + self.label_size = label_size + self.label_halo_color = label_halo_color + self.label_halo_width = label_halo_width + self.color_property = color_property + self.color_stops = color_stops + self.color_default = color_default + self.color_function_type = color_function_type + self.line_stroke = line_stroke + self.line_width_property = line_width_property + self.line_width_stops = line_width_stops + self.line_width_default = line_width_default + self.line_width_function_type = line_width_function_type + + def generate_vector_color_map(self): + """Generate color stops array for use with match expression in mapbox template""" + vector_stops = [] + for row in self.data: + + # map color to JSON feature using color_property + color = color_map(row[self.color_property], self.color_stops, self.color_default) + + # link to vector feature using data_join_property (from JSON object) + vector_stops.append([row[self.data_join_property], color]) + + return vector_stops + + def generate_vector_width_map(self): + """Generate width stops array for use with match expression in mapbox template""" + vector_stops = [] + + if self.line_width_function_type == 'match': + match_width = self.line_width_stops + + for row in self.data: + + # map width to JSON feature using width_property + width = numeric_map(row[self.line_width_property], self.line_width_stops, self.line_width_default) + + # link to vector feature using data_join_property (from JSON object) + vector_stops.append([row[self.data_join_property], width]) + + return vector_stops + + def add_unique_template_variables(self, options): + """Update map template variables specific to linestring visual""" + + # set line stroke dash interval based on line_stroke property + if self.line_stroke in ["dashed", "--"]: + self.line_dash_array = [6, 4] + elif self.line_stroke in ["dotted", ":"]: + self.line_dash_array = [0.5, 4] + elif self.line_stroke in ["dash dot", "-."]: + self.line_dash_array = [6, 4, 0.5, 4] + elif self.line_stroke in ["solid", "-"]: + self.line_dash_array = [1, 0] + else: + # default to solid line + self.line_dash_array = [1, 0] + + # common variables for vector and geojson-based linestring maps + options.update(dict( + colorStops=self.color_stops, + colorProperty=self.color_property, + colorType=self.color_function_type, + defaultColor=self.color_default, + lineColor=self.color_default, + lineDashArray=self.line_dash_array, + lineStroke=self.line_stroke, + widthStops=self.line_width_stops, + widthProperty=self.line_width_property, + widthType=self.line_width_function_type, + defaultWidth=self.line_width_default, + labelColor=self.label_color, + labelSize=self.label_size, + labelHaloColor=self.label_halo_color, + labelHaloWidth=self.label_halo_width + )) + + # vector-based linestring map variables + if self.vector_source: + options.update(dict( + vectorUrl=self.vector_url, + vectorLayer=self.vector_layer_name, + vectorJoinDataProperty=self.vector_join_property, + vectorColorStops=[[0,self.color_default]], + vectorWidthStops=[[0,self.line_width_default]], + joinData=json.dumps(self.data, ensure_ascii=False), + dataJoinProperty=self.data_join_property, + )) + + if self.color_property: + options.update(dict(vectorColorStops=self.generate_vector_color_map())) + + if self.line_width_property: + options.update(dict(vectorWidthStops=self.generate_vector_width_map())) + + # geojson-based linestring map variables + else: + options.update(dict( + geojson_data=json.dumps(self.data, ensure_ascii=False), + )) + diff --git a/tests/linestrings.geojson b/tests/linestrings.geojson new file mode 100644 index 0000000..9687388 --- /dev/null +++ b/tests/linestrings.geojson @@ -0,0 +1,61 @@ +{ + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "id": "01", + "properties": {"sample": 50, "width": 1}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-122.4833858013153, 37.829607404976734], + [-122.4830961227417, 37.82932776098012], + [-122.4830746650696, 37.82932776098012], + [-122.48218417167662, 37.82889558180985], + [-122.48218417167662, 37.82890193740421], + [-122.48221099376678, 37.82868372835086], + [-122.4822163581848, 37.82868372835086], + [-122.48205006122589, 37.82801003030873] + ] + } + }, { + "type": "Feature", + "id": "02", + "properties": {"sample": 500, "width": 2}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-122.4833858013153, 37.929607404976734], + [-122.4830961227417, 37.83] + ] + } + }, { + "type": "Feature", + "properties": {"sample": 5000, "width": 1}, + "geometry": { + "type": "LineString", + "coordinates": [ + [-122.48369693756104, 37.83381888486939], + [-122.48348236083984, 37.83317489144141], + [-122.48339653015138, 37.83270036637107], + [-122.48356819152832, 37.832056363179625], + [-122.48404026031496, 37.83114119107971], + [-122.48404026031496, 37.83049717427869], + [-122.48348236083984, 37.829920943955045], + [-122.48356819152832, 37.82954808664175], + [-122.48507022857666, 37.82944639795659], + [-122.48610019683838, 37.82880236636284], + [-122.48695850372314, 37.82931081282506], + [-122.48700141906738, 37.83080223556934], + [-122.48751640319824, 37.83168351665737], + [-122.48803138732912, 37.832158048267786], + [-122.48888969421387, 37.83297152392784], + [-122.48987674713133, 37.83263257682617], + [-122.49043464660643, 37.832937629287755], + [-122.49125003814696, 37.832429207817725], + [-122.49163627624512, 37.832564787218985], + [-122.49223709106445, 37.83337825839438], + [-122.49378204345702, 37.83368330777276] + ] + } + }] +} \ No newline at end of file diff --git a/tests/test_html.py b/tests/test_html.py index 6f4ca41..aa86dbf 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,6 +1,7 @@ import os import json import base64 +import random from mock import patch @@ -24,6 +25,12 @@ def polygon_data(): return json.loads(fh.read()) +@pytest.fixture() +def linestring_data(): + with open('tests/linestrings.geojson') as fh: + return json.loads(fh.read()) + + TOKEN = 'pk.abc123' @@ -51,6 +58,14 @@ def test_secret_key_ChoroplethViz(polygon_data): ChoroplethViz(polygon_data, access_token=secret) +def test_secret_key_LinestringViz(linestring_data): + """Secret key raises a token error + """ + secret = 'sk.abc123' + with pytest.raises(TokenError): + LinestringViz(linestring_data, access_token=secret) + + def test_token_env_CircleViz(monkeypatch, data): """Viz can get token from environment if not specified """ @@ -77,6 +92,14 @@ def test_token_env_ChoroplethViz(monkeypatch, polygon_data): assert TOKEN in viz.create_html() +def test_token_env_LinestringViz(monkeypatch, linestring_data): + """Viz can get token from environment if not specified + """ + monkeypatch.setenv('MAPBOX_ACCESS_TOKEN', TOKEN) + viz = LinestringViz(linestring_data, color_property="sample") + assert TOKEN in viz.create_html() + + def test_html_color(data): viz = CircleViz(data, color_property="Avg Medicare Payments", @@ -100,6 +123,14 @@ def test_html_ChoroplethViz(polygon_data): assert "" in viz.create_html() +def test_html_LinestringViz(linestring_data): + viz = LinestringViz(linestring_data, + color_property="sample", + color_stops=[[0.0, "red"], [50.0, "gold"], [1000.0, "blue"]], + access_token=TOKEN) + assert "" in viz.create_html() + + @patch('mapboxgl.viz.display') def test_display_CircleViz(display, data): """Assert that show calls the mocked display function @@ -221,6 +252,40 @@ def test_display_vector_extruded_ChoroplethViz(display): display.assert_called_once() +@patch('mapboxgl.viz.display') +def test_display_LinestringViz(display, linestring_data): + """Assert that show calls the mocked display function + """ + viz = LinestringViz(linestring_data, + color_property="sample", + color_stops=[[0.0, "red"], [50.0, "gold"], [1000.0, "blue"]], + access_token=TOKEN) + viz.show() + display.assert_called_once() + + +@patch('mapboxgl.viz.display') +def test_display_vector_LinestringViz(display): + """Assert that show calls the mocked display function when using data-join technique + for LinestringViz. + """ + data = [{"elevation": x, "weight": random.randint(0,100)} for x in range(0, 21000, 10)] + + viz = LinestringViz(data, + vector_url='mapbox://mapbox.mapbox-terrain-v2', + vector_layer_name='contour', + vector_join_property='ele', + data_join_property='elevation', + color_property="elevation", + color_stops=create_color_stops([0, 50, 100, 500, 1500], colors='YlOrRd'), + line_width_property='weight', + line_width_stops=create_numeric_stops([0, 25, 50, 75, 100], 1, 6), + 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 687bb1d..fb876ca 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,13 +8,14 @@ from mapboxgl.utils import (df_to_geojson, scale_between, create_radius_stops, create_weight_stops, create_numeric_stops, create_color_stops, - img_encode, rgb_tuple_from_str, color_map, height_map) + img_encode, rgb_tuple_from_str, color_map, height_map, numeric_map) @pytest.fixture() def df(): return pd.read_csv('tests/points.csv') + @pytest.fixture() def df_no_properties(): df = pd.read_csv('tests/points.csv') @@ -37,6 +38,7 @@ def test_df_no_properties(df_no_properties): 'features'] assert tuple(features[0]['properties'].keys()) == () + def test_df_geojson_file(df): features = df_to_geojson(df, filename='out.geojson') with open('out.geojson', 'r') as f: @@ -60,26 +62,31 @@ def test_scale_between_maxMin(): scale = scale_between(0,1,1) assert scale == [0,1] + def test_color_stops(): """Create color stops from breaks using colorBrewer""" stops = create_color_stops([0, 1, 2], colors='YlGn') assert stops == [[0,"rgb(247,252,185)"], [1,"rgb(173,221,142)"], [2,"rgb(49,163,84)"]] + def test_color_stops_custom(): """Create color stops from custom color breaks""" stops = create_color_stops([0, 1, 2], colors=['red', 'yellow', 'green']) assert stops == [[0,"red"], [1,"yellow"], [2,"green"]] + def test_color_stops_custom_invalid(): """Create invalid color stops from custom color breaks and throw value error""" with pytest.raises(ValueError): create_color_stops([0, 1, 2], colors=['x', 'yellow', 'green']) + def test_color_stops_custom_null(): """Create invalid number of color stops that do not match the number of breaks""" with pytest.raises(ValueError): create_color_stops([0, 1, 2], colors=['red', 'yellow', 'green', 'grey']) + def test_create_radius_stops(df): domain = [7678.214347826088, 5793.63142857143, 1200] radius_stops = create_radius_stops(domain, 1, 10) @@ -153,6 +160,36 @@ def test_color_map_interp_exact(): assert color_map(0.0, interp_stops, 'rgb(32,32,32)') == 'rgb(255,0,0)' +def test_numeric_map(): + """Map interpolated (or matched) value from numeric stops""" + stops = [[0.0, 0], [50.0, 5000.0], [1000.0, 100000.0]] + assert numeric_map(117.0, stops, 0.0) == 11700.0 + + +def test_numeric_map_match(): + """Match value from numeric stops""" + match_stops = [['road', 1.0], ['fence', 15.0], ['wall', 10.0]] + assert numeric_map('fence', match_stops, 0.0) == 15.0 + + +def test_numeric_map_no_stops(): + """Return default if length of stops argument is 0""" + stops = [] + assert numeric_map(117.0, stops, 42) == 42 + + +def test_numeric_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 numeric_map(-1.0, stops, 42) == 0 + + +def test_numeric_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 numeric_map(50.0, stops, 42) == 5000.0 + + def test_create_numeric_stops(): """Create numeric stops from custom breaks""" domain = [7678.214347826088, 5793.63142857143, 1200] @@ -187,4 +224,4 @@ def test_height_map_default(): 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 + assert height_map(50.0, stops, 42) == 5000.0